feat: 补充平台端库存管理模块

补齐平台端库存余额、流水、初始化和手工调整能力,并将快递发货接入库存扣减闭环,方便运营侧统一查账与审计。

Made-with: Cursor
This commit is contained in:
AriadenCaseblg
2026-04-19 23:43:46 +08:00
parent b097837aa3
commit 005bd968df
32 changed files with 61113 additions and 59368 deletions

View File

@@ -0,0 +1,10 @@
package com.zbkj.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zbkj.common.model.product.ProductInventory;
/**
* 商品库存余额 Mapper
*/
public interface ProductInventoryDao extends BaseMapper<ProductInventory> {
}

View File

@@ -0,0 +1,10 @@
package com.zbkj.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zbkj.common.model.product.ProductInventoryRecord;
/**
* 商品库存流水 Mapper
*/
public interface ProductInventoryRecordDao extends BaseMapper<ProductInventoryRecord> {
}

View File

@@ -0,0 +1,12 @@
package com.zbkj.service.service;
import com.zbkj.common.model.admin.SystemAdmin;
import com.zbkj.common.request.InventoryInitRequest;
/**
* 库存初始化服务
*/
public interface InventoryInitService {
Boolean initInventory(InventoryInitRequest request, SystemAdmin admin);
}

View File

@@ -0,0 +1,43 @@
package com.zbkj.service.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.github.pagehelper.PageInfo;
import com.zbkj.common.model.admin.SystemAdmin;
import com.zbkj.common.model.order.OrderInvoiceDetail;
import com.zbkj.common.model.product.ProductInventory;
import com.zbkj.common.request.InventoryAdjustRequest;
import com.zbkj.common.request.InventoryInitRequest;
import com.zbkj.common.request.InventorySearchRequest;
import com.zbkj.common.response.InventoryPageResponse;
import com.zbkj.common.response.InventoryRecordPageResponse;
import java.util.List;
/**
* 商品库存服务
*/
public interface ProductInventoryService extends IService<ProductInventory> {
/**
* 默认预警库存
*/
Integer DEFAULT_ALERT_STOCK = 10;
PageInfo<InventoryPageResponse> getPlatformPage(InventorySearchRequest request);
PageInfo<InventoryRecordPageResponse> getRecordPage(InventorySearchRequest request);
Boolean adjust(InventoryAdjustRequest request, SystemAdmin admin);
Boolean initInventory(InventoryInitRequest request, SystemAdmin admin);
/**
* 快递发货时同步扣减库存余额并记录流水。
*/
Boolean recordOrderDelivery(String orderNo, Integer invoiceId, List<OrderInvoiceDetail> invoiceDetailList, SystemAdmin admin);
/**
* 二期逆向库存扩展点:退款回补、取消发货冲销可复用此入口。
*/
Boolean reverseOrderDelivery(String orderNo, Integer invoiceId, List<OrderInvoiceDetail> invoiceDetailList, SystemAdmin admin, String remark);
}

View File

@@ -0,0 +1,24 @@
package com.zbkj.service.service.impl;
import com.zbkj.common.model.admin.SystemAdmin;
import com.zbkj.common.request.InventoryInitRequest;
import com.zbkj.service.service.InventoryInitService;
import com.zbkj.service.service.ProductInventoryService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
/**
* 库存初始化服务实现
*/
@Service
public class InventoryInitServiceImpl implements InventoryInitService {
@Resource
private ProductInventoryService productInventoryService;
@Override
public Boolean initInventory(InventoryInitRequest request, SystemAdmin admin) {
return productInventoryService.initInventory(request, admin);
}
}

View File

