From 68d6b88695145dcd815907ed3b31252b6dbf77c2 Mon Sep 17 00:00:00 2001 From: wyn <1074145239@qq.com> Date: Wed, 15 Jul 2026 09:05:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BA=93=E5=AD=98=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/controller/BuyOrderController.java | 44 +- .../BuyOrderBatchDeliveryAsyncService.java | 6 + .../service/impl/BuyOrderServiceImpl.java | 5 + .../entity/BuyOrderBatchDeliveryItem.java | 29 + .../entity/BuyOrderBatchDeliveryTask.java | 31 + .../modules/common/entity/InvStockRecord.java | 79 + .../InventoryManagementController.java | 228 ++ .../master/dto/inventory/InvCostItemDto.java | 11 + .../dto/inventory/InvStockRecordDto.java | 20 + .../service/InventoryManagementService.java | 59 + .../impl/InventoryManagementServiceImpl.java | 1867 +++++++++++++++++ .../mq/Consumer/OrderCancelConsumer.java | 97 +- 12 files changed, 2447 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/peanut/modules/book/service/BuyOrderBatchDeliveryAsyncService.java create mode 100644 src/main/java/com/peanut/modules/common/entity/BuyOrderBatchDeliveryItem.java create mode 100644 src/main/java/com/peanut/modules/common/entity/BuyOrderBatchDeliveryTask.java create mode 100644 src/main/java/com/peanut/modules/common/entity/InvStockRecord.java create mode 100644 src/main/java/com/peanut/modules/master/controller/InventoryManagementController.java create mode 100644 src/main/java/com/peanut/modules/master/dto/inventory/InvCostItemDto.java create mode 100644 src/main/java/com/peanut/modules/master/dto/inventory/InvStockRecordDto.java create mode 100644 src/main/java/com/peanut/modules/master/service/InventoryManagementService.java create mode 100644 src/main/java/com/peanut/modules/master/service/impl/InventoryManagementServiceImpl.java diff --git a/src/main/java/com/peanut/modules/book/controller/BuyOrderController.java b/src/main/java/com/peanut/modules/book/controller/BuyOrderController.java index 9f03a5d..8efdde7 100644 --- a/src/main/java/com/peanut/modules/book/controller/BuyOrderController.java +++ b/src/main/java/com/peanut/modules/book/controller/BuyOrderController.java @@ -31,6 +31,7 @@ import com.peanut.modules.common.dao.UserCourseBuyDao; import com.peanut.modules.common.entity.*; import com.peanut.modules.common.service.*; import com.peanut.modules.master.service.CourseCatalogueService; +import com.peanut.modules.master.service.InventoryManagementService; import com.peanut.modules.pay.alipay.dto.ReFundDTO; import com.peanut.modules.pay.alipay.service.AliPayService; import com.peanut.modules.pay.weChatPay.dto.WeChatRefundInfo; @@ -127,6 +128,8 @@ public class BuyOrderController { private BuyOrderRefundLogService buyOrderRefundLogService; @Autowired private AliPayService aliPayService; + @Autowired + private InventoryManagementService inventoryManagementService; @RequestMapping(value = "/decomposeShipment", method = RequestMethod.POST) public R decomposeShipment(@RequestBody BuyOrderListRequestVo requestVo) { @@ -445,7 +448,7 @@ public class BuyOrderController { price = shopProductService.getVipPrice(product); } } - if (!handleStock(buyOrderProduct, product)) { + if (!handleStock(buyOrderProduct, product, buyOrder)) { return R.error(500, "库存不足"); } if (StringUtils.isNotEmpty(mechStr)){ @@ -511,6 +514,16 @@ public class BuyOrderController { buyOrder.setOrderStatus("0"); buyOrderService.save(buyOrder); + // 订单已有 orderId 后再写 inv 出库流水(只记 order_id,不记 order_sn) + for (BuyOrderProduct buyOrderProduct : buyOrderProductList) { + ShopProduct product = shopProductService.getById(buyOrderProduct.getProductId()); + if (product != null && isBookProduct(product) + && inventoryManagementService.hasInvStockRecord(buyOrderProduct.getProductId(), buyOrder, product)) { + inventoryManagementService.saleByBookOrder( + buyOrderProduct.getProductId(), buyOrderProduct.getQuantity(), buyOrder, product); + } + } + //解决购物车相关问题 for (BuyOrderProduct buyOrderProduct : buyOrderProductList) { buyOrderProduct.setOrderId(buyOrder.getOrderId()); @@ -1259,6 +1272,14 @@ public class BuyOrderController { return (activityPrice == null || activityPrice.equals(BigDecimal.ZERO)) ? product.getPrice() : activityPrice; } + private boolean isBookProduct(ShopProduct product) { + if (product == null || product.getGoodsType() == null) { + return false; + } + System.out.println("=======GoodsType======"+product.getGoodsType()); + return "02".equals(product.getGoodsType()) || "03".equals(product.getGoodsType()) || "04".equals(product.getGoodsType()); + } + /** * 处理商品库存 * TODO 新版本上线后删除此方法 @@ -1295,6 +1316,27 @@ public class BuyOrderController { shopProductService.updateById(product); return true; } + private boolean handleStock(BuyOrderProduct buyOrderProduct, ShopProduct product, BuyOrder buyOrder) { + int quantity = buyOrderProduct.getQuantity(); + if (product.getProductStock() - quantity < 0) { + return false; + } + if (isBookProduct(product)) { + if (inventoryManagementService.hasInvStockRecord(buyOrderProduct.getProductId(), buyOrder, product)) { + Boolean hasProduct = inventoryManagementService.validateBookInvStock( + buyOrderProduct.getProductId(), quantity, buyOrder, product); + if (!hasProduct) { + return false; + } + // inv 出库放到 buyOrder 落库后执行,才能写入 order_id + } + } + product.setProductStock(product.getProductStock() - quantity); + product.setSumSales(product.getSumSales() + quantity); + shopProductService.updateById(product); + + return true; + } diff --git a/src/main/java/com/peanut/modules/book/service/BuyOrderBatchDeliveryAsyncService.java b/src/main/java/com/peanut/modules/book/service/BuyOrderBatchDeliveryAsyncService.java new file mode 100644 index 0000000..d783514 --- /dev/null +++ b/src/main/java/com/peanut/modules/book/service/BuyOrderBatchDeliveryAsyncService.java @@ -0,0 +1,6 @@ +package com.peanut.modules.book.service; + +public interface BuyOrderBatchDeliveryAsyncService { + + void executeBatchDelivery(Integer taskId); +} 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 2792900..2a4cd1e 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 @@ -28,6 +28,7 @@ import com.peanut.modules.book.vo.response.*; import com.peanut.modules.common.entity.*; import com.peanut.modules.common.service.*; import com.peanut.modules.common.vo.CourseCatalogueVo; +import com.peanut.modules.master.service.InventoryManagementService; import com.peanut.modules.oss.service.OssService; import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; @@ -125,6 +126,8 @@ public class BuyOrderServiceImpl extends ServiceImpl impl private BuyOrderBatchDeliveryItemDao batchDeliveryItemDao; @Autowired private BuyOrderBatchDeliveryAsyncService batchDeliveryAsyncService; + @Autowired + private InventoryManagementService inventoryManagementService; //private static final int BATCH_DELIVERY_MAX_SIZE = 100; @Override @@ -1228,6 +1231,8 @@ public class BuyOrderServiceImpl extends ServiceImpl impl removeCourseToUser(buyOrder); //回滚库存 shopProductService.rollbackStock(buyOrder); + // 回滚 inv 售出流水:回加采购 remain_quantity,售出记录 del_flag=1 + inventoryManagementService.rollbackSaleByOrderId(buyOrder.getOrderId()); } } diff --git a/src/main/java/com/peanut/modules/common/entity/BuyOrderBatchDeliveryItem.java b/src/main/java/com/peanut/modules/common/entity/BuyOrderBatchDeliveryItem.java new file mode 100644 index 0000000..72a903f --- /dev/null +++ b/src/main/java/com/peanut/modules/common/entity/BuyOrderBatchDeliveryItem.java @@ -0,0 +1,29 @@ +package com.peanut.modules.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +@Data +@TableName("buy_order_batch_delivery_item") +public class BuyOrderBatchDeliveryItem { + + public static final int STATUS_PENDING = 0; + public static final int STATUS_SUCCESS = 1; + public static final int STATUS_FAILED = 2; + + @TableId(type = IdType.AUTO) + private Integer id; + private Integer taskId; + private Integer orderId; + private String orderSn; + private Integer buyOrderProductId; + /** 0待处理 1成功 2失败 */ + private Integer status; + private String failMessage; + private Date createTime; + private Date updateTime; +} diff --git a/src/main/java/com/peanut/modules/common/entity/BuyOrderBatchDeliveryTask.java b/src/main/java/com/peanut/modules/common/entity/BuyOrderBatchDeliveryTask.java new file mode 100644 index 0000000..b36ecad --- /dev/null +++ b/src/main/java/com/peanut/modules/common/entity/BuyOrderBatchDeliveryTask.java @@ -0,0 +1,31 @@ +package com.peanut.modules.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +@Data +@TableName("buy_order_batch_delivery_task") +public class BuyOrderBatchDeliveryTask { + + public static final int STATUS_PENDING = 0; + public static final int STATUS_RUNNING = 1; + public static final int STATUS_COMPLETED = 2; + public static final int STATUS_FAILED = 3; + + @TableId(type = IdType.AUTO) + private Integer id; + private String expressCompanyCode; + private Integer totalCount; + private Integer successCount; + private Integer failCount; + /** 0待处理 1处理中 2已完成 3异常终止 */ + private Integer status; + private String failMessage; + private Date createTime; + private Date updateTime; + private Date finishTime; +} diff --git a/src/main/java/com/peanut/modules/common/entity/InvStockRecord.java b/src/main/java/com/peanut/modules/common/entity/InvStockRecord.java new file mode 100644 index 0000000..6c95fe2 --- /dev/null +++ b/src/main/java/com/peanut/modules/common/entity/InvStockRecord.java @@ -0,0 +1,79 @@ +package com.peanut.modules.common.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +@Data +@TableName("inv_stock_record") +public class InvStockRecord { + + public static final int BIZ_TYPE_PURCHASE = 1; + public static final int BIZ_TYPE_SALE = 2; + + /** 采购来源:灵枢 */ + public static final String SOURCE_LS = "ls"; + /** 采购来源:众秒 */ + public static final String SOURCE_ZM = "zm"; + /** 采购来源:其他 */ + public static final String SOURCE_OTHER = "other"; + /** 采购来源:出版社 */ + public static final String SOURCE_PUBLISHER = "publisher"; + + /** 出库类型:捐赠 */ + public static final String OUTBOUND_DONATION = "donation"; + /** 出库类型:淘宝 */ + public static final String OUTBOUND_TAOBAO = "taobao"; + /** 出库类型:APP */ + public static final String OUTBOUND_APP = "app"; + /** 出库类型:套书采购绑定(内部组装,导出售出明细时排除) */ + public static final String OUTBOUND_BOOKS = "books"; + + public static final int MERCHANT_LS = 1; + public static final int MERCHANT_ZM = 2; + + /** 订单类型:灵枢 */ + public static final String ORDER_TYPE_LS = "lsorder"; + /** 订单类型:众秒 */ + public static final String ORDER_TYPE_ZM = "order"; + + @TableId(type = IdType.AUTO) + private Long id; + private Integer productId; + /** 商户标识 1灵枢 2众秒 */ + private Integer merchantId; + /** + * 采购来源:ls灵枢 zm众秒 other其他 publisher出版社; + * 售出时也可存出库类型(兼容) + */ + private String source; + /** 出库类型:donation捐赠 taobao淘宝 app,仅售出有值 */ + private String outboundType; + /** 业务类型 1采购 2售出 */ + private Integer bizType; + /** 数量:采购正数,售出负数 */ + private Integer quantity; + /** 剩余库存:采购时等于 quantity,售出时从采购记录扣减 */ + private Integer remainQuantity; + private BigDecimal unitCost; + /** 关联订单ID(图书订单售出时有值) */ + private Integer orderId; + /** 平台/外部订单编号(淘宝等批量导入、图书订单售出时有值) */ + private String orderSn; + /** 订单来源(图书订单售出时有值) */ + private Integer come; + /** 出库所扣减的采购流水ID(售出时有值,退库时回加 remain_quantity) */ + private Long purchaseRecordId; + /** 套书采购流水ID(套书绑定单品出库时有值,指向本次套书采购记录 id) */ + private Long setPurchaseRecordId; + private String remark; + private Date createTime; + /** 0正常 1作废(订单超时取消等软删售出流水) */ + @TableLogic + private Integer delFlag; +} diff --git a/src/main/java/com/peanut/modules/master/controller/InventoryManagementController.java b/src/main/java/com/peanut/modules/master/controller/InventoryManagementController.java new file mode 100644 index 0000000..1b6be33 --- /dev/null +++ b/src/main/java/com/peanut/modules/master/controller/InventoryManagementController.java @@ -0,0 +1,228 @@ +package com.peanut.modules.master.controller; + +import com.peanut.common.utils.R; +import com.peanut.modules.common.entity.InvStockRecord; +import com.peanut.modules.master.dto.inventory.InvStockRecordDto; +import com.peanut.modules.master.service.InventoryManagementService; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.util.List; +import java.util.Map; + +/** + * 库存管理(采购、售出) + */ +@Slf4j +@RestController("inventoryManagement") +@RequestMapping("master/inventoryManagement") +public class InventoryManagementController { + + @Autowired + private InventoryManagementService inventoryManagementService; + + @RequestMapping("/getStockRecordList") + public R getStockRecordList(@RequestBody Map params) { + return R.ok().put("result", inventoryManagementService.pageStockRecords(params)); + } + + @RequestMapping("/getStockRecordDetail") + public R getStockRecordDetail(@RequestBody Map params) { + return R.ok().put("result", inventoryManagementService.getStockRecordDetail( + Long.parseLong(params.get("id").toString()))); + } + + /** + * 采购/售出; + * 采购 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 + */ + @RequestMapping("/purchaseProduct") + public R purchaseProduct(@RequestBody InvStockRecordDto dto) { + try { + if (dto.getBiz_type() != null && dto.getBiz_type() == InvStockRecord.BIZ_TYPE_SALE) { + if ((dto.getOutboundType() == null || dto.getOutboundType().isBlank()) + && dto.getSource() != null && !dto.getSource().isBlank()) { + dto.setOutboundType(dto.getSource()); + } + } else if (dto.getSource() == null || dto.getSource().isBlank()) { + dto.setSource(InvStockRecord.SOURCE_OTHER); + } + inventoryManagementService.purchaseProduct(dto); + return R.ok(); + } catch (RuntimeException e) { + return R.error(e.getMessage()); + } + } + + /** + * Excel批量售出;参数:merchantId、outboundType、file + * Excel列:create_time, productName, quantity, ordersn + * 任一条解析/匹配失败则全部不入库 + */ + @RequestMapping("/batchOutbound") + public R batchSaleByExcel(@RequestParam("merchantId") Integer merchantId, + @RequestParam("outboundType") String outboundType, + @RequestParam("file") MultipartFile file) { + try { + int count = inventoryManagementService.batchSaleByExcel(merchantId, outboundType, file); + return R.ok().put("count", count); + } catch (RuntimeException e) { + return R.error(e.getMessage()); + } + } + + @RequestMapping("/stockStatistics") + public R stockStatistics(@RequestBody Map params) { + if (params.get("merchantId") == null || params.get("merchantId").toString().isBlank()) { + return R.error("merchantId为必填"); + } + return R.ok().put("result", inventoryManagementService.stockStatistics(params)); + } + + /** 采购/售出明细全量导出(忽略 current/limit);支持 merchantId、productName、startTime、endTime、bizType(1入库/2售出,可选) */ + @RequestMapping("/exportStockRecordList") + public void exportStockRecordList(HttpServletResponse response, @RequestBody Map params) { + List> maps = inventoryManagementService.exportStockRecordList(params); + Integer bizType = parseBizType(params.get("bizType")); + XSSFWorkbook wb = new XSSFWorkbook(); + String fileName; + if (bizType != null && bizType == InvStockRecord.BIZ_TYPE_PURCHASE) { + writePurchaseExportSheet(wb, maps); + fileName = "入库明细.xlsx"; + } else if (bizType != null && bizType == InvStockRecord.BIZ_TYPE_SALE) { + writeSaleExportSheet(wb, maps); + fileName = "售出明细.xlsx"; + } else { + List> purchaseRows = maps.stream() + .filter(map -> "purchase".equals(cellStr(map.get("exportCategory")))) + .toList(); + List> saleRows = maps.stream() + .filter(map -> "sale".equals(cellStr(map.get("exportCategory")))) + .toList(); + writePurchaseExportSheet(wb, purchaseRows); + writeSaleExportSheet(wb, saleRows); + fileName = "采购售出明细.xlsx"; + } + writeExcel(response, wb, fileName); + } + + private Integer parseBizType(Object bizTypeObj) { + if (bizTypeObj == null || bizTypeObj.toString().isBlank()) { + return null; + } + return Integer.parseInt(bizTypeObj.toString().trim()); + } + + private void writePurchaseExportSheet(XSSFWorkbook wb, List> maps) { + Sheet sheet = wb.createSheet(wb.getNumberOfSheets() == 0 ? "入库明细" : "入库明细"); + Row titleRow = sheet.createRow(0); + titleRow.createCell(0).setCellValue("商品ID"); + titleRow.createCell(1).setCellValue("商品名称"); + titleRow.createCell(2).setCellValue("供应商类型"); + titleRow.createCell(3).setCellValue("入库时间"); + titleRow.createCell(4).setCellValue("数量"); + titleRow.createCell(5).setCellValue("剩余库存"); + int rowIndex = 1; + for (Map map : maps) { + Row row = sheet.createRow(rowIndex++); + row.createCell(0).setCellValue(cellStr(map.get("productId"))); + row.createCell(1).setCellValue(cellStr(map.get("productName"))); + row.createCell(2).setCellValue(cellStr(map.get("sourceLabel"))); + row.createCell(3).setCellValue(cellStr(map.get("createTime"))); + row.createCell(4).setCellValue(cellStr(map.get("quantity"))); + row.createCell(5).setCellValue(cellStr(map.get("remainQuantity"))); + } + } + + private void writeSaleExportSheet(XSSFWorkbook wb, List> maps) { + String sheetName = wb.getNumberOfSheets() == 0 ? "售出明细" : "售出明细"; + Sheet sheet = wb.createSheet(sheetName); + Row titleRow = sheet.createRow(0); + titleRow.createCell(0).setCellValue("商品ID"); + titleRow.createCell(1).setCellValue("商品名称"); + titleRow.createCell(2).setCellValue("出库类型"); + titleRow.createCell(3).setCellValue("成本"); + titleRow.createCell(4).setCellValue("数量"); + titleRow.createCell(5).setCellValue("订单编号"); + titleRow.createCell(6).setCellValue("出库时间"); + titleRow.createCell(7).setCellValue("come"); + int rowIndex = 1; + for (Map map : maps) { + Row row = sheet.createRow(rowIndex++); + row.createCell(0).setCellValue(cellStr(map.get("productId"))); + row.createCell(1).setCellValue(cellStr(map.get("productName"))); + row.createCell(2).setCellValue(cellStr(map.get("outboundTypeLabel"))); + row.createCell(3).setCellValue(cellStr(map.get("unitCost"))); + row.createCell(4).setCellValue(cellStr(map.get("quantity"))); + row.createCell(5).setCellValue(cellStr(map.get("orderSn"))); + row.createCell(6).setCellValue(cellStr(map.get("createTime"))); + row.createCell(7).setCellValue(cellStr(map.get("come"))); + } + } + + /** 商品库存统计全量导出(忽略 current/limit);支持 merchantId、productName、startTime、endTime、isAlert */ + @RequestMapping("/exportStockStatistics") + public void exportStockStatistics(HttpServletResponse response, @RequestBody Map params) { + List> maps = inventoryManagementService.exportStockStatistics(params); + XSSFWorkbook wb = new XSSFWorkbook(); + Sheet sheet = wb.createSheet("商品库存统计"); + Row titleRow = sheet.createRow(0); + titleRow.createCell(0).setCellValue("商品名称"); + titleRow.createCell(1).setCellValue("采购总数"); + titleRow.createCell(2).setCellValue("售出总数"); + titleRow.createCell(3).setCellValue("剩余库存"); + titleRow.createCell(4).setCellValue("剩余比例"); + int rowIndex = 1; + for (Map map : maps) { + Row row = sheet.createRow(rowIndex); + row.createCell(0).setCellValue(cellStr(map.get("productName"))); + row.createCell(1).setCellValue(cellStr(map.get("purchaseTotal"))); + row.createCell(2).setCellValue(cellStr(map.get("saleTotal"))); + row.createCell(3).setCellValue(cellStr(map.get("remainTotal"))); + row.createCell(4).setCellValue(cellStr(map.get("remainRate"))); + rowIndex++; + } + writeExcel(response, wb, "商品库存统计.xlsx"); + } + + private String cellStr(Object value) { + return value == null ? "" : value.toString(); + } + + private void writeExcel(HttpServletResponse response, XSSFWorkbook wb, String fileName) { + OutputStream outputStream = null; + try { + fileName = URLEncoder.encode(fileName, "UTF-8"); + response.setContentType("application/vnd.ms-excel"); + response.setHeader("Content-disposition", "attachment;filename=" + fileName); + outputStream = response.getOutputStream(); + wb.write(outputStream); + } catch (IOException e) { + log.error("导出Excel失败", e); + } finally { + try { + if (outputStream != null) { + outputStream.flush(); + outputStream.close(); + } + wb.close(); + } catch (IOException e) { + log.error("关闭Excel流失败", e); + } + } + } +} diff --git a/src/main/java/com/peanut/modules/master/dto/inventory/InvCostItemDto.java b/src/main/java/com/peanut/modules/master/dto/inventory/InvCostItemDto.java new file mode 100644 index 0000000..45a4c9b --- /dev/null +++ b/src/main/java/com/peanut/modules/master/dto/inventory/InvCostItemDto.java @@ -0,0 +1,11 @@ +package com.peanut.modules.master.dto.inventory; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class InvCostItemDto { + private BigDecimal unitCost; + private Integer quantity; +} diff --git a/src/main/java/com/peanut/modules/master/dto/inventory/InvStockRecordDto.java b/src/main/java/com/peanut/modules/master/dto/inventory/InvStockRecordDto.java new file mode 100644 index 0000000..a4e0a40 --- /dev/null +++ b/src/main/java/com/peanut/modules/master/dto/inventory/InvStockRecordDto.java @@ -0,0 +1,20 @@ +package com.peanut.modules.master.dto.inventory; + +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class InvStockRecordDto { + private Integer productId; + /** 商户标识 1灵枢 2众秒 */ + private Integer merchantId; + /** 采购来源:ls灵枢 zm众秒 other其他 publisher出版社;售出时可兼作出库类型 */ + private String source; + /** 出库类型(售出,可选):donation捐赠 taobao淘宝 app */ + private String outboundType; + private Integer quantity; + private Integer biz_type; + private BigDecimal unitCost; + private String remark; +} diff --git a/src/main/java/com/peanut/modules/master/service/InventoryManagementService.java b/src/main/java/com/peanut/modules/master/service/InventoryManagementService.java new file mode 100644 index 0000000..46bef10 --- /dev/null +++ b/src/main/java/com/peanut/modules/master/service/InventoryManagementService.java @@ -0,0 +1,59 @@ +package com.peanut.modules.master.service; + +import com.alipay.api.domain.Product; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.peanut.modules.common.entity.BuyOrder; +import com.peanut.modules.common.entity.ShopProduct; +import com.peanut.modules.master.dto.inventory.InvStockRecordDto; + +import org.springframework.web.multipart.MultipartFile; +import java.util.List; +import java.util.Map; + +public interface InventoryManagementService { + + Page> pageStockRecords(Map params); + + Map getStockRecordDetail(Long id); + + void purchaseProduct(InvStockRecordDto dto); + + /** + * 图书订单售出:写入 inv_stock_record 售出流水。 + * 天医币支付固定扣众秒库存(merchant_id=2);其它支付按商品 payMerchant。 + */ + 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); + + Page> stockStatistics(Map params); + + List> exportStockRecordList(Map params); + + List> exportStockStatistics(Map params); + + /** + * 按订单回滚库存流水售出:回加采购批次 remain_quantity,售出记录 del_flag=1 + */ + void rollbackSaleByOrderId(Integer orderId); + + /** + * Excel 批量售出:全部解析/匹配成功才入库,任一条失败则全部不入库 + * + * @param merchantId 商户ID + * @param outboundType 出库类型 donation/taobao/app + * @param file Excel(列:create_time, productName, quantity, ordersn) + * @return 成功入库条数 + */ + int batchSaleByExcel(Integer merchantId, String outboundType, MultipartFile file); +} 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 new file mode 100644 index 0000000..b9f822b --- /dev/null +++ b/src/main/java/com/peanut/modules/master/service/impl/InventoryManagementServiceImpl.java @@ -0,0 +1,1867 @@ +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.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.github.yulichang.wrapper.MPJLambdaWrapper; +import com.peanut.modules.common.dao.InvStockRecordDao; +import com.peanut.modules.common.dao.BuyOrderDao; +import com.peanut.modules.common.dao.BookDao; +import com.peanut.modules.common.dao.InvProductCostDao; +import com.peanut.modules.common.dao.ShopProductBookDao; +import com.peanut.modules.common.dao.ShopProductDao; +import com.peanut.modules.common.entity.*; +import com.peanut.modules.master.dto.inventory.InvStockRecordDto; +import com.peanut.modules.master.service.InventoryManagementService; +import com.peanut.config.Constants; +import com.peanut.modules.sys.entity.SysDictDataEntity; +import com.peanut.modules.sys.service.SysDictDataService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service("inventoryManagementService") +public class InventoryManagementServiceImpl extends ServiceImpl implements InventoryManagementService { + + @Autowired + private InvStockRecordDao invStockRecordDao; + @Autowired + private ShopProductDao shopProductDao; + @Autowired + private ShopProductBookDao shopProductBookDao; + @Autowired + private BookDao bookDao; + @Autowired + private BuyOrderDao buyOrderDao; + @Autowired + private InvProductCostDao invProductCostDao; + @Autowired + private SysDictDataService sysDictDataService; + + private static final String DICT_LABEL_OUTBOUND_TYPE = "inventory_outbound_type"; + /** 供应商/采购来源字典 */ + private static final String DICT_LABEL_SOURCE_TYPE = "inventory_source_type"; + + /** Excel/别名书名 -> inv_product_cost 正式书名 */ + private static final Map PRODUCT_NAME_ALIASES = Map.of( + "吴述太阴肺湿热温病学", "太阴肺湿热病学(吴述)", + "吴述中医各家学说通论", "中医各家学说通论(吴述)", + "伤寒传习录 上部", "伤寒传习录【上部】", + "伤寒传习录上部", "伤寒传习录【上部】", + "诊法系列书", "太湖教材:诊法系列" + ); + + @Override + public Page> pageStockRecords(Map params) { + if (params.get("merchantId") == null || StringUtils.isBlank(params.get("merchantId").toString())) { + throw new RuntimeException("商户ID为必传"); + } + if (params.get("bizType") == null || StringUtils.isBlank(params.get("bizType").toString())) { + throw new RuntimeException("明细类型为必传"); + } + long current = Long.parseLong(params.get("current").toString()); + long limit = Long.parseLong(params.get("limit").toString()); + Integer merchantId = Integer.parseInt(params.get("merchantId").toString()); + Integer bizType = Integer.parseInt(params.get("bizType").toString()); + List productIds = null; + Object productNameObj = params.get("productName"); + if (productNameObj != null && StringUtils.isNotBlank(productNameObj.toString())) { + String keyword = normalizeKeyword(productNameObj.toString().trim()); + if (StringUtils.isNotBlank(keyword)) { + List products = shopProductDao.selectList( + new LambdaQueryWrapper() + .select(ShopProduct::getProductId) + .eq(ShopProduct::getDelFlag, 0) + .like(ShopProduct::getProductName, keyword) + ); + if (products == null || products.isEmpty()) { + return new Page<>(current, limit); + } + productIds = products.stream() + .map(ShopProduct::getProductId) + .collect(Collectors.toList()); + } + } + Object productIdObj = params.get("productId"); + + LambdaQueryWrapper countWrapper = new LambdaQueryWrapper() + .eq(InvStockRecord::getMerchantId, merchantId) + .eq(InvStockRecord::getBizType, bizType); + if (productIdObj != null && StringUtils.isNotBlank(productIdObj.toString())) { + countWrapper.eq(InvStockRecord::getProductId, Integer.parseInt(productIdObj.toString())); + } + if (productIds != null && !productIds.isEmpty()) { + countWrapper.in(InvStockRecord::getProductId, productIds); + } + applyCreateTimeFilter(countWrapper, params); + long total = invStockRecordDao.selectCount(countWrapper); + if (total == 0) { + return new Page<>(current, limit); + } + + LambdaQueryWrapper listWrapper = new LambdaQueryWrapper() + .eq(InvStockRecord::getMerchantId, merchantId) + .eq(InvStockRecord::getBizType, bizType) + .orderByDesc(InvStockRecord::getCreateTime); + if (productIdObj != null && StringUtils.isNotBlank(productIdObj.toString())) { + listWrapper.eq(InvStockRecord::getProductId, Integer.parseInt(productIdObj.toString())); + } + if (productIds != null && !productIds.isEmpty()) { + listWrapper.in(InvStockRecord::getProductId, productIds); + } + applyCreateTimeFilter(listWrapper, params); + + Page entityPage = invStockRecordDao.selectPage(new Page<>(current, limit), listWrapper); + List> records = toStockRecordMaps(entityPage.getRecords()); + fillProductNameForRecords(records); + fillOrderSnForRecords(records); + ensureOrderSnOnly(records); + enrichSourceAndOutboundLabels(records); + + StockRecordPage page = new StockRecordPage(current, limit); + page.setSearchCount(false); + page.setRecords(records); + page.setTotal(total); + int remainTotalStock = 0; + if (productIds != null && productIds.size() == 1) { + Integer targetProductId = productIds.get(0); + List purchaseRecords = invStockRecordDao.selectList( + new LambdaQueryWrapper() + .eq(InvStockRecord::getProductId, targetProductId) + .eq(InvStockRecord::getMerchantId, merchantId) + .eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE) + ); + for (InvStockRecord record : purchaseRecords) { + remainTotalStock += record.getRemainQuantity() == null ? 0 : record.getRemainQuantity(); + } + } + page.setRemainTotalStock(remainTotalStock); + return page; + } + + private String normalizeKeyword(String keyword) { + if (keyword == null) { + return null; + } + return keyword.trim().replaceAll("[\"'“”‘’《》]", ""); + } + + public static class StockRecordPage extends Page> { + private Integer remainTotalStock = 0; + + public StockRecordPage(long current, long size) { + super(current, size); + } + + public Integer getRemainTotalStock() { + return remainTotalStock; + } + + public void setRemainTotalStock(Integer remainTotalStock) { + this.remainTotalStock = remainTotalStock; + } + } + + @Override + public Map getStockRecordDetail(Long id) { + InvStockRecord record = invStockRecordDao.selectById(id); + if (record == null) { + throw new RuntimeException("库存流水不存在"); + } + Map result = new HashMap<>(); + result.put("record", record); + result.put("merchantLabel", merchantLabel(record.getMerchantId())); + if (record.getBizType() != null && record.getBizType() == InvStockRecord.BIZ_TYPE_PURCHASE) { + String sourceValue = resolveSourceDictValue(record.getSource()); + result.put("source", sourceValue); + result.put("sourceLabel", sourceValue); + } else if (record.getBizType() != null && record.getBizType() == InvStockRecord.BIZ_TYPE_SALE) { + String outboundType = record.getOutboundType(); + result.put("outboundType", outboundType); + result.put("outboundTypeLabel", outboundTypeLabel(outboundType)); + if (record.getOrderId() != null) { + BuyOrder order = buyOrderDao.selectById(record.getOrderId()); + result.put("orderSn", order != null ? order.getOrderSn() : null); + } + } + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void purchaseProduct(InvStockRecordDto dto) { + validateProduct(dto.getProductId()); + validateMerchant(dto.getMerchantId()); + if (dto.getBiz_type() == null || (dto.getBiz_type() != 1 && dto.getBiz_type() != 2)) { + throw new RuntimeException("biz_type仅支持1(采购)或2(售出)"); + } + if (dto.getQuantity() == null || dto.getQuantity() <= 0) { + throw new RuntimeException("数量必须大于0"); + } + if (dto.getBiz_type() == InvStockRecord.BIZ_TYPE_SALE) { + String outboundType = resolveOutboundType(dto.getOutboundType()); + List splits = deductPurchaseStockFifo(dto.getProductId(), dto.getMerchantId(), dto.getQuantity()); + for (CostSplit split : splits) { + insertSaleRecord(dto.getProductId(), dto.getMerchantId(), outboundType, + -split.quantity, split.unitCost, dto.getRemark(), null, null, null, split.purchaseRecordId); + } + adjustShopProductStock(dto.getProductId(), -dto.getQuantity()); + return; + } + + validateSource(dto.getMerchantId(), dto.getSource()); + if (dto.getUnitCost() == null || dto.getUnitCost().compareTo(BigDecimal.ZERO) <= 0) { + throw new RuntimeException("单位成本必须大于0"); + } + + // 采购:跨商户调拨 = 来源方按成本批次多条出库 + 本商户一条采购(成本取接口传值) + if (isCrossMerchantTransfer(dto.getMerchantId(), dto.getSource())) { + int sourceMerchantId = sourceToMerchantId(dto.getSource()); + String saleTargetSource = merchantIdToSource(dto.getMerchantId()); + List splits = deductPurchaseStockFifo(dto.getProductId(), sourceMerchantId, dto.getQuantity()); + for (CostSplit split : splits) { + insertRecord(dto.getProductId(), sourceMerchantId, saleTargetSource, null, + InvStockRecord.BIZ_TYPE_SALE, -split.quantity, split.unitCost, dto.getRemark(), + null, null, null, split.purchaseRecordId, null); + } + insertRecord(dto.getProductId(), dto.getMerchantId(), normalizeSource(dto.getSource()), + InvStockRecord.BIZ_TYPE_PURCHASE, dto.getQuantity(), dto.getUnitCost(), dto.getRemark()); + return; + } + + // 普通采购(source=other 等) + ShopProduct product = shopProductDao.selectById(dto.getProductId()); + if (isSetBookProduct(product)) { + purchaseSetBook(dto, product); + return; + } + insertRecord(dto.getProductId(), dto.getMerchantId(), normalizeSource(dto.getSource()), + InvStockRecord.BIZ_TYPE_PURCHASE, dto.getQuantity(), dto.getUnitCost(), dto.getRemark()); + adjustShopProductStock(dto.getProductId(), dto.getQuantity()); + } + + /** + * 套装书采购:套书库存增加,套内每本单品按当前商户 FIFO 出库并扣减 shop_product 库存。 + * 套内图书从 shop_product_book 按套书 product_id 关联查询。 + */ + private void purchaseSetBook(InvStockRecordDto dto, ShopProduct setProduct) { + List 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) { + return false; + } + return listBookIdsByProductId(product.getProductId()).size() > 1; + } + + private List listBookIdsByProductId(Integer productId) { + if (productId == null) { + return Collections.emptyList(); + } + List relations = shopProductBookDao.selectList( + new LambdaQueryWrapper() + .eq(ShopProductBookEntity::getProductId, productId) + .isNotNull(ShopProductBookEntity::getBookId) + ); + if (relations == null || relations.isEmpty()) { + return Collections.emptyList(); + } + return relations.stream() + .map(ShopProductBookEntity::getBookId) + .distinct() + .collect(Collectors.toList()); + } + + private ShopProduct findComponentProductByBookId(Integer bookId, Integer setProductId) { + if (bookId == null) { + return null; + } + List relations = shopProductBookDao.selectList( + new LambdaQueryWrapper() + .eq(ShopProductBookEntity::getBookId, bookId) + .ne(setProductId != null, ShopProductBookEntity::getProductId, setProductId) + ); + if (relations == null || relations.isEmpty()) { + return null; + } + Integer componentProductId = null; + for (ShopProductBookEntity rel : relations) { + Integer candidateId = rel.getProductId(); + if (candidateId == null) { + continue; + } + // 同一 book_id 可能关联套书与单品,优先取 shop_product_book 中仅关联 1 本书的单品 product_id + Long bookCount = shopProductBookDao.selectCount( + new LambdaQueryWrapper() + .eq(ShopProductBookEntity::getProductId, candidateId) + ); + if (bookCount != null && bookCount == 1) { + componentProductId = candidateId; + break; + } + if (componentProductId == null) { + componentProductId = candidateId; + } + } + if (componentProductId == null) { + return null; + } + ShopProduct product = shopProductDao.selectById(componentProductId); + if (product == null || (product.getDelFlag() != null && product.getDelFlag() != 0)) { + return null; + } + return product; + } + + private void validateSetBookComponentStock(List bookIds, Integer merchantId, int quantity, Integer setProductId) { + List errors = new ArrayList<>(); + for (Integer bookId : bookIds) { + ShopProduct component = findComponentProductByBookId(bookId, setProductId); + if (component == null) { + String bookName = resolveBookName(bookId); + String bookPart = bookName.isBlank() + ? "书籍ID[" + bookId + "]" + : "《" + bookName + "》(书籍ID:" + bookId + ")"; + errors.add(bookPart + "对应商品不存在"); + continue; + } + int remain = sumRemainStockByMerchant(component.getProductId(), merchantId); + if (remain < quantity) { + String name = component.getProductName() != null ? component.getProductName() : ""; + if (remain <= 0) { + errors.add("《" + name + "》(商品ID:" + component.getProductId() + ")在当前商户无库存或库存不足"); + } else { + errors.add("《" + name + "》(商品ID:" + component.getProductId() + ")库存不足,剩余" + remain + ",需要" + quantity); + } + } + } + if (!errors.isEmpty()) { + throw new RuntimeException(String.join(";", errors)); + } + } + + private String resolveBookName(Integer bookId) { + if (bookId == null) { + return ""; + } + BookEntity book = bookDao.selectById(bookId); + if (book == null || book.getName() == null || book.getName().isBlank()) { + return ""; + } + return book.getName().trim(); + } + + private int sumRemainStockByMerchant(Integer productId, Integer merchantId) { + List purchaseRecords = invStockRecordDao.selectList( + new LambdaQueryWrapper() + .eq(InvStockRecord::getProductId, productId) + .eq(InvStockRecord::getMerchantId, merchantId) + .eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE) + ); + int total = 0; + for (InvStockRecord record : purchaseRecords) { + total += record.getRemainQuantity() == null ? 0 : record.getRemainQuantity(); + } + return total; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void saleByBookOrder(Integer productId, int quantity, BuyOrder buyOrder, ShopProduct product) { + if (buyOrder == null) { + throw new RuntimeException("订单信息不能为空"); + } + validateProduct(productId); + if (quantity <= 0) { + throw new RuntimeException("售出数量必须大于0"); + } + int merchantId = resolveBookOrderMerchantId(productId, buyOrder, product, quantity); + + List splits = deductPurchaseStockFifo(productId, merchantId, quantity); + for (CostSplit split : splits) { + insertSaleRecord(productId, merchantId, InvStockRecord.OUTBOUND_APP, + -split.quantity, split.unitCost, "图书订单售出", buyOrder, null, null, split.purchaseRecordId); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void rollbackSaleByOrderId(Integer orderId) { + if (orderId == null) { + return; + } + List saleRecords = invStockRecordDao.selectList( + new LambdaQueryWrapper() + .eq(InvStockRecord::getOrderId, orderId) + .eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_SALE) + .orderByDesc(InvStockRecord::getId) + ); + if (saleRecords == null || saleRecords.isEmpty()) { + return; + } + 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); + } + } + // @TableLogic:deleteById 将 del_flag 置为 1 + invStockRecordDao.deleteById(sale.getId()); + } + } + + /** 按采购流水ID精确回加 remain_quantity(不超过该批次原始 quantity) */ + private void restorePurchaseRemainById(Long purchaseRecordId, int restoreQty) { + if (purchaseRecordId == null || restoreQty <= 0) { + return; + } + InvStockRecord purchase = invStockRecordDao.selectById(purchaseRecordId); + if (purchase == null || purchase.getBizType() == null + || purchase.getBizType() != InvStockRecord.BIZ_TYPE_PURCHASE) { + return; + } + 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; + } + purchase.setRemainQuantity(remainQty + add); + invStockRecordDao.updateById(purchase); + if (add < restoreQty) { + // 容量不够时按旧逻辑兜底补足(兼容脏数据) + restorePurchaseRemain(purchase.getProductId(), purchase.getMerchantId(), + purchase.getUnitCost(), restoreQty - add); + } + } + + /** + * 按成本单价逆 FIFO 回加采购批次剩余库存(不超过该批次原始 quantity) + */ + private void restorePurchaseRemain(Integer productId, Integer merchantId, BigDecimal unitCost, int restoreQty) { + if (productId == null || merchantId == null || restoreQty <= 0) { + return; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(InvStockRecord::getProductId, productId) + .eq(InvStockRecord::getMerchantId, merchantId) + .eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE) + .orderByDesc(InvStockRecord::getCreateTime, InvStockRecord::getId); + if (unitCost != null) { + wrapper.eq(InvStockRecord::getUnitCost, unitCost); + } + List purchases = invStockRecordDao.selectList(wrapper); + int remaining = restoreQty; + for (InvStockRecord purchase : purchases) { + if (remaining <= 0) { + break; + } + int originQty = purchase.getQuantity() == null ? 0 : purchase.getQuantity(); + int remainQty = purchase.getRemainQuantity() == null ? 0 : purchase.getRemainQuantity(); + int capacity = Math.max(0, originQty - remainQty); + if (capacity <= 0) { + continue; + } + int add = Math.min(capacity, remaining); + purchase.setRemainQuantity(remainQty + add); + invStockRecordDao.updateById(purchase); + remaining -= add; + } + if (remaining > 0 && unitCost != null) { + // 同成本批次容量不够时,回退到任意采购批次 + restorePurchaseRemain(productId, merchantId, null, remaining); + } else if (remaining > 0) { + throw new RuntimeException("回滚库存失败,订单售出无法完整回加采购剩余,还差" + remaining + + ",productId=" + productId + ", merchantId=" + merchantId); + } + } + + @Override + public boolean hasInvStockRecord(Integer productId, BuyOrder buyOrder, ShopProduct product) { + if (productId == null) { + return false; + } + if (isPeanutCoinPay(buyOrder)) { + return hasInvOnMerchant(productId, InvStockRecord.MERCHANT_ZM); + } + Long count = invStockRecordDao.selectCount( + new LambdaQueryWrapper() + .eq(InvStockRecord::getProductId, productId) + ); + return count != null && count > 0; + } + + @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 remain = sumRemainStockByMerchant(productId, merchantId); + return remain > 0 && remain >= quantity; + } + + /** 天医币支付固定众秒 merchant_id=2 */ + private boolean isPeanutCoinPay(BuyOrder buyOrder) { + return buyOrder != null && Constants.PAYMENT_METHOD_VIRTUAL.equals(buyOrder.getPaymentMethod()); + } + + private int resolveBookOrderMerchantId(Integer productId, BuyOrder buyOrder, ShopProduct product, int needQty) { + if (isPeanutCoinPay(buyOrder)) { + return InvStockRecord.MERCHANT_ZM; + } + int payMerchant = product == null || product.getPayMerchant() == null ? 0 : product.getPayMerchant(); + return resolveInvMerchantId(productId, payMerchant, needQty); + } + + /** + * 优先按 payMerchant 对应商户扣库存;该商户剩余不足时,回退到另一商户(兼容入库商户与 pay_merchant 不一致)。 + */ + private int resolveInvMerchantId(Integer productId, int payMerchant, int needQty) { + int preferred = payMerchant == 1 ? InvStockRecord.MERCHANT_LS : InvStockRecord.MERCHANT_ZM; + int other = preferred == InvStockRecord.MERCHANT_LS ? InvStockRecord.MERCHANT_ZM : InvStockRecord.MERCHANT_LS; + int preferredRemain = sumRemainStockByMerchant(productId, preferred); + if (preferredRemain >= 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; + } + + private boolean hasInvOnMerchant(Integer productId, int merchantId) { + Long count = invStockRecordDao.selectCount( + new LambdaQueryWrapper() + .eq(InvStockRecord::getProductId, productId) + .eq(InvStockRecord::getMerchantId, merchantId) + ); + return count != null && count > 0; + } + + private void insertSaleRecord(Integer productId, Integer merchantId, String outboundType, + int quantity, BigDecimal unitCost, String remark, BuyOrder buyOrder) { + insertSaleRecord(productId, merchantId, outboundType, quantity, unitCost, remark, buyOrder, null, null, null); + } + + private void insertSaleRecord(Integer productId, Integer merchantId, String outboundType, + int quantity, BigDecimal unitCost, String remark, BuyOrder buyOrder, + Date createTime, String orderSn, Long purchaseRecordId) { + insertRecord(productId, merchantId, outboundType, outboundType, + InvStockRecord.BIZ_TYPE_SALE, quantity, unitCost, remark, buyOrder, createTime, orderSn, + purchaseRecordId, null); + } + + private Long insertRecord(Integer productId, Integer merchantId, String source, + int bizType, int quantity, BigDecimal unitCost, String remark) { + return insertRecord(productId, merchantId, source, null, bizType, quantity, unitCost, remark, + null, null, null, null, null); + } + + private Long insertRecord(Integer productId, Integer merchantId, String source, + int bizType, int quantity, BigDecimal unitCost, String remark, + BuyOrder buyOrder) { + return insertRecord(productId, merchantId, source, null, bizType, quantity, unitCost, remark, + buyOrder, null, null, null, null); + } + + private Long insertRecord(Integer productId, Integer merchantId, String source, String outboundType, + int bizType, int quantity, BigDecimal unitCost, String remark, + BuyOrder buyOrder) { + return insertRecord(productId, merchantId, source, outboundType, bizType, quantity, unitCost, remark, + buyOrder, null, null, null, null); + } + + private Long insertRecord(Integer productId, Integer merchantId, String source, String outboundType, + int bizType, int quantity, BigDecimal unitCost, String remark, + BuyOrder buyOrder, Date createTime, String orderSn, Long purchaseRecordId, + Long setPurchaseRecordId) { + InvStockRecord record = new InvStockRecord(); + record.setProductId(productId); + record.setMerchantId(merchantId); + record.setSource(source); + if (bizType == InvStockRecord.BIZ_TYPE_SALE) { + record.setOutboundType(outboundType == null || outboundType.isBlank() ? null : outboundType); + } + record.setBizType(bizType); + record.setQuantity(quantity); + if (bizType == InvStockRecord.BIZ_TYPE_PURCHASE) { + record.setRemainQuantity(quantity); + } + record.setUnitCost(unitCost); + if (buyOrder != null) { + record.setOrderId(buyOrder.getOrderId()); + record.setCome(buyOrder.getCome()); + if (orderSn == null || orderSn.isBlank()) { + orderSn = buyOrder.getOrderSn(); + } + } + if (orderSn != null && !orderSn.isBlank()) { + record.setOrderSn(orderSn.trim()); + } + if (purchaseRecordId != null) { + record.setPurchaseRecordId(purchaseRecordId); + } + if (setPurchaseRecordId != null) { + record.setSetPurchaseRecordId(setPurchaseRecordId); + } + record.setRemark(remark); + record.setCreateTime(createTime != null ? createTime : new Date()); + record.setDelFlag(0); + invStockRecordDao.insert(record); + return record.getId(); + } + + private boolean isCrossMerchantTransfer(Integer merchantId, String source) { + String src = normalizeSource(source); + return (merchantId == InvStockRecord.MERCHANT_ZM && InvStockRecord.SOURCE_LS.equals(src)) + || (merchantId == InvStockRecord.MERCHANT_LS && InvStockRecord.SOURCE_ZM.equals(src)); + } + + private int sourceToMerchantId(String source) { + String src = normalizeSource(source); + if (InvStockRecord.SOURCE_LS.equals(src)) { + return InvStockRecord.MERCHANT_LS; + } + if (InvStockRecord.SOURCE_ZM.equals(src)) { + return InvStockRecord.MERCHANT_ZM; + } + throw new RuntimeException("来源无法映射商户: " + source); + } + + private String merchantIdToSource(Integer merchantId) { + if (merchantId == InvStockRecord.MERCHANT_LS) { + return InvStockRecord.SOURCE_LS; + } + if (merchantId == InvStockRecord.MERCHANT_ZM) { + return InvStockRecord.SOURCE_ZM; + } + return InvStockRecord.SOURCE_OTHER; + } + + private String normalizeSource(String source) { + if (source == null || source.isBlank()) { + return InvStockRecord.SOURCE_OTHER; + } + return source.trim().toLowerCase(); + } + + private String normalizeOutboundSource(String source) { + if (source == null || source.isBlank()) { + return ""; + } + return source.trim(); + } + + /** 校验出库类型,返回字典中的 dict_type(匹配忽略大小写) */ + private String resolveOutboundType(String outboundType) { + String src = normalizeOutboundSource(outboundType); + if (src.isEmpty()) { + throw new RuntimeException("出库类型outboundType为必传"); + } + List dicts = listOutboundTypeDicts(); + if (dicts.isEmpty()) { + throw new RuntimeException("未配置出库类型字典(dict_label=" + DICT_LABEL_OUTBOUND_TYPE + ")"); + } + for (SysDictDataEntity dict : dicts) { + if (dict.getDictType() != null && src.equalsIgnoreCase(dict.getDictType().trim())) { + return dict.getDictType().trim(); + } + } + throw new RuntimeException("出库类型仅支持 " + outboundTypeDictValueTips(dicts)); + } + + private void validateOutboundType(String outboundType) { + if (outboundType == null || outboundType.isBlank()) { + return; + } + resolveOutboundType(outboundType); + } + + private List listOutboundTypeDicts() { + List list = sysDictDataService.list( + new LambdaQueryWrapper() + .eq(SysDictDataEntity::getDictLabel, DICT_LABEL_OUTBOUND_TYPE) + .orderByAsc(SysDictDataEntity::getSort) + .orderByAsc(SysDictDataEntity::getId) + ); + return list == null ? Collections.emptyList() : list; + } + + private String outboundTypeDictValueTips(List dicts) { + return dicts.stream() + .map(SysDictDataEntity::getDictValue) + .filter(v -> v != null && !v.isBlank()) + .collect(Collectors.joining("、")); + } + + private void validateSource(Integer merchantId, String source) { + String src = normalizeSource(source); + if (!InvStockRecord.SOURCE_LS.equals(src) + && !InvStockRecord.SOURCE_ZM.equals(src) + && !InvStockRecord.SOURCE_OTHER.equals(src) + && !InvStockRecord.SOURCE_PUBLISHER.equals(src)) { + throw new RuntimeException("采购来源source仅支持 ls(灵枢)、zm(众妙)、other(其他)、publisher(出版社)"); + } + if (merchantId == InvStockRecord.MERCHANT_LS && InvStockRecord.SOURCE_LS.equals(src)) { + throw new RuntimeException("灵枢商户采购时,source不能为ls"); + } + if (merchantId == InvStockRecord.MERCHANT_ZM && InvStockRecord.SOURCE_ZM.equals(src)) { + throw new RuntimeException("众妙商户采购时,source不能为zm"); + } + } + + @Override + public Page> stockStatistics(Map params) { + Integer merchantId = Integer.parseInt(params.get("merchantId").toString()); + validateMerchant(merchantId); + boolean isAlert = params.get("isAlert") != null && "1".equals(params.get("isAlert").toString().trim()); + + List> result = buildStockStatisticsList(params, merchantId); + if (result.isEmpty()) { + return new Page<>(); + } + + if (isAlert) { + List> alertList = result.stream() + .filter(row -> { + double rate = row.get("remainRate") instanceof Number + ? ((Number) row.get("remainRate")).doubleValue() : 0D; + return rate < 0.3D; + }) + .collect(Collectors.toList()); + Page> page = new Page<>(1, alertList.isEmpty() ? 10 : alertList.size()); + page.setSearchCount(false); + page.setRecords(alertList); + page.setTotal(alertList.size()); + return page; + } + + long current = params.get("current") == null ? 1L : Long.parseLong(params.get("current").toString()); + long limit = params.get("limit") == null ? 10L : Long.parseLong(params.get("limit").toString()); + int fromIndex = (int) ((current - 1) * limit); + List> pageRecords = new ArrayList<>(); + if (fromIndex < result.size()) { + int toIndex = Math.min(fromIndex + (int) limit, result.size()); + pageRecords = new ArrayList<>(result.subList(fromIndex, toIndex)); + } + Page> page = new Page<>(current, limit); + page.setSearchCount(false); + page.setRecords(pageRecords); + page.setTotal(result.size()); + return page; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int batchSaleByExcel(Integer merchantId, String outboundType, MultipartFile file) { + validateMerchant(merchantId); + if (outboundType == null || outboundType.isBlank()) { + throw new RuntimeException("售出类型outboundType为必传"); + } + String normalizedOutbound = resolveOutboundType(outboundType); + if (file == null || file.isEmpty()) { + throw new RuntimeException("Excel文件不能为空"); + } + + List rows = parseBatchSaleExcel(file); + if (rows.isEmpty()) { + throw new RuntimeException("Excel无有效数据行"); + } + + List merchantCosts = listProductCosts(merchantId); + Map costByNormName = buildProductCostNameMap(merchantCosts); + List errors = new ArrayList<>(); + for (BatchSaleRow row : rows) { + if (row.createTime == null) { + errors.add("第" + row.excelRowNum + "行:create_time无法识别"); + } + if (row.rawProductName == null || row.rawProductName.isBlank()) { + errors.add("第" + row.excelRowNum + "行:productName为空"); + } + if (row.quantity == null || row.quantity <= 0) { + errors.add("第" + row.excelRowNum + "行:quantity无法识别或必须大于0"); + } + if (row.orderSn == null || row.orderSn.isBlank()) { + errors.add("第" + row.excelRowNum + "行:ordersn为空"); + } + if (row.rawProductName != null && !row.rawProductName.isBlank()) { + String normName = normalizeSaleProductName(row.rawProductName); + if (normName.isBlank()) { + errors.add("第" + row.excelRowNum + "行:商品名称[" + row.rawProductName + "]去除前缀后为空"); + } else { + InvProductCost cost = resolveProductCost(normName, costByNormName, merchantCosts); + if (cost == null) { + errors.add("第" + row.excelRowNum + "行:商品名称[" + row.rawProductName + "]未匹配到商品"); + } else { + row.productId = cost.getProductId(); + row.matchedName = cost.getName(); + } + } + } + } + if (!errors.isEmpty()) { + throw new RuntimeException("全部未入库:" + String.join(";", errors)); + } + + Map 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); + } + } + for (Map.Entry entry : needQtyMap.entrySet()) { + int remain = sumRemainStockByMerchant(entry.getKey(), merchantId); + if (remain < entry.getValue()) { + String name = productNameMap.get(entry.getKey()); + String namePart = (name != null && !name.isBlank()) ? "《" + name + "》" : ""; + errors.add(namePart + "商品ID[" + entry.getKey() + "]库存不足,剩余" + remain + ",需要" + entry.getValue()); + } + } + if (!errors.isEmpty()) { + throw new RuntimeException("全部未入库:" + String.join(";", errors)); + } + + for (BatchSaleRow row : rows) { + List splits = deductPurchaseStockFifo(row.productId, merchantId, row.quantity); + String remark = "批量导入售出 orderSn=" + row.orderSn; + for (CostSplit split : splits) { + insertSaleRecord(row.productId, merchantId, normalizedOutbound, + -split.quantity, split.unitCost, remark, null, row.createTime, row.orderSn, split.purchaseRecordId); + } + adjustShopProductStock(row.productId, -row.quantity); + } + return rows.size(); + } + + private List listProductCosts(Integer merchantId) { + List costs = invProductCostDao.selectList( + new LambdaQueryWrapper() + .eq(InvProductCost::getMerchantId, merchantId) + ); + return costs == null ? Collections.emptyList() : costs; + } + + private Map buildProductCostNameMap(List costs) { + Map map = new HashMap<>(); + for (InvProductCost cost : costs) { + if (cost.getName() == null || cost.getName().isBlank()) { + continue; + } + putCostAlias(map, cost.getName().trim(), cost); + putCostAlias(map, normalizeSaleProductName(cost.getName()), cost); + } + return map; + } + + private void putCostAlias(Map map, String alias, InvProductCost cost) { + if (alias == null || alias.isBlank()) { + return; + } + map.putIfAbsent(alias, cost); + map.putIfAbsent(alias.replace(" ", ""), cost); + } + + /** + * 精确匹配优先;否则在商户成本表中按“去前缀名互相包含”做唯一匹配 + * (兼容表内存 现售:吴述经方配伍研究,Excel 写 吴述经方配伍研究) + */ + private InvProductCost resolveProductCost(String normName, + Map costByNormName, + List merchantCosts) { + if (normName == null || normName.isBlank()) { + return null; + } + InvProductCost exact = costByNormName.get(normName); + if (exact != null) { + return exact; + } + exact = costByNormName.get(normName.replace(" ", "")); + if (exact != null) { + return exact; + } + List candidates = new ArrayList<>(); + for (InvProductCost cost : merchantCosts) { + if (cost.getName() == null || cost.getName().isBlank()) { + continue; + } + String costNorm = normalizeSaleProductName(cost.getName()); + if (costNorm.isBlank()) { + continue; + } + if (costNorm.equals(normName) + || costNorm.replace(" ", "").equals(normName.replace(" ", "")) + || costNorm.contains(normName) + || normName.contains(costNorm)) { + candidates.add(cost); + } + } + if (candidates.size() == 1) { + return candidates.get(0); + } + return null; + } + + /** 去掉 新书现售:/现售:/预售: 以及书名号后用于匹配,并做已知别名归一 */ + private String normalizeSaleProductName(String name) { + if (name == null) { + return ""; + } + String n = name.trim(); + String[] prefixes = { + "新书现售:", "新书现售:", "现售:", "现售:", + "预售:", "预售:", "限量预售:", "限量预售:" + }; + boolean changed; + do { + changed = false; + for (String prefix : prefixes) { + if (n.startsWith(prefix)) { + n = n.substring(prefix.length()).trim(); + changed = true; + } + } + } while (changed); + if (n.startsWith("《") && n.endsWith("》") && n.length() > 2) { + n = n.substring(1, n.length() - 1).trim(); + } + String alias = PRODUCT_NAME_ALIASES.get(n); + if (alias == null) { + alias = PRODUCT_NAME_ALIASES.get(n.replace(" ", "")); + } + return alias != null ? alias : n; + } + + private List parseBatchSaleExcel(MultipartFile file) { + DataFormatter formatter = new DataFormatter(); + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + List rows = new ArrayList<>(); + try (Workbook workbook = WorkbookFactory.create(file.getInputStream())) { + Sheet sheet = workbook.getNumberOfSheets() > 0 ? workbook.getSheetAt(0) : null; + if (sheet == null) { + throw new RuntimeException("Excel无工作表"); + } + Row header = sheet.getRow(0); + if (header == null) { + throw new RuntimeException("Excel缺少表头"); + } + Map colIndex = new HashMap<>(); + for (int c = 0; c < header.getLastCellNum(); c++) { + Cell cell = header.getCell(c); + if (cell == null) { + continue; + } + String key = formatter.formatCellValue(cell).trim().toLowerCase(); + if (!key.isEmpty()) { + colIndex.put(key, c); + } + } + if (!colIndex.containsKey("create_time") || !colIndex.containsKey("productname") + || !colIndex.containsKey("quantity") || !colIndex.containsKey("ordersn")) { + throw new RuntimeException("Excel表头必须包含 create_time, productName, quantity, ordersn"); + } + int lastRow = sheet.getLastRowNum(); + for (int r = 1; r <= lastRow; r++) { + Row excelRow = sheet.getRow(r); + if (excelRow == null || isExcelRowBlank(excelRow, formatter)) { + continue; + } + BatchSaleRow item = new BatchSaleRow(); + item.excelRowNum = r + 1; + item.createTime = readExcelDate(excelRow.getCell(colIndex.get("create_time")), formatter, sdf); + item.rawProductName = readExcelString(excelRow.getCell(colIndex.get("productname")), formatter); + item.quantity = readExcelInt(excelRow.getCell(colIndex.get("quantity")), formatter); + item.orderSn = readExcelString(excelRow.getCell(colIndex.get("ordersn")), formatter); + rows.add(item); + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("解析Excel失败:" + e.getMessage()); + } + return rows; + } + + private boolean isExcelRowBlank(Row row, DataFormatter formatter) { + for (Cell cell : row) { + if (cell != null && StringUtils.isNotBlank(formatter.formatCellValue(cell))) { + return false; + } + } + return true; + } + + private String readExcelString(Cell cell, DataFormatter formatter) { + if (cell == null) { + return null; + } + if (cell.getCellTypeEnum() == CellType.NUMERIC) { + double num = cell.getNumericCellValue(); + if (num == Math.floor(num) && !Double.isInfinite(num)) { + return String.valueOf((long) num); + } + } + String val = formatter.formatCellValue(cell); + return val == null ? null : val.trim(); + } + + private Integer readExcelInt(Cell cell, DataFormatter formatter) { + if (cell == null) { + return null; + } + try { + if (cell.getCellTypeEnum() == CellType.NUMERIC) { + return (int) cell.getNumericCellValue(); + } + String val = formatter.formatCellValue(cell).trim(); + if (val.isEmpty()) { + return null; + } + return Integer.parseInt(val.replace(",", "")); + } catch (Exception e) { + return null; + } + } + + private Date readExcelDate(Cell cell, DataFormatter formatter, SimpleDateFormat sdf) { + if (cell == null) { + return null; + } + try { + if (cell.getCellTypeEnum() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) { + return cell.getDateCellValue(); + } + if (cell.getCellTypeEnum() == CellType.NUMERIC) { + return DateUtil.getJavaDate(cell.getNumericCellValue()); + } + String val = formatter.formatCellValue(cell).trim(); + if (val.isEmpty()) { + return null; + } + try { + return sdf.parse(val); + } catch (ParseException e) { + return new SimpleDateFormat("yyyy/MM/dd HH:mm:ss").parse(val); + } + } catch (Exception e) { + return null; + } + } + + private static class BatchSaleRow { + private int excelRowNum; + private Date createTime; + private String rawProductName; + private Integer quantity; + private String orderSn; + private Integer productId; + private String matchedName; + } + + @Override + public List> exportStockRecordList(Map params) { + params = exportParamsWithoutPagination(params); + if (params.get("merchantId") == null || StringUtils.isBlank(params.get("merchantId").toString())) { + throw new RuntimeException("商户ID为必传"); + } + Integer merchantId = Integer.parseInt(params.get("merchantId").toString()); + Integer bizType = null; + if (params.get("bizType") != null && StringUtils.isNotBlank(params.get("bizType").toString())) { + bizType = Integer.parseInt(params.get("bizType").toString()); + } + List productIds = resolveProductIdsByName(params.get("productName")); + if (productIds != null && productIds.isEmpty()) { + return new ArrayList<>(); + } + Object productIdObj = params.get("productId"); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(InvStockRecord::getMerchantId, merchantId) + .orderByDesc(InvStockRecord::getCreateTime); + if (bizType != null) { + wrapper.eq(InvStockRecord::getBizType, bizType); + } + if (productIdObj != null && StringUtils.isNotBlank(productIdObj.toString())) { + wrapper.eq(InvStockRecord::getProductId, Integer.parseInt(productIdObj.toString())); + } + if (productIds != null && !productIds.isEmpty()) { + wrapper.in(InvStockRecord::getProductId, productIds); + } + applyCreateTimeFilter(wrapper, params); + + List entityList = invStockRecordDao.selectList(wrapper); + List> list = toStockRecordMaps(entityList); + fillProductNameForRecords(list); + fillOrderSnForRecords(list); + ensureOrderSnOnly(list); + enrichSourceAndOutboundLabels(list); + if (list.isEmpty()) { + return new ArrayList<>(); + } + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + Map setPurchaseQtyCache = new HashMap<>(); + Map> bindCache = new HashMap<>(); + List> result = new ArrayList<>(); + for (Map item : list) { + Integer recordBizType = toIntegerSafe(item.get("bizType")); + if (recordBizType == null) { + continue; + } + if (recordBizType == InvStockRecord.BIZ_TYPE_SALE) { + if (isSetBookBindOutbound(item.get("outboundType"))) { + continue; + } + result.addAll(expandSaleRecordForExport(item, sdf, setPurchaseQtyCache, bindCache)); + } else if (recordBizType == InvStockRecord.BIZ_TYPE_PURCHASE) { + result.add(buildPurchaseExportRow(item, sdf)); + } + } + return result; + } + + private Map buildPurchaseExportRow(Map item, SimpleDateFormat sdf) { + Map row = new LinkedHashMap<>(); + row.put("exportCategory", "purchase"); + row.put("productId", item.get("productId")); + row.put("productName", item.get("productName")); + row.put("sourceLabel", item.get("sourceLabel")); + row.put("createTime", formatExportTime(item.get("createTime"), sdf)); + row.put("quantity", item.get("quantity")); + row.put("remainQuantity", item.get("remainQuantity")); + return row; + } + + private Map buildSaleExportRow(Map item, SimpleDateFormat sdf) { + Map row = new LinkedHashMap<>(); + row.put("exportCategory", "sale"); + row.put("productId", item.get("productId")); + row.put("productName", item.get("productName")); + row.put("outboundTypeLabel", item.get("outboundTypeLabel")); + row.put("unitCost", item.get("unitCost")); + row.put("quantity", Math.abs(toIntValue(item.get("quantity")))); + row.put("orderSn", item.get("orderSn") == null ? "" : item.get("orderSn")); + row.put("createTime", formatExportTime(item.get("createTime"), sdf)); + row.put("come", item.get("come")); + return row; + } + + private List> expandSaleRecordForExport(Map item, SimpleDateFormat sdf, + Map setPurchaseQtyCache, + Map> bindCache) { + Integer productId = toIntegerSafe(item.get("productId")); + Long purchaseRecordId = toLongSafe(item.get("purchaseRecordId")); + if (productId != null && purchaseRecordId != null && isSetBookProductByProductId(productId)) { + List binds = bindCache.computeIfAbsent(purchaseRecordId, this::listSetBookComponentOutbounds); + if (!binds.isEmpty()) { + int saleQty = Math.abs(toIntValue(item.get("quantity"))); + int setPurchaseQty = setPurchaseQtyCache.computeIfAbsent(purchaseRecordId, this::resolveSetPurchaseQuantity); + return buildSetBookExpandedSaleRows(item, binds, saleQty, setPurchaseQty, sdf); + } + } + return Collections.singletonList(buildSaleExportRow(item, sdf)); + } + + private List> buildSetBookExpandedSaleRows(Map parent, + List binds, + int saleQty, int setPurchaseQty, + SimpleDateFormat sdf) { + Map productNames = loadProductNameMap( + binds.stream().map(InvStockRecord::getProductId).filter(Objects::nonNull).collect(Collectors.toSet())); + List> rows = new ArrayList<>(); + for (InvStockRecord bind : binds) { + int bindQty = bind.getQuantity() == null ? 0 : Math.abs(bind.getQuantity()); + int exportQty = setPurchaseQty > 0 + ? (int) Math.round(bindQty * (double) saleQty / setPurchaseQty) + : saleQty; + if (exportQty <= 0) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("exportCategory", "sale"); + row.put("productId", bind.getProductId()); + row.put("productName", productNames.getOrDefault(bind.getProductId(), "")); + row.put("outboundTypeLabel", parent.get("outboundTypeLabel")); + row.put("unitCost", bind.getUnitCost()); + row.put("quantity", exportQty); + row.put("orderSn", parent.get("orderSn") == null ? "" : parent.get("orderSn")); + row.put("createTime", formatExportTime(parent.get("createTime"), sdf)); + row.put("come", parent.get("come")); + rows.add(row); + } + if (rows.isEmpty()) { + return Collections.singletonList(buildSaleExportRow(parent, sdf)); + } + return rows; + } + + private int resolveSetPurchaseQuantity(Long setPurchaseRecordId) { + if (setPurchaseRecordId == null) { + return 0; + } + InvStockRecord purchase = invStockRecordDao.selectById(setPurchaseRecordId); + if (purchase == null || purchase.getQuantity() == null) { + return 0; + } + return Math.abs(purchase.getQuantity()); + } + + private List listSetBookComponentOutbounds(Long setPurchaseRecordId) { + if (setPurchaseRecordId == null) { + return Collections.emptyList(); + } + List list = invStockRecordDao.selectList( + new LambdaQueryWrapper() + .eq(InvStockRecord::getSetPurchaseRecordId, setPurchaseRecordId) + .eq(InvStockRecord::getOutboundType, InvStockRecord.OUTBOUND_BOOKS) + .eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_SALE) + .orderByAsc(InvStockRecord::getId) + ); + return list == null ? Collections.emptyList() : list; + } + + private boolean isSetBookProductByProductId(Integer productId) { + return listBookIdsByProductId(productId).size() > 1; + } + + private Map loadProductNameMap(Set productIds) { + if (productIds == null || productIds.isEmpty()) { + return Collections.emptyMap(); + } + List products = shopProductDao.selectList( + new LambdaQueryWrapper() + .select(ShopProduct::getProductId, ShopProduct::getProductName) + .in(ShopProduct::getProductId, productIds) + ); + Map map = new HashMap<>(); + if (products != null) { + for (ShopProduct product : products) { + if (product.getProductId() != null) { + map.put(product.getProductId(), product.getProductName()); + } + } + } + return map; + } + + private String formatExportTime(Object createTime, SimpleDateFormat sdf) { + if (createTime instanceof Date) { + return sdf.format((Date) createTime); + } + return createTime == null ? "" : createTime.toString(); + } + + @Override + public List> exportStockStatistics(Map params) { + params = exportParamsWithoutPagination(params); + if (params.get("merchantId") == null || StringUtils.isBlank(params.get("merchantId").toString())) { + throw new RuntimeException("merchantId为必填"); + } + Integer merchantId = Integer.parseInt(params.get("merchantId").toString()); + boolean isAlert = params.get("isAlert") != null && "1".equals(params.get("isAlert").toString().trim()); + List> result = buildStockStatisticsList(params, merchantId); + if (isAlert) { + result = result.stream() + .filter(row -> { + double rate = row.get("remainRate") instanceof Number + ? ((Number) row.get("remainRate")).doubleValue() : 0D; + return rate < 0.3D; + }) + .collect(Collectors.toList()); + } + List> exportRows = new ArrayList<>(); + for (Map item : result) { + Map row = new LinkedHashMap<>(); + row.put("productName", item.get("productName")); + row.put("purchaseTotal", item.get("purchaseTotal")); + row.put("saleTotal", item.get("saleTotal")); + row.put("remainTotal", item.get("remainTotal")); + double rate = item.get("remainRate") instanceof Number + ? ((Number) item.get("remainRate")).doubleValue() : 0D; + row.put("remainRate", String.format("%.2f%%", rate * 100)); + exportRows.add(row); + } + return exportRows; + } + + private List> buildStockStatisticsList(Map params, Integer merchantId) { + List productIds = resolveProductIdsByName(params.get("productName")); + if (productIds != null && productIds.isEmpty()) { + return new ArrayList<>(); + } + + MPJLambdaWrapper remainWrapper = new MPJLambdaWrapper<>(); + remainWrapper.select(InvStockRecord::getProductId); + remainWrapper.select("SUM(t.remain_quantity) AS remainTotal"); + remainWrapper.eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE); + remainWrapper.eq(InvStockRecord::getMerchantId, merchantId); + if (productIds != null && !productIds.isEmpty()) { + remainWrapper.in(InvStockRecord::getProductId, productIds); + } + remainWrapper.groupBy(InvStockRecord::getProductId); + List> remainList = invStockRecordDao.selectJoinMaps(remainWrapper); + + MPJLambdaWrapper purchaseAllWrapper = new MPJLambdaWrapper<>(); + purchaseAllWrapper.select(InvStockRecord::getProductId); + purchaseAllWrapper.select("SUM(t.quantity) AS purchaseAllTotal"); + purchaseAllWrapper.eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE); + purchaseAllWrapper.eq(InvStockRecord::getMerchantId, merchantId); + if (productIds != null && !productIds.isEmpty()) { + purchaseAllWrapper.in(InvStockRecord::getProductId, productIds); + } + purchaseAllWrapper.groupBy(InvStockRecord::getProductId); + List> purchaseAllList = invStockRecordDao.selectJoinMaps(purchaseAllWrapper); + + MPJLambdaWrapper purchaseWrapper = new MPJLambdaWrapper<>(); + purchaseWrapper.select(InvStockRecord::getProductId); + purchaseWrapper.select("SUM(t.quantity) AS purchaseTotal"); + purchaseWrapper.eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE); + purchaseWrapper.eq(InvStockRecord::getMerchantId, merchantId); + if (productIds != null && !productIds.isEmpty()) { + purchaseWrapper.in(InvStockRecord::getProductId, productIds); + } + applyCreateTimeFilter(purchaseWrapper, params); + purchaseWrapper.groupBy(InvStockRecord::getProductId); + List> purchaseList = invStockRecordDao.selectJoinMaps(purchaseWrapper); + + MPJLambdaWrapper saleWrapper = new MPJLambdaWrapper<>(); + saleWrapper.select(InvStockRecord::getProductId); + saleWrapper.select("SUM(ABS(t.quantity)) AS saleTotal"); + saleWrapper.eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_SALE); + saleWrapper.eq(InvStockRecord::getMerchantId, merchantId); + if (productIds != null && !productIds.isEmpty()) { + saleWrapper.in(InvStockRecord::getProductId, productIds); + } + applyCreateTimeFilter(saleWrapper, params); + saleWrapper.groupBy(InvStockRecord::getProductId); + List> saleList = invStockRecordDao.selectJoinMaps(saleWrapper); + + Map> merged = new LinkedHashMap<>(); + mergeStatRows(merged, remainList, "remainTotal"); + mergeStatRows(merged, purchaseAllList, "purchaseAllTotal"); + mergeStatRows(merged, purchaseList, "purchaseTotal"); + mergeStatRows(merged, saleList, "saleTotal"); + if (merged.isEmpty()) { + return new ArrayList<>(); + } + + List ids = new ArrayList<>(merged.keySet()); + Map nameMap = new HashMap<>(); + List products = shopProductDao.selectList( + new LambdaQueryWrapper() + .select(ShopProduct::getProductId, ShopProduct::getProductName) + .eq(ShopProduct::getDelFlag, 0) + .in(ShopProduct::getProductId, ids) + ); + nameMap = products.stream() + .collect(Collectors.toMap(ShopProduct::getProductId, ShopProduct::getProductName, (a, b) -> a)); + + List> result = new ArrayList<>(); + for (Map.Entry> entry : merged.entrySet()) { + Integer pid = entry.getKey(); + Map item = entry.getValue(); + int purchaseTotal = toIntValue(item.get("purchaseTotal")); + if (purchaseTotal == 0 && !hasTimeFilter(params)) { + purchaseTotal = toIntValue(item.get("purchaseAllTotal")); + } + int saleTotal = toIntValue(item.get("saleTotal")); + int remainTotal = toIntValue(item.get("remainTotal")); + int purchaseAllTotal = toIntValue(item.get("purchaseAllTotal")); + double remainRate = purchaseAllTotal == 0 ? 0D : (double) remainTotal / purchaseAllTotal; + Map row = new HashMap<>(); + row.put("productId", pid); + row.put("productName", nameMap.get(pid)); + row.put("purchaseTotal", purchaseTotal); + row.put("saleTotal", saleTotal); + row.put("remainTotal", remainTotal); + row.put("remainRate", remainRate); + row.put("merchantLabel", merchantLabel(merchantId)); + result.add(row); + } + + result.sort((a, b) -> { + double rateA = a.get("remainRate") instanceof Number ? ((Number) a.get("remainRate")).doubleValue() : 0D; + double rateB = b.get("remainRate") instanceof Number ? ((Number) b.get("remainRate")).doubleValue() : 0D; + int cmp = Double.compare(rateA, rateB); + if (cmp != 0) { + return cmp; + } + Integer pidA = toIntegerSafe(a.get("productId")); + Integer pidB = toIntegerSafe(b.get("productId")); + if (pidA == null || pidB == null) { + return 0; + } + return pidA.compareTo(pidB); + }); + return result; + } + + private void mergeStatRows(Map> merged, List> list, String field) { + if (list == null) { + return; + } + for (Map item : list) { + Integer pid = toIntegerSafe(item.get("product_id") != null ? item.get("product_id") : item.get("productId")); + if (pid == null) { + continue; + } + Map row = merged.computeIfAbsent(pid, k -> new HashMap<>()); + row.put(field, item.get(field)); + } + } + + private int toIntValue(Object value) { + Integer v = toIntegerSafe(value); + return v == null ? 0 : v; + } + + private boolean hasTimeFilter(Map params) { + Object start = params.get("startTime"); + Object end = params.get("endTime"); + return (start != null && StringUtils.isNotBlank(start.toString())) + || (end != null && StringUtils.isNotBlank(end.toString())); + } + + private List resolveProductIdsByName(Object productNameObj) { + if (productNameObj == null || StringUtils.isBlank(productNameObj.toString())) { + return null; + } + String keyword = normalizeKeyword(productNameObj.toString().trim()); + if (StringUtils.isBlank(keyword)) { + return null; + } + List products = shopProductDao.selectList( + new LambdaQueryWrapper() + .select(ShopProduct::getProductId) + .eq(ShopProduct::getDelFlag, 0) + .like(ShopProduct::getProductName, keyword) + ); + if (products == null || products.isEmpty()) { + return new ArrayList<>(); + } + return products.stream() + .map(ShopProduct::getProductId) + .collect(Collectors.toList()); + } + + private void applyCreateTimeFilter(LambdaQueryWrapper wrapper, Map params) { + Object start = params.get("startTime"); + Object end = params.get("endTime"); + if (start != null && StringUtils.isNotBlank(start.toString())) { + wrapper.ge(InvStockRecord::getCreateTime, start.toString().trim()); + } + if (end != null && StringUtils.isNotBlank(end.toString())) { + wrapper.le(InvStockRecord::getCreateTime, end.toString().trim()); + } + } + + private void applyCreateTimeFilter(MPJLambdaWrapper wrapper, Map params) { + Object start = params.get("startTime"); + Object end = params.get("endTime"); + if (start != null && StringUtils.isNotBlank(start.toString())) { + wrapper.ge(InvStockRecord::getCreateTime, start.toString().trim()); + } + if (end != null && StringUtils.isNotBlank(end.toString())) { + wrapper.le(InvStockRecord::getCreateTime, end.toString().trim()); + } + } + + private Map exportParamsWithoutPagination(Map params) { + Map copy = new HashMap<>(params); + copy.remove("current"); + copy.remove("limit"); + return copy; + } + + private List> toStockRecordMaps(List records) { + List> list = new ArrayList<>(); + if (records == null || records.isEmpty()) { + return list; + } + for (InvStockRecord record : records) { + Map row = new LinkedHashMap<>(); + row.put("id", record.getId()); + row.put("productId", record.getProductId()); + row.put("merchantId", record.getMerchantId()); + row.put("source", record.getSource()); + row.put("outboundType", record.getOutboundType()); + row.put("bizType", record.getBizType()); + row.put("quantity", record.getQuantity()); + row.put("remainQuantity", record.getRemainQuantity()); + row.put("unitCost", record.getUnitCost()); + row.put("orderId", record.getOrderId()); + row.put("orderSn", record.getOrderSn()); + row.put("come", record.getCome()); + row.put("purchaseRecordId", record.getPurchaseRecordId()); + row.put("setPurchaseRecordId", record.getSetPurchaseRecordId()); + row.put("remark", record.getRemark()); + row.put("createTime", record.getCreateTime()); + row.put("create_time", record.getCreateTime()); + list.add(row); + } + return list; + } + + /** 采购明细:source 按字典 dict_type→dict_value 转换;出库明细补出库类型文案 */ + private void enrichSourceAndOutboundLabels(List> records) { + if (records == null || records.isEmpty()) { + return; + } + for (Map item : records) { + Integer bizType = toIntegerSafe(item.get("bizType")); + if (bizType != null && bizType == InvStockRecord.BIZ_TYPE_PURCHASE) { + String sourceCode = item.get("source") == null ? "" : item.get("source").toString(); + String sourceValue = resolveSourceDictValue(sourceCode); + item.put("source", sourceValue); + item.put("sourceLabel", sourceValue); + item.put("outboundType", null); + item.put("outboundTypeLabel", null); + } else if (bizType != null && bizType == InvStockRecord.BIZ_TYPE_SALE) { + Object outbound = item.get("outboundType"); + String outboundType = outbound == null ? "" : outbound.toString(); + item.put("outboundType", outboundType); + item.put("outboundTypeLabel", outboundTypeLabel(outboundType)); + } + } + } + + /** + * 供应商字典:dict_label=inventory_source_type,按 dict_type 匹配库中的 source,返回 dict_value + */ + private String resolveSourceDictValue(String source) { + if (source == null || source.isBlank()) { + return ""; + } + String src = source.trim(); + for (SysDictDataEntity dict : listSourceTypeDicts()) { + if (dict.getDictType() != null && src.equalsIgnoreCase(dict.getDictType().trim())) { + return dict.getDictValue() == null || dict.getDictValue().isBlank() + ? src : dict.getDictValue(); + } + } + return source; + } + + private List listSourceTypeDicts() { + List list = sysDictDataService.list( + new LambdaQueryWrapper() + .eq(SysDictDataEntity::getDictLabel, DICT_LABEL_SOURCE_TYPE) + .orderByAsc(SysDictDataEntity::getSort) + .orderByAsc(SysDictDataEntity::getId) + ); + return list == null ? Collections.emptyList() : list; + } + + private String outboundTypeLabel(String outboundType) { + if (outboundType == null || outboundType.isBlank()) { + return ""; + } + String type = outboundType.trim(); + for (SysDictDataEntity dict : listOutboundTypeDicts()) { + if (dict.getDictType() != null && type.equalsIgnoreCase(dict.getDictType().trim())) { + return dict.getDictValue() == null ? type : dict.getDictValue(); + } + } + return outboundType; + } + + private boolean isSetBookBindOutbound(Object outboundType) { + if (outboundType == null) { + return false; + } + return InvStockRecord.OUTBOUND_BOOKS.equalsIgnoreCase(outboundType.toString().trim()); + } + + private void fillProductNameForRecords(List> records) { + if (records == null || records.isEmpty()) { + return; + } + Set productIds = new HashSet<>(); + for (Map item : records) { + Integer productId = toIntegerSafe(item.get("productId")); + if (productId != null) { + productIds.add(productId); + } + } + if (productIds.isEmpty()) { + return; + } + List products = shopProductDao.selectList( + new LambdaQueryWrapper() + .select(ShopProduct::getProductId, ShopProduct::getProductName) + .in(ShopProduct::getProductId, productIds) + ); + Map nameMap = products.stream() + .collect(Collectors.toMap(ShopProduct::getProductId, ShopProduct::getProductName, (a, b) -> a)); + for (Map item : records) { + Integer productId = toIntegerSafe(item.get("productId")); + item.put("productName", productId != null ? nameMap.get(productId) : null); + } + } + + private Object getMapField(Map map, String snakeKey, String camelKey) { + if (map == null) { + return null; + } + Object val = map.get(camelKey); + if (val != null) { + return val; + } + val = map.get(snakeKey); + if (val != null) { + return val; + } + return map.get("t_" + snakeKey); + } + + private void ensureOrderSnOnly(List> records) { + if (records == null || records.isEmpty()) { + return; + } + for (Map item : records) { + Object orderSn = getMapField(item, "order_sn", "orderSn"); + if (orderSn != null && StringUtils.isNotBlank(orderSn.toString())) { + item.put("orderSn", orderSn); + } + item.remove("order_sn"); + } + } + + private void fillOrderSnForRecords(List> records) { + if (records == null || records.isEmpty()) { + return; + } + Set orderIds = new HashSet<>(); + for (Map item : records) { + Integer bizType = toIntegerSafe(getMapField(item, "biz_type", "bizType")); + if (bizType == null || bizType != InvStockRecord.BIZ_TYPE_SALE) { + continue; + } + Integer orderId = toIntegerSafe(getMapField(item, "order_id", "orderId")); + if (orderId != null) { + orderIds.add(orderId); + } + } + if (orderIds.isEmpty()) { + return; + } + List orders = buyOrderDao.selectList( + new LambdaQueryWrapper() + .select(BuyOrder::getOrderId, BuyOrder::getOrderSn) + .in(BuyOrder::getOrderId, orderIds) + ); + Map orderSnMap = orders.stream() + .collect(Collectors.toMap(BuyOrder::getOrderId, BuyOrder::getOrderSn, (a, b) -> a)); + for (Map item : records) { + Integer bizType = toIntegerSafe(getMapField(item, "biz_type", "bizType")); + if (bizType == null || bizType != InvStockRecord.BIZ_TYPE_SALE) { + continue; + } + Object existing = getMapField(item, "order_sn", "orderSn"); + if (existing != null && StringUtils.isNotBlank(existing.toString())) { + continue; + } + Integer orderId = toIntegerSafe(getMapField(item, "order_id", "orderId")); + if (orderId != null) { + item.put("orderSn", orderSnMap.get(orderId)); + } + } + } + + private Integer toIntegerSafe(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + String str = value.toString().trim(); + if (!str.matches("^-?\\d+$")) { + return null; + } + return Integer.parseInt(str); + } + + private Long toLongSafe(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + String str = value.toString().trim(); + if (!str.matches("^-?\\d+$")) { + return null; + } + return Long.parseLong(str); + } + + private void validateProduct(Integer productId) { + if (productId == null || shopProductDao.selectById(productId) == null) { + throw new RuntimeException("shop_product 商品不存在"); + } + } + + private void validateMerchant(Integer merchantId) { + if (merchantId == null || (merchantId != 1 && merchantId != 2)) { + throw new RuntimeException("商户标识无效,1=灵枢,2=众妙"); + } + } + + private String merchantLabel(Integer merchantId) { + if (merchantId == null) { + return ""; + } + if (merchantId == 1) { + return "灵枢"; + } + if (merchantId == 2) { + return "众妙"; + } + return String.valueOf(merchantId); + } + + private List deductPurchaseStockFifo(Integer productId, Integer merchantId, int saleQuantity) { + List purchaseRecords = invStockRecordDao.selectList( + new LambdaQueryWrapper() + .eq(InvStockRecord::getProductId, productId) + .eq(InvStockRecord::getMerchantId, merchantId) + .eq(InvStockRecord::getBizType, InvStockRecord.BIZ_TYPE_PURCHASE) + .gt(InvStockRecord::getRemainQuantity, 0) + .orderByAsc(InvStockRecord::getCreateTime, InvStockRecord::getId) + ); + List splits = new ArrayList<>(); + int remaining = saleQuantity; + for (InvStockRecord purchase : purchaseRecords) { + if (remaining <= 0) { + break; + } + int remainQty = purchase.getRemainQuantity() == null ? 0 : purchase.getRemainQuantity(); + int deductQty = Math.min(remainQty, remaining); + purchase.setRemainQuantity(remainQty - deductQty); + invStockRecordDao.updateById(purchase); + splits.add(new CostSplit(deductQty, purchase.getUnitCost(), purchase.getId())); + remaining -= deductQty; + } + if (remaining > 0) { + throw new RuntimeException("库存不足,商户[" + merchantLabel(merchantId) + "]还差" + remaining); + } + return splits; + } + + private String normalizeOrderType(String orderType) { + if (InvStockRecord.ORDER_TYPE_LS.equals(orderType)) { + return InvStockRecord.ORDER_TYPE_LS; + } + return InvStockRecord.ORDER_TYPE_ZM; + } + + private int orderTypeToMerchantId(String orderType) { + if (InvStockRecord.ORDER_TYPE_LS.equals(orderType)) { + return InvStockRecord.MERCHANT_LS; + } + return InvStockRecord.MERCHANT_ZM; + } + + private String orderTypeToSource(String orderType) { + if (InvStockRecord.ORDER_TYPE_LS.equals(orderType)) { + return InvStockRecord.SOURCE_LS; + } + return InvStockRecord.SOURCE_ZM; + } + + private static class CostSplit { + private final int quantity; + private final BigDecimal unitCost; + private final Long purchaseRecordId; + + private CostSplit(int quantity, BigDecimal unitCost, Long purchaseRecordId) { + this.quantity = quantity; + this.unitCost = unitCost; + this.purchaseRecordId = purchaseRecordId; + } + } + + private void adjustShopProductStock(Integer productId, int deltaQty) { + if (productId == null || deltaQty == 0) { + return; + } + ShopProduct shopProduct = shopProductDao.selectById(productId); + if (shopProduct == null) { + throw new RuntimeException("未找到 shop_product 商品,无法同步库存,productId=" + productId); + } + int currentStock = shopProduct.getProductStock() == null ? 0 : shopProduct.getProductStock(); + int newStock = currentStock + deltaQty; + if (newStock < 0) { + throw new RuntimeException("shop_product库存不足,当前库存" + currentStock); + } + shopProduct.setProductStock(newStock); + shopProductDao.updateById(shopProduct); + } +} diff --git a/src/main/java/com/peanut/modules/mq/Consumer/OrderCancelConsumer.java b/src/main/java/com/peanut/modules/mq/Consumer/OrderCancelConsumer.java index 66ea9fc..625b756 100644 --- a/src/main/java/com/peanut/modules/mq/Consumer/OrderCancelConsumer.java +++ b/src/main/java/com/peanut/modules/mq/Consumer/OrderCancelConsumer.java @@ -3,17 +3,23 @@ package com.peanut.modules.mq.Consumer; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.peanut.config.Constants; import com.peanut.config.DelayQueueConfig; +import com.peanut.modules.book.service.BuyOrderService; import com.peanut.modules.common.dao.BuyOrderProductDao; +import com.peanut.modules.common.dao.MyUserDao; import com.peanut.modules.common.dao.ShopProductDao; import com.peanut.modules.common.entity.BuyOrder; import com.peanut.modules.common.entity.BuyOrderProduct; +import com.peanut.modules.common.entity.MyUserEntity; import com.peanut.modules.common.entity.ShopProduct; -import com.peanut.modules.book.service.BuyOrderService; import com.peanut.modules.common.service.CouponService; +import com.peanut.modules.master.service.InventoryManagementService; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang.StringUtils; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + import java.util.List; /** @@ -21,6 +27,7 @@ import java.util.List; * @Author: Cauchy * @CreateTime: 2023/10/10 */ +@Slf4j @Component public class OrderCancelConsumer { @@ -30,36 +37,70 @@ public class OrderCancelConsumer { BuyOrderProductDao buyOrderProductDao; @Autowired ShopProductDao shopProductDao; + @Autowired + MyUserDao myUserDao; + @Autowired + InventoryManagementService inventoryManagementService; + @Autowired + CouponService couponService; + /** + * 30分钟未支付取消。 + * 临时限制:仅 tester_flag=1 的测试账号订单会执行取消与回滚,正式账号直接跳过。 + */ @RabbitListener(queues = DelayQueueConfig.ORDER_CANCEL_DEAD_LETTER_QUEUE) + @Transactional(rollbackFor = Exception.class) public void orderConsumer(String orderId) { - if (StringUtils.isNotEmpty(orderId)){ - BuyOrder buyOrder = buyOrderService.getById(orderId); - if(buyOrder == null){ - return; - } - if(Constants.ORDER_STATUS_TO_BE_PAID.equals(buyOrder.getOrderStatus())){ - buyOrder.setOrderStatus(Constants.ORDER_STATUS_OUT_OF_TIME); - //回滚优惠卷 - if (buyOrder.getCouponId()!=null&&buyOrder.getCouponId()!=0){ - buyOrder.setCouponId(null); - } - //回滚库存 - 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){ - shopProduct.setProductStock(shopProduct.getProductStock()+b.getQuantity()); - shopProductDao.updateById(shopProduct); - } - } - buyOrderService.updateById(buyOrder); - } - if(Constants.ORDER_STATUS_OUT_OF_TIME.equals(buyOrder.getOrderStatus())){ - buyOrderService.removeById(buyOrder); + if (StringUtils.isEmpty(orderId)) { + return; + } + BuyOrder buyOrder = buyOrderService.getById(orderId); + if (buyOrder == null) { + return; + } + if (!Constants.ORDER_STATUS_TO_BE_PAID.equals(buyOrder.getOrderStatus())) { + return; + } + // TODO 上线全量后删除此测试账号限制 + if (!isTesterOrder(buyOrder)) { + log.info("订单超时取消跳过:非测试账号, orderId={}, userId={}", buyOrder.getOrderId(), buyOrder.getUserId()); + return; + } + buyOrder.setOrderStatus(Constants.ORDER_STATUS_OUT_OF_TIME); + + // 回滚优惠券(buy_order.coupon_id 存的是 coupon_history.id),保留订单 couponId 便于查单 + Integer couponHistoryId = buyOrder.getCouponId(); + if (couponHistoryId != null && couponHistoryId != 0) { + couponService.rollbackCoupon(couponHistoryId); + } + + // 回滚商品库存与销量 + 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); + 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); } -} \ No newline at end of file + + private boolean isTesterOrder(BuyOrder buyOrder) { + if (buyOrder.getUserId() == null) { + return false; + } + MyUserEntity user = myUserDao.selectById(buyOrder.getUserId()); + return user != null && user.getTesterFlag() != null && user.getTesterFlag() == 1; + } +}