@@ -0,0 +1,356 @@
package com.zbkj.service.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.github.pagehelper.Page;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.zbkj.common.constants.Constants;
import com.zbkj.common.enums.InventoryPmEnum;
import com.zbkj.common.enums.InventorySourceTypeEnum;
import com.zbkj.common.exception.CrmebException;
import com.zbkj.common.model.admin.SystemAdmin;
import com.zbkj.common.model.merchant.Merchant;
import com.zbkj.common.model.merchant.MerchantInfo;
import com.zbkj.common.model.order.OrderInvoiceDetail;
import com.zbkj.common.model.product.Product;
import com.zbkj.common.model.product.ProductAttrValue;
import com.zbkj.common.model.product.ProductInventory;
import com.zbkj.common.model.product.ProductInventoryRecord;
import com.zbkj.common.request.InventoryAdjustRequest;
import com.zbkj.common.request.InventoryInitRequest;
import com.zbkj.common.request.InventorySearchRequest;
import com.zbkj.common.response.InventoryPageResponse;
import com.zbkj.common.response.InventoryRecordPageResponse;
import com.zbkj.service.dao.ProductInventoryDao;
import com.zbkj.service.dao.ProductInventoryRecordDao;
import com.zbkj.service.service.MerchantInfoService;
import com.zbkj.service.service.MerchantService;
import com.zbkj.service.service.ProductAttrValueService;
import com.zbkj.service.service.ProductInventoryService;
import com.zbkj.service.service.ProductService;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 商品库存服务实现
*/
@Service
public class ProductInventoryServiceImpl extends ServiceImpl<ProductInventoryDao, ProductInventory> implements ProductInventoryService {
@Resource
private ProductInventoryDao dao;
@Resource
private ProductInventoryRecordDao productInventoryRecordDao;
@Resource
private ProductService productService;
@Resource
private ProductAttrValueService productAttrValueService;
@Resource
private MerchantService merchantService;
@Resource
private MerchantInfoService merchantInfoService;
@Resource
private TransactionTemplate transactionTemplate;
@Override
public PageInfo<InventoryPageResponse> getPlatformPage(InventorySearchRequest request) {
LambdaQueryWrapper<ProductInventory> lqw = Wrappers.lambdaQuery();
lqw.eq(ProductInventory::getIsDel, false);
if (ObjectUtil.isNotNull(request.getMerId()) && request.getMerId() > 0) {
lqw.eq(ProductInventory::getMerId, request.getMerId());
}
if (ObjectUtil.isNotNull(request.getProductId()) && request.getProductId() > 0) {
lqw.eq(ProductInventory::getProductId, request.getProductId());
}
if (Boolean.TRUE.equals(request.getAlertOnly())) {
lqw.apply("stock <= alert_stock");
}
if (StrUtil.isNotBlank(request.getKeywords())) {
String keywords = request.getKeywords().trim();
lqw.and(wrapper -> wrapper.like(ProductInventory::getProductName, keywords)
.or().like(ProductInventory::getSku, keywords));
}
lqw.orderByDesc(ProductInventory::getLastOperateTime, ProductInventory::getId);
Page<ProductInventory> page = PageHelper.startPage(request.getPage(), request.getLimit());
List<ProductInventory> inventoryList = dao.selectList(lqw);
if (CollUtil.isEmpty(inventoryList)) {
return com.zbkj.common.page.CommonPage.copyPageInfo(page, new ArrayList<>());
}
Map<Integer, Merchant> merchantMap = merchantService.getMapByIdList(inventoryList.stream()
.map(ProductInventory::getMerId).filter(Objects::nonNull).distinct().collect(Collectors.toList()));
List<InventoryPageResponse> responseList = inventoryList.stream().map(item -> {
InventoryPageResponse response = new InventoryPageResponse();
BeanUtils.copyProperties(item, response);
Merchant merchant = merchantMap.get(item.getMerId());
response.setMerchantName(ObjectUtil.isNotNull(merchant) ? merchant.getName() : "");
return response;
}).collect(Collectors.toList());
return com.zbkj.common.page.CommonPage.copyPageInfo(page, responseList);
}
@Override
public PageInfo<InventoryRecordPageResponse> getRecordPage(InventorySearchRequest request) {
LambdaQueryWrapper<ProductInventoryRecord> lqw = Wrappers.lambdaQuery();
if (ObjectUtil.isNotNull(request.getMerId()) && request.getMerId() > 0) {
lqw.eq(ProductInventoryRecord::getMerId, request.getMerId());
}
if (ObjectUtil.isNotNull(request.getProductId()) && request.getProductId() > 0) {
lqw.eq(ProductInventoryRecord::getProductId, request.getProductId());
}
if (StrUtil.isNotBlank(request.getSourceType())) {
lqw.eq(ProductInventoryRecord::getSourceType, request.getSourceType());
}
if (StrUtil.isNotBlank(request.getKeywords())) {
String keywords = request.getKeywords().trim();
lqw.and(wrapper -> wrapper.like(ProductInventoryRecord::getProductName, keywords)
.or().like(ProductInventoryRecord::getSku, keywords)
.or().like(ProductInventoryRecord::getSourceNo, keywords));
}
lqw.orderByDesc(ProductInventoryRecord::getId);
Page<ProductInventoryRecord> page = PageHelper.startPage(request.getPage(), request.getLimit());
List<ProductInventoryRecord> recordList = productInventoryRecordDao.selectList(lqw);
if (CollUtil.isEmpty(recordList)) {
return com.zbkj.common.page.CommonPage.copyPageInfo(page, new ArrayList<>());
}
Map<Integer, Merchant> merchantMap = merchantService.getMapByIdList(recordList.stream()
.map(ProductInventoryRecord::getMerId).filter(Objects::nonNull).distinct().collect(Collectors.toList()));
List<InventoryRecordPageResponse> responseList = recordList.stream().map(item -> {
InventoryRecordPageResponse response = new InventoryRecordPageResponse();
BeanUtils.copyProperties(item, response);
Merchant merchant = merchantMap.get(item.getMerId());
response.setMerchantName(ObjectUtil.isNotNull(merchant) ? merchant.getName() : "");
return response;
}).collect(Collectors.toList());
return com.zbkj.common.page.CommonPage.copyPageInfo(page, responseList);
}
@Override
public Boolean adjust(InventoryAdjustRequest request, SystemAdmin admin) {
Product product = productService.getById(request.getProductId());
if (ObjectUtil.isNull(product) || Boolean.TRUE.equals(product.getIsDel())) {
throw new CrmebException("商品不存在");
}
ProductAttrValue attrValue = resolveAttrValue(product, request.getAttrValueId());
Integer beforeStock = getAvailableStock(product, attrValue);
Date now = DateUtil.date();
Boolean isIn = InventorySourceTypeEnum.MANUAL_IN.getCode().equals(request.getSourceType());
if (!isIn && request.getNumber() > beforeStock) {
throw new CrmebException("库存不足,无法出库");
}
Boolean execute = transactionTemplate.execute(e -> {
if (isIn) {
productService.operationStock(product.getId(), request.getNumber(), Constants.OPERATION_TYPE_QUICK_ADD);
if (ObjectUtil.isNotNull(attrValue)) {
productAttrValueService.operationStock(attrValue.getId(), request.getNumber(), Constants.OPERATION_TYPE_QUICK_ADD,
product.getType(), product.getMarketingType(), attrValue.getVersion());
}
} else {
productService.operationStock(product.getId(), request.getNumber(), Constants.OPERATION_TYPE_DELETE);
if (ObjectUtil.isNotNull(attrValue)) {
productAttrValueService.operationStock(attrValue.getId(), request.getNumber(), Constants.OPERATION_TYPE_DELETE,
product.getType(), product.getMarketingType(), attrValue.getVersion());
}
}
Product freshProduct = productService.getById(product.getId());
ProductAttrValue freshAttrValue = ObjectUtil.isNotNull(attrValue) ? productAttrValueService.getById(attrValue.getId()) : null;
Integer afterStock = ObjectUtil.isNotNull(freshAttrValue) ? freshAttrValue.getStock() : freshProduct.getStock();
ProductInventory inventory = upsertInventory(freshProduct, freshAttrValue, afterStock, now);
saveRecord(inventory, freshProduct, freshAttrValue, isIn ? InventoryPmEnum.IN : InventoryPmEnum.OUT, request.getNumber(),
beforeStock, afterStock, request.getSourceType(), "", null, null, admin, request.getRemark(), now);
return Boolean.TRUE;
});
return Boolean.TRUE.equals(execute);
}
@Override
public Boolean initInventory(InventoryInitRequest request, SystemAdmin admin) {
LambdaQueryWrapper<Product> productWrapper = Wrappers.lambdaQuery();
productWrapper.eq(Product::getIsDel, false);
productWrapper.eq(Product::getMarketingType, 0);
if (ObjectUtil.isNotNull(request.getMerId()) && request.getMerId() > 0) {
productWrapper.eq(Product::getMerId, request.getMerId());
}
List<Product> productList = productService.list(productWrapper);
Date now = DateUtil.date();
for (Product product : productList) {
List<ProductAttrValue> attrValueList = productAttrValueService.getListByProductIdAndType(product.getId(),
product.getType(), product.getMarketingType(), false);
if (CollUtil.isEmpty(attrValueList)) {
syncInventorySnapshot(product, null, admin, request.getRebuild(), now);
continue;
}
for (ProductAttrValue attrValue : attrValueList) {
syncInventorySnapshot(product, attrValue, admin, request.getRebuild(), now);
}
}
return true;
}
@Override
public Boolean recordOrderDelivery(String orderNo, Integer invoiceId, List<OrderInvoiceDetail> invoiceDetailList, SystemAdmin admin) {
if (CollUtil.isEmpty(invoiceDetailList)) {
return true;
}
Date now = DateUtil.date();
invoiceDetailList.forEach(detail -> {
Product product = productService.getById(detail.getProductId());
if (ObjectUtil.isNull(product) || Boolean.TRUE.equals(product.getIsDel())) {
return;
}
ProductAttrValue attrValue = ObjectUtil.isNotNull(detail.getAttrValueId()) && detail.getAttrValueId() > 0
? productAttrValueService.getById(detail.getAttrValueId()) : null;
ProductInventory inventory = getOrInitInventory(product, attrValue, now);
Integer beforeStock = ObjectUtil.isNull(inventory.getStock()) ? 0 : inventory.getStock();
if (detail.getNum() > beforeStock) {
throw new CrmebException(StrUtil.format("商品【{}】库存不足,无法发货", product.getName()));
}
Integer afterStock = beforeStock - detail.getNum();
inventory.setStock(afterStock);
inventory.setLastOperateTime(now);
inventory.setUpdateTime(now);
saveOrUpdate(inventory);
saveRecord(inventory, product, attrValue, InventoryPmEnum.OUT, detail.getNum(), beforeStock, afterStock,
InventorySourceTypeEnum.ORDER_DELIVERY.getCode(), orderNo, invoiceId, detail.getId(), admin,
"订单发货出库", now);
});
return true;
}
@Override
public Boolean reverseOrderDelivery(String orderNo, Integer invoiceId, List<OrderInvoiceDetail> invoiceDetailList, SystemAdmin admin, String remark) {
// 一期先预留统一逆向入口,后续退款回补/取消发货按同一库存模型接入。
throw new CrmebException("逆向库存能力将在二期开放");
}
private void syncInventorySnapshot(Product product, ProductAttrValue attrValue, SystemAdmin admin, Boolean rebuild, Date now) {
Integer stock = ObjectUtil.isNotNull(attrValue) ? attrValue.getStock() : product.getStock();
ProductInventory exist = getByUniqueKey(product.getMerId(), product.getId(), ObjectUtil.isNotNull(attrValue) ? attrValue.getId() : 0);
Integer beforeStock = ObjectUtil.isNotNull(exist) ? exist.getStock() : 0;
ProductInventory inventory = upsertInventory(product, attrValue, stock, now);
if (ObjectUtil.isNull(exist) || Boolean.TRUE.equals(rebuild) || !Objects.equals(beforeStock, stock)) {
saveRecord(inventory, product, attrValue, InventoryPmEnum.IN, stock, beforeStock, stock,
InventorySourceTypeEnum.INIT_SYNC.getCode(), "", null, null, admin, "库存初始化同步", now);
}
}
private ProductAttrValue resolveAttrValue(Product product, Integer attrValueId) {
if (ObjectUtil.isNull(attrValueId) || attrValueId <= 0) {
if (!Boolean.TRUE.equals(product.getSpecType())) {
return null;
}
List<ProductAttrValue> attrValueList = productAttrValueService.getListByProductIdAndType(product.getId(),
product.getType(), product.getMarketingType(), false);
if (attrValueList.size() == 1) {
return attrValueList.get(0);
}
throw new CrmebException("多规格商品请选择具体规格");
}
ProductAttrValue attrValue = productAttrValueService.getById(attrValueId);
if (ObjectUtil.isNull(attrValue) || !attrValue.getProductId().equals(product.getId()) || Boolean.TRUE.equals(attrValue.getIsDel())) {
throw new CrmebException("商品规格不存在");
}
return attrValue;
}
private ProductInventory upsertInventory(Product product, ProductAttrValue attrValue, Integer stock, Date now) {
Integer attrValueId = ObjectUtil.isNotNull(attrValue) ? attrValue.getId() : 0;
ProductInventory inventory = getByUniqueKey(product.getMerId(), product.getId(), attrValueId);
if (ObjectUtil.isNull(inventory)) {
inventory = new ProductInventory();
inventory.setMerId(product.getMerId());
inventory.setProductId(product.getId());
inventory.setAttrValueId(attrValueId);
inventory.setIsDel(false);
inventory.setCreateTime(now);
}
if (ObjectUtil.isNull(inventory.getAlertStock())) {
inventory.setAlertStock(resolveAlertStock(product.getMerId()));
}
inventory.setSku(ObjectUtil.isNotNull(attrValue) ? attrValue.getSku() : "");
inventory.setProductName(product.getName());
inventory.setImage(ObjectUtil.isNotNull(attrValue) && StrUtil.isNotBlank(attrValue.getImage()) ? attrValue.getImage() : product.getImage());
inventory.setStock(stock);
inventory.setLastOperateTime(now);
inventory.setUpdateTime(now);
saveOrUpdate(inventory);
return inventory;
}
private ProductInventory getByUniqueKey(Integer merId, Integer productId, Integer attrValueId) {
LambdaQueryWrapper<ProductInventory> lqw = Wrappers.lambdaQuery();
lqw.eq(ProductInventory::getMerId, merId);
lqw.eq(ProductInventory::getProductId, productId);
lqw.eq(ProductInventory::getAttrValueId, attrValueId);
lqw.eq(ProductInventory::getIsDel, false);
lqw.last(" limit 1");
return dao.selectOne(lqw);
}
private Integer getAvailableStock(Product product, ProductAttrValue attrValue) {
ProductInventory inventory = getByUniqueKey(product.getMerId(), product.getId(), ObjectUtil.isNotNull(attrValue) ? attrValue.getId() : 0);
if (ObjectUtil.isNotNull(inventory) && ObjectUtil.isNotNull(inventory.getStock())) {
return inventory.getStock();
}
return ObjectUtil.isNotNull(attrValue) ? attrValue.getStock() : product.getStock();
}
private ProductInventory getOrInitInventory(Product product, ProductAttrValue attrValue, Date now) {
Integer attrValueId = ObjectUtil.isNotNull(attrValue) ? attrValue.getId() : 0;
ProductInventory inventory = getByUniqueKey(product.getMerId(), product.getId(), attrValueId);
if (ObjectUtil.isNotNull(inventory)) {
return inventory;
}
Integer currentStock = ObjectUtil.isNotNull(attrValue) ? attrValue.getStock() : product.getStock();
return upsertInventory(product, attrValue, currentStock, now);
}
private Integer resolveAlertStock(Integer merId) {
MerchantInfo merchantInfo = merchantInfoService.getByMerId(merId);
if (ObjectUtil.isNotNull(merchantInfo) && ObjectUtil.isNotNull(merchantInfo.getAlertStock()) && merchantInfo.getAlertStock() >= 0) {
return merchantInfo.getAlertStock();
}
return ProductInventoryService.DEFAULT_ALERT_STOCK;
}
private void saveRecord(ProductInventory inventory, Product product, ProductAttrValue attrValue, InventoryPmEnum pmEnum, Integer number,
Integer beforeStock, Integer afterStock, String sourceType, String sourceNo, Integer sourceId,
Integer sourceDetailId, SystemAdmin admin, String remark, Date now) {
ProductInventoryRecord record = new ProductInventoryRecord();
record.setInventoryId(inventory.getId());
record.setMerId(product.getMerId());
record.setProductId(product.getId());
record.setAttrValueId(ObjectUtil.isNotNull(attrValue) ? attrValue.getId() : 0);
record.setSku(ObjectUtil.isNotNull(attrValue) ? attrValue.getSku() : "");
record.setProductName(product.getName());
record.setPm(pmEnum.getCode());
record.setNumber(number);
record.setBeforeStock(beforeStock);
record.setAfterStock(afterStock);
record.setCostPrice(ObjectUtil.isNotNull(attrValue) ? attrValue.getCost() : product.getCost());
record.setSourceType(sourceType);
record.setSourceNo(sourceNo);
record.setSourceId(sourceId);
record.setSourceDetailId(sourceDetailId);
record.setOperateAdminId(ObjectUtil.isNotNull(admin) ? admin.getId() : 0);
record.setOperateAdminType(ObjectUtil.isNotNull(admin) ? admin.getType() : 0);
record.setOperateAdminName(ObjectUtil.isNotNull(admin) ? admin.getRealName() : "system");
record.setRemark(StrUtil.isBlank(remark) ? "" : remark);
record.setCreateTime(now);
productInventoryRecordDao.insert(record);
}
}