面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 为商品管理模块增加完整的库存管理(入库单+流水+盘点+预警+套餐拆库存),支持商品及 SKU 级别。
架构: 后端新增 5 张表 + InventoryService + AdminInventoryController,订单支付/取消/退款时自动记录库存流水;管理端新增 InventoryManage.vue(4 标签页)+ InventoryInboundCreate.vue
技术栈: Spring Boot 2.7.18 + MyBatis-Plus + Vue 2 + Element UI
entity/InventoryTransaction.java — 库存流水实体entity/InventoryInboundOrder.java — 入库单实体entity/InventoryInboundItem.java — 入库单明细实体entity/InventoryCountingRecord.java — 盘点记录实体entity/ProductBundleItem.java — 套餐商品组成实体mapper/InventoryTransactionMapper.java — 库存流水 Mappermapper/InventoryInboundOrderMapper.java — 入库单 Mappermapper/InventoryInboundItemMapper.java — 入库单明细 Mappermapper/InventoryCountingRecordMapper.java — 盘点记录 Mappermapper/ProductBundleItemMapper.java — 套餐组成 Mapperservice/InventoryService.java — 库存管理核心服务controller/admin/AdminInventoryController.java — 管理端库存 APIentity/Product.java — 加 minStockAlert 字段entity/ProductSku.java — 加 minStockAlert 字段config/DatabaseInitializer.java — 迁移 168-172 建表加列resources/schema.sql — 同步新表定义service/ProductOrderService.java — 支付/取消/退款时记录库存流水 + bundle 商品穿透service/ProductService.java — 增加 bundle 库存检查支持views/admin/InventoryManage.vue — 库存管理主页(4 标签页)views/admin/InventoryInboundCreate.vue — 创建入库单api/admin.js — 新增库存管理 API 调用router/index.js — 新增库存管理路由views/admin/ProductManage.vue — 每行加「库存」按钮文件:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java修改:cfc-backend/src/main/resources/schema.sql
[ ] 步骤 1:在 DatabaseInitializer.runMigrations() 末尾添加迁移 168
// 迁移168: 创建 inventory_transactions 表(库存流水)
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS inventory_transactions (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"product_id BIGINT NOT NULL COMMENT '商品ID', " +
"sku_id BIGINT COMMENT 'SKU ID,NULL=商品级库存', " +
"type VARCHAR(20) NOT NULL COMMENT '类型: inbound/outbound/adjustment/counting/refund', " +
"direction TINYINT NOT NULL COMMENT '方向: 1=入库 -1=出库', " +
"quantity INT NOT NULL COMMENT '变动数量(正数)', " +
"before_stock INT NOT NULL COMMENT '变动前库存', " +
"after_stock INT NOT NULL COMMENT '变动后库存', " +
"reference_type VARCHAR(32) COMMENT '关联类型: inbound_order/order/counting/adjustment', " +
"reference_id BIGINT COMMENT '关联ID', " +
"operator_id BIGINT COMMENT '操作人ID,关联users.id', " +
"remark VARCHAR(500) COMMENT '备注', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"INDEX idx_product (product_id), " +
"INDEX idx_sku (sku_id), " +
"INDEX idx_type (type), " +
"INDEX idx_reference (reference_type, reference_id), " +
"INDEX idx_created_at (created_at)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表'");
log.info("已创建 inventory_transactions 表");
} catch (Exception e) {
log.warn("创建 inventory_transactions 表可能已存在: {}", e.getMessage());
}
[ ] 步骤 2:添加迁移 169 — 创建入库单表
// 迁移169: 创建 inventory_inbound_orders 表(入库单)
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS inventory_inbound_orders (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '入库单号', " +
"supplier_id BIGINT COMMENT '供应商ID,关联ecom_supplier.id', " +
"supplier_name VARCHAR(200) COMMENT '供应商名称', " +
"status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/completed/cancelled', " +
"total_items INT NOT NULL DEFAULT 0 COMMENT '商品总数', " +
"operator_id BIGINT COMMENT '操作人ID,关联users.id', " +
"remark VARCHAR(500) COMMENT '备注', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
"INDEX idx_status (status), " +
"INDEX idx_operator (operator_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库单表'");
log.info("已创建 inventory_inbound_orders 表");
} catch (Exception e) {
log.warn("创建 inventory_inbound_orders 表可能已存在: {}", e.getMessage());
}
[ ] 步骤 3:添加迁移 170 — 创建入库单明细表 + 盘点表 + 套餐组成表
// 迁移170: 创建 inventory_inbound_items 表(入库单明细)
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS inventory_inbound_items (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"inbound_order_id BIGINT NOT NULL COMMENT '入库单ID', " +
"product_id BIGINT NOT NULL COMMENT '商品ID', " +
"sku_id BIGINT COMMENT 'SKU ID', " +
"quantity INT NOT NULL COMMENT '入库数量', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"INDEX idx_inbound_order (inbound_order_id), " +
"INDEX idx_product (product_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库单明细表'");
log.info("已创建 inventory_inbound_items 表");
} catch (Exception e) {
log.warn("创建 inventory_inbound_items 表可能已存在: {}", e.getMessage());
}
// 迁移171: 创建 inventory_counting_records 表(盘点记录)
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS inventory_counting_records (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"product_id BIGINT NOT NULL COMMENT '商品ID', " +
"sku_id BIGINT COMMENT 'SKU ID', " +
"expected_stock INT NOT NULL COMMENT '系统库存', " +
"actual_stock INT NOT NULL COMMENT '实际库存', " +
"difference INT NOT NULL COMMENT '差异(actual - expected)', " +
"status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/confirmed', " +
"operator_id BIGINT COMMENT '操作人ID', " +
"remark VARCHAR(500) COMMENT '备注', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"INDEX idx_product (product_id), " +
"INDEX idx_status (status)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='盘点记录表'");
log.info("已创建 inventory_counting_records 表");
} catch (Exception e) {
log.warn("创建 inventory_counting_records 表可能已存在: {}", e.getMessage());
}
// 迁移172: 创建 product_bundle_items 表(套餐商品组成)
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_bundle_items (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"bundle_product_id BIGINT NOT NULL COMMENT '套餐商品ID,关联products.id', " +
"child_product_id BIGINT NOT NULL COMMENT '子商品ID,关联products.id', " +
"child_sku_id BIGINT COMMENT '子商品SKU ID,NULL=商品级', " +
"quantity INT NOT NULL DEFAULT 1 COMMENT '子商品数量', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"INDEX idx_bundle (bundle_product_id), " +
"INDEX idx_child (child_product_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐商品组成表'");
log.info("已创建 product_bundle_items 表");
} catch (Exception e) {
log.warn("创建 product_bundle_items 表可能已存在: {}", e.getMessage());
}
[ ] 步骤 4:添加迁移 173 — 商品表加库存预警列
// 迁移173: products表添加min_stock_alert列(库存预警下限)
ensureColumn("products", "min_stock_alert", "INT DEFAULT 10 COMMENT '库存预警下限'");
// 迁移173: product_skus表添加min_stock_alert列(库存预警下限)
ensureColumn("product_skus", "min_stock_alert", "INT DEFAULT 10 COMMENT '库存预警下限'");
[ ] 步骤 5:同步 schema.sql
在 schema.sql 末尾追加 5 张新表的 CREATE TABLE IF NOT EXISTS 语句,以及 products/product_skus 的 min_stock_alert 列定义。
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 7:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
git add cfc-backend/src/main/resources/schema.sql
git commit -m "feat: 库存管理 - 数据库迁移(5张新表+预警列)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/entity/InventoryTransaction.javacfc-backend/src/main/java/com/etotem/cfc/entity/InventoryInboundOrder.javacfc-backend/src/main/java/com/etotem/cfc/entity/InventoryInboundItem.javacfc-backend/src/main/java/com/etotem/cfc/entity/InventoryCountingRecord.javacfc-backend/src/main/java/com/etotem/cfc/entity/ProductBundleItem.javacfc-backend/src/main/java/com/etotem/cfc/entity/Product.java修改:cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java
[ ] 步骤 1:创建 InventoryTransaction.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("inventory_transactions")
public class InventoryTransaction implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long productId;
private Long skuId;
private String type; // inbound/outbound/adjustment/counting/refund
private Integer direction; // 1=入库 -1=出库
private Integer quantity;
private Integer beforeStock;
private Integer afterStock;
private String referenceType; // inbound_order/order/counting/adjustment
private Long referenceId;
private Long operatorId;
private String remark;
private Date createdAt;
}
[ ] 步骤 2:创建 InventoryInboundOrder.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("inventory_inbound_orders")
public class InventoryInboundOrder implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private Long supplierId;
private String supplierName;
private String status; // pending/completed/cancelled
private Integer totalItems;
private Long operatorId;
private String remark;
private Date createdAt;
private Date updatedAt;
}
[ ] 步骤 3:创建 InventoryInboundItem.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("inventory_inbound_items")
public class InventoryInboundItem implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long inboundOrderId;
private Long productId;
private Long skuId;
private Integer quantity;
private Date createdAt;
}
[ ] 步骤 4:创建 InventoryCountingRecord.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("inventory_counting_records")
public class InventoryCountingRecord implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long productId;
private Long skuId;
private Integer expectedStock;
private Integer actualStock;
private Integer difference;
private String status; // pending/confirmed
private Long operatorId;
private String remark;
private Date createdAt;
}
[ ] 步骤 5:创建 ProductBundleItem.java
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
@TableName("product_bundle_items")
public class ProductBundleItem implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long bundleProductId;
private Long childProductId;
private Long childSkuId;
private Integer quantity;
private Date createdAt;
}
[ ] 步骤 6:更新 Product.java 和 ProductSku.java
Product.java 添加字段:
private Integer minStockAlert; // 库存预警下限
并添加 getter 方法:
public Integer getMinStockAlert() { return minStockAlert; }
ProductSku.java 添加字段:
private Integer minStockAlert; // 库存预警下限
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 8:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/entity/
git commit -m "feat: 库存管理 - 实体类(5个新实体 + Product/ProductSku扩展)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/mapper/InventoryTransactionMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/InventoryInboundOrderMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/InventoryInboundItemMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/InventoryCountingRecordMapper.java创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductBundleItemMapper.java
[ ] 步骤 1:创建 5 个 Mapper 接口
每个 Mapper 继承 BaseMapper<T>,使用 @Mapper 注解:
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.InventoryTransaction;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InventoryTransactionMapper extends BaseMapper<InventoryTransaction> {}
对其他 4 个 Mapper 同样操作,仅替换实体类型和文件名。
[ ] 步骤 2:编译验证
cd cfc-backend && mvn clean compile
[ ] 步骤 3:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/mapper/Inventory*.java
git add cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductBundleItemMapper.java
git commit -m "feat: 库存管理 - Mapper接口"
文件:
创建:cfc-backend/src/main/java/com/etotem/cfc/service/InventoryService.java
[ ] 步骤 1:创建 InventoryService.java
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.*;
import com.etotem.cfc.mapper.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class InventoryService {
@Resource
private InventoryTransactionMapper transactionMapper;
@Resource
private InventoryInboundOrderMapper inboundOrderMapper;
@Resource
private InventoryInboundItemMapper inboundItemMapper;
@Resource
private InventoryCountingRecordMapper countingRecordMapper;
@Resource
private ProductBundleItemMapper bundleItemMapper;
@Resource
private ProductMapper productMapper;
@Resource
private ProductSkuMapper productSkuMapper;
// ========== 入库单 ==========
@Transactional
public Result<Map<String, Object>> createInboundOrder(Long supplierId, String supplierName,
List<Map<String, Object>> items, Long operatorId, String remark) {
if (items == null || items.isEmpty()) {
return Result.error("入库明细不能为空");
}
// 生成入库单号
String orderNo = "IN-" + new SimpleDateFormat("yyyyMMdd").format(new Date())
+ "-" + System.currentTimeMillis() % 10000;
InventoryInboundOrder order = new InventoryInboundOrder();
order.setOrderNo(orderNo);
order.setSupplierId(supplierId);
order.setSupplierName(supplierName);
order.setStatus("pending");
order.setTotalItems(items.size());
order.setOperatorId(operatorId);
order.setRemark(remark);
order.setCreatedAt(new Date());
order.setUpdatedAt(new Date());
inboundOrderMapper.insert(order);
for (Map<String, Object> item : items) {
InventoryInboundItem inboundItem = new InventoryInboundItem();
inboundItem.setInboundOrderId(order.getId());
inboundItem.setProductId(Long.valueOf(item.get("productId").toString()));
if (item.get("skuId") != null) {
inboundItem.setSkuId(Long.valueOf(item.get("skuId").toString()));
}
inboundItem.setQuantity(Integer.valueOf(item.get("quantity").toString()));
inboundItem.setCreatedAt(new Date());
inboundItemMapper.insert(inboundItem);
}
Map<String, Object> result = new HashMap<>();
result.put("id", order.getId());
result.put("orderNo", orderNo);
return Result.success(result);
}
@Transactional
public Result<String> confirmInbound(Long orderId, Long operatorId) {
InventoryInboundOrder order = inboundOrderMapper.selectById(orderId);
if (order == null) return Result.error("入库单不存在");
if (!"pending".equals(order.getStatus())) return Result.error("入库单状态异常");
List<InventoryInboundItem> items = inboundItemMapper.selectList(
new LambdaQueryWrapper<InventoryInboundItem>().eq(InventoryInboundItem::getInboundOrderId, orderId));
for (InventoryInboundItem item : items) {
Product product = productMapper.selectById(item.getProductId());
if (product == null) continue;
int beforeStock = product.getStock() != null ? product.getStock() : 0;
int afterStock = beforeStock + item.getQuantity();
// 更新商品库存
if (item.getSkuId() != null) {
ProductSku sku = productSkuMapper.selectById(item.getSkuId());
if (sku != null) {
int skuBefore = sku.getStock() != null ? sku.getStock() : 0;
sku.setStock(skuBefore + item.getQuantity());
sku.setUpdateTime(new Date());
productSkuMapper.updateById(sku);
}
} else {
product.setStock(afterStock);
product.setUpdatedAt(new Date());
productMapper.updateById(product);
}
// 写入流水
InventoryTransaction tx = new InventoryTransaction();
tx.setProductId(item.getProductId());
tx.setSkuId(item.getSkuId());
tx.setType("inbound");
tx.setDirection(1);
tx.setQuantity(item.getQuantity());
tx.setBeforeStock(beforeStock);
tx.setAfterStock(afterStock);
tx.setReferenceType("inbound_order");
tx.setReferenceId(orderId);
tx.setOperatorId(operatorId);
tx.setCreatedAt(new Date());
transactionMapper.insert(tx);
}
order.setStatus("completed");
order.setUpdatedAt(new Date());
inboundOrderMapper.updateById(order);
return Result.success("入库成功");
}
@Transactional
public Result<String> cancelInbound(Long orderId) {
InventoryInboundOrder order = inboundOrderMapper.selectById(orderId);
if (order == null) return Result.error("入库单不存在");
if (!"pending".equals(order.getStatus())) return Result.error("仅待确认入库单可取消");
order.setStatus("cancelled");
order.setUpdatedAt(new Date());
inboundOrderMapper.updateById(order);
return Result.success("已取消");
}
public Result<Map<String, Object>> listInboundOrders(int page, int size, String status, String keyword) {
Page<InventoryInboundOrder> pageParam = new Page<>(page, size);
LambdaQueryWrapper<InventoryInboundOrder> wrapper = new LambdaQueryWrapper<InventoryInboundOrder>()
.orderByDesc(InventoryInboundOrder::getCreatedAt);
if (status != null && !status.isEmpty()) {
wrapper.eq(InventoryInboundOrder::getStatus, status);
}
Page<InventoryInboundOrder> result = inboundOrderMapper.selectPage(pageParam, wrapper);
Map<String, Object> data = new HashMap<>();
data.put("records", result.getRecords());
data.put("total", result.getTotal());
data.put("page", result.getCurrent());
data.put("size", result.getSize());
return Result.success(data);
}
public Result<Map<String, Object>> detailInboundOrder(Long id) {
InventoryInboundOrder order = inboundOrderMapper.selectById(id);
if (order == null) return Result.error("入库单不存在");
List<InventoryInboundItem> items = inboundItemMapper.selectList(
new LambdaQueryWrapper<InventoryInboundItem>().eq(InventoryInboundItem::getInboundOrderId, id));
Map<String, Object> data = new HashMap<>();
data.put("order", order);
data.put("items", items);
return Result.success(data);
}
// ========== 库存流水 ==========
public Result<Map<String, Object>> listTransactions(int page, int size, Long productId, Long skuId,
String type, String startDate, String endDate) {
Page<InventoryTransaction> pageParam = new Page<>(page, size);
LambdaQueryWrapper<InventoryTransaction> wrapper = new LambdaQueryWrapper<InventoryTransaction>()
.orderByDesc(InventoryTransaction::getCreatedAt);
if (productId != null) wrapper.eq(InventoryTransaction::getProductId, productId);
if (skuId != null) wrapper.eq(InventoryTransaction::getSkuId, skuId);
if (type != null && !type.isEmpty()) wrapper.eq(InventoryTransaction::getType, type);
if (startDate != null && !startDate.isEmpty()) {
try {
wrapper.ge(InventoryTransaction::getCreatedAt, new SimpleDateFormat("yyyy-MM-dd").parse(startDate));
} catch (Exception ignored) {}
}
if (endDate != null && !endDate.isEmpty()) {
try {
Calendar cal = Calendar.getInstance();
cal.setTime(new SimpleDateFormat("yyyy-MM-dd").parse(endDate));
cal.add(Calendar.DAY_OF_MONTH, 1);
wrapper.lt(InventoryTransaction::getCreatedAt, cal.getTime());
} catch (Exception ignored) {}
}
Page<InventoryTransaction> result = transactionMapper.selectPage(pageParam, wrapper);
Map<String, Object> data = new HashMap<>();
data.put("records", result.getRecords());
data.put("total", result.getTotal());
data.put("page", result.getCurrent());
data.put("size", result.getSize());
return Result.success(data);
}
// ========== 记录出库(供订单服务调用) ==========
@Transactional
public void recordOutbound(Long productId, Long skuId, Integer quantity, String referenceType,
Long referenceId, Long operatorId, String remark) {
Product product = productMapper.selectById(productId);
if (product == null) return;
int beforeStock = product.getStock() != null ? product.getStock() : 0;
int afterStock = beforeStock - quantity;
InventoryTransaction tx = new InventoryTransaction();
tx.setProductId(productId);
tx.setSkuId(skuId);
tx.setType("outbound");
tx.setDirection(-1);
tx.setQuantity(quantity);
tx.setBeforeStock(beforeStock);
tx.setAfterStock(afterStock);
tx.setReferenceType(referenceType);
tx.setReferenceId(referenceId);
tx.setOperatorId(operatorId);
tx.setRemark(remark);
tx.setCreatedAt(new Date());
transactionMapper.insert(tx);
}
@Transactional
public void recordRefundInbound(Long productId, Long skuId, Integer quantity, String referenceType,
Long referenceId, Long operatorId, String remark) {
Product product = productMapper.selectById(productId);
if (product == null) return;
int beforeStock = product.getStock() != null ? product.getStock() : 0;
int afterStock = beforeStock + quantity;
InventoryTransaction tx = new InventoryTransaction();
tx.setProductId(productId);
tx.setSkuId(skuId);
tx.setType("refund");
tx.setDirection(1);
tx.setQuantity(quantity);
tx.setBeforeStock(beforeStock);
tx.setAfterStock(afterStock);
tx.setReferenceType(referenceType);
tx.setReferenceId(referenceId);
tx.setOperatorId(operatorId);
tx.setRemark(remark);
tx.setCreatedAt(new Date());
transactionMapper.insert(tx);
}
// ========== 盘点 ==========
@Transactional
public Result<String> createCounting(Long productId, Long skuId, Integer actualStock, Long operatorId, String remark) {
Product product = productMapper.selectById(productId);
if (product == null) return Result.error("商品不存在");
int expectedStock = 0;
if (skuId != null) {
ProductSku sku = productSkuMapper.selectById(skuId);
if (sku != null) expectedStock = sku.getStock() != null ? sku.getStock() : 0;
} else {
expectedStock = product.getStock() != null ? product.getStock() : 0;
}
InventoryCountingRecord record = new InventoryCountingRecord();
record.setProductId(productId);
record.setSkuId(skuId);
record.setExpectedStock(expectedStock);
record.setActualStock(actualStock);
record.setDifference(actualStock - expectedStock);
record.setStatus("pending");
record.setOperatorId(operatorId);
record.setRemark(remark);
record.setCreatedAt(new Date());
countingRecordMapper.insert(record);
return Result.success("盘点记录已创建");
}
@Transactional
public Result<String> confirmCounting(Long recordId, Long operatorId) {
InventoryCountingRecord record = countingRecordMapper.selectById(recordId);
if (record == null) return Result.error("盘点记录不存在");
if (!"pending".equals(record.getStatus())) return Result.error("盘点记录已确认");
if (record.getDifference() != 0) {
// 调账: 更新库存
if (record.getSkuId() != null) {
ProductSku sku = productSkuMapper.selectById(record.getSkuId());
if (sku != null) {
sku.setStock(record.getActualStock());
sku.setUpdateTime(new Date());
productSkuMapper.updateById(sku);
}
} else {
Product product = productMapper.selectById(record.getProductId());
if (product != null) {
product.setStock(record.getActualStock());
product.setUpdatedAt(new Date());
productMapper.updateById(product);
}
}
// 写入调账流水
InventoryTransaction tx = new InventoryTransaction();
tx.setProductId(record.getProductId());
tx.setSkuId(record.getSkuId());
tx.setType("adjustment");
tx.setDirection(record.getDifference() > 0 ? 1 : -1);
tx.setQuantity(Math.abs(record.getDifference()));
tx.setBeforeStock(record.getExpectedStock());
tx.setAfterStock(record.getActualStock());
tx.setReferenceType("counting");
tx.setReferenceId(recordId);
tx.setOperatorId(operatorId);
tx.setRemark("盘点调账: " + record.getRemark());
tx.setCreatedAt(new Date());
transactionMapper.insert(tx);
}
record.setStatus("confirmed");
countingRecordMapper.updateById(record);
return Result.success("盘点已确认");
}
public Result<Map<String, Object>> listCountingRecords(int page, int size, String status) {
Page<InventoryCountingRecord> pageParam = new Page<>(page, size);
LambdaQueryWrapper<InventoryCountingRecord> wrapper = new LambdaQueryWrapper<InventoryCountingRecord>()
.orderByDesc(InventoryCountingRecord::getCreatedAt);
if (status != null && !status.isEmpty()) {
wrapper.eq(InventoryCountingRecord::getStatus, status);
}
Page<InventoryCountingRecord> result = countingRecordMapper.selectPage(pageParam, wrapper);
Map<String, Object> data = new HashMap<>();
data.put("records", result.getRecords());
data.put("total", result.getTotal());
data.put("page", result.getCurrent());
data.put("size", result.getSize());
return Result.success(data);
}
// ========== 库存预警 ==========
public Result<List<Map<String, Object>>> listStockAlerts() {
List<Map<String, Object>> alerts = new ArrayList<>();
// 查询商品级预警
List<Product> products = productMapper.selectList(
new LambdaQueryWrapper<Product>()
.eq(Product::getProductType, "physical")
.apply("min_stock_alert IS NOT NULL AND stock < min_stock_alert"));
for (Product p : products) {
Map<String, Object> alert = new HashMap<>();
alert.put("productId", p.getId());
alert.put("productName", p.getName());
alert.put("skuId", null);
alert.put("specs", null);
alert.put("currentStock", p.getStock());
alert.put("minStockAlert", p.getMinStockAlert());
alerts.add(alert);
}
// 查询SKU级预警
List<ProductSku> skus = productSkuMapper.selectList(
new LambdaQueryWrapper<ProductSku>()
.apply("min_stock_alert IS NOT NULL AND stock < min_stock_alert AND enabled = 1"));
for (ProductSku sku : skus) {
Product p = productMapper.selectById(sku.getProductId());
Map<String, Object> alert = new HashMap<>();
alert.put("productId", sku.getProductId());
alert.put("productName", p != null ? p.getName() : "未知");
alert.put("skuId", sku.getId());
alert.put("specs", sku.getSpecs());
alert.put("currentStock", sku.getStock());
alert.put("minStockAlert", sku.getMinStockAlert());
alerts.add(alert);
}
return Result.success(alerts);
}
// ========== 商品库存详情 ==========
public Result<Map<String, Object>> getProductStock(Long productId) {
Product product = productMapper.selectById(productId);
if (product == null) return Result.error("商品不存在");
Map<String, Object> data = new HashMap<>();
data.put("productId", product.getId());
data.put("productName", product.getName());
data.put("productType", product.getProductType());
data.put("stock", product.getStock());
data.put("minStockAlert", product.getMinStockAlert());
// 查询SKU库存
List<ProductSku> skus = productSkuMapper.findByProductIdAll(productId);
data.put("skus", skus);
// 最近10条流水
List<InventoryTransaction> recentTx = transactionMapper.selectList(
new LambdaQueryWrapper<InventoryTransaction>()
.eq(InventoryTransaction::getProductId, productId)
.orderByDesc(InventoryTransaction::getCreatedAt)
.last("LIMIT 10"));
data.put("recentTransactions", recentTx);
// 如果是套餐商品,查询子商品
if ("bundle".equals(product.getProductType())) {
List<ProductBundleItem> bundleItems = bundleItemMapper.selectList(
new LambdaQueryWrapper<ProductBundleItem>()
.eq(ProductBundleItem::getBundleProductId, productId));
List<Map<String, Object>> childProducts = new ArrayList<>();
for (ProductBundleItem bi : bundleItems) {
Product child = productMapper.selectById(bi.getChildProductId());
if (child != null) {
Map<String, Object> cp = new HashMap<>();
cp.put("productId", child.getId());
cp.put("productName", child.getName());
cp.put("stock", child.getStock());
cp.put("quantity", bi.getQuantity());
cp.put("skuId", bi.getChildSkuId());
childProducts.add(cp);
}
}
data.put("bundleItems", childProducts);
}
return Result.success(data);
}
// ========== 套餐商品组成 ==========
@Transactional
public Result<String> saveBundleItems(Long bundleProductId, List<Map<String, Object>> items) {
// 删除旧配置
bundleItemMapper.delete(new LambdaQueryWrapper<ProductBundleItem>()
.eq(ProductBundleItem::getBundleProductId, bundleProductId));
// 插入新配置
for (Map<String, Object> item : items) {
ProductBundleItem bi = new ProductBundleItem();
bi.setBundleProductId(bundleProductId);
bi.setChildProductId(Long.valueOf(item.get("childProductId").toString()));
if (item.get("childSkuId") != null) {
bi.setChildSkuId(Long.valueOf(item.get("childSkuId").toString()));
}
bi.setQuantity(item.get("quantity") != null ? Integer.valueOf(item.get("quantity").toString()) : 1);
bi.setCreatedAt(new Date());
bundleItemMapper.insert(bi);
}
return Result.success("套餐商品配置已保存");
}
public Result<List<ProductBundleItem>> listBundleItems(Long bundleProductId) {
List<ProductBundleItem> items = bundleItemMapper.selectList(
new LambdaQueryWrapper<ProductBundleItem>()
.eq(ProductBundleItem::getBundleProductId, bundleProductId));
return Result.success(items);
}
// ========== 套餐库存检查 ==========
public Result<String> checkBundleStock(Long bundleProductId, Integer quantity) {
List<ProductBundleItem> items = bundleItemMapper.selectList(
new LambdaQueryWrapper<ProductBundleItem>()
.eq(ProductBundleItem::getBundleProductId, bundleProductId));
if (items.isEmpty()) return Result.error("套餐未配置子商品");
for (ProductBundleItem bi : items) {
int need = bi.getQuantity() * quantity;
if (bi.getChildSkuId() != null) {
ProductSku sku = productSkuMapper.selectById(bi.getChildSkuId());
if (sku == null || sku.getStock() == null || sku.getStock() < need) {
Product p = productMapper.selectById(bi.getChildProductId());
return Result.error("子商品库存不足: " + (p != null ? p.getName() : "未知"));
}
} else {
Product p = productMapper.selectById(bi.getChildProductId());
if (p == null || p.getStock() == null || p.getStock() < need) {
return Result.error("子商品库存不足: " + (p != null ? p.getName() : "未知"));
}
}
}
return Result.success("库存充足");
}
}
[ ] 步骤 2:编译验证
cd cfc-backend && mvn clean compile
[ ] 步骤 3:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/InventoryService.java
git commit -m "feat: 库存管理 - InventoryService(入库单/流水/盘点/预警/套餐)"
文件:
创建:cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminInventoryController.java
[ ] 步骤 1:创建 AdminInventoryController.java
package com.etotem.cfc.controller.admin;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.ProductBundleItem;
import com.etotem.cfc.service.InventoryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
import java.util.Map;
@Tag(name = "管理端-库存管理")
@RestController
@RequestMapping("/api/admin/inventory")
public class AdminInventoryController {
@Resource
private InventoryService inventoryService;
// ========== 入库单 ==========
@Operation(summary = "创建入库单")
@PostMapping("/inbound/create")
public Result<Map<String, Object>> createInbound(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long adminId) {
Long supplierId = params.get("supplierId") != null ? Long.valueOf(params.get("supplierId").toString()) : null;
String supplierName = (String) params.get("supplierName");
String remark = (String) params.get("remark");
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
return inventoryService.createInboundOrder(supplierId, supplierName, items, adminId, remark);
}
@Operation(summary = "入库单列表")
@PostMapping("/inbound/list")
public Result<Map<String, Object>> listInbound(@RequestBody Map<String, Object> params) {
int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
String status = (String) params.get("status");
String keyword = (String) params.get("keyword");
return inventoryService.listInboundOrders(page, size, status, keyword);
}
@Operation(summary = "入库单详情")
@PostMapping("/inbound/detail")
public Result<Map<String, Object>> detailInbound(@RequestBody Map<String, Object> params) {
Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
if (id == null) return Result.error("id不能为空");
return inventoryService.detailInboundOrder(id);
}
@Operation(summary = "确认入库")
@PostMapping("/inbound/confirm")
public Result<String> confirmInbound(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long adminId) {
Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
if (id == null) return Result.error("id不能为空");
return inventoryService.confirmInbound(id, adminId);
}
@Operation(summary = "取消入库单")
@PostMapping("/inbound/cancel")
public Result<String> cancelInbound(@RequestBody Map<String, Object> params) {
Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
if (id == null) return Result.error("id不能为空");
return inventoryService.cancelInbound(id);
}
// ========== 库存流水 ==========
@Operation(summary = "库存流水列表")
@PostMapping("/transactions/list")
public Result<Map<String, Object>> listTransactions(@RequestBody Map<String, Object> params) {
int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
Long productId = params.get("productId") != null ? Long.valueOf(params.get("productId").toString()) : null;
Long skuId = params.get("skuId") != null ? Long.valueOf(params.get("skuId").toString()) : null;
String type = (String) params.get("type");
String startDate = (String) params.get("startDate");
String endDate = (String) params.get("endDate");
return inventoryService.listTransactions(page, size, productId, skuId, type, startDate, endDate);
}
// ========== 盘点 ==========
@Operation(summary = "创建盘点")
@PostMapping("/counting/create")
public Result<String> createCounting(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long adminId) {
Long productId = params.get("productId") != null ? Long.valueOf(params.get("productId").toString()) : null;
Long skuId = params.get("skuId") != null ? Long.valueOf(params.get("skuId").toString()) : null;
Integer actualStock = params.get("actualStock") != null ? Integer.valueOf(params.get("actualStock").toString()) : null;
String remark = (String) params.get("remark");
if (productId == null) return Result.error("productId不能为空");
if (actualStock == null) return Result.error("actualStock不能为空");
return inventoryService.createCounting(productId, skuId, actualStock, adminId, remark);
}
@Operation(summary = "确认盘点")
@PostMapping("/counting/confirm")
public Result<String> confirmCounting(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long adminId) {
Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
if (id == null) return Result.error("id不能为空");
return inventoryService.confirmCounting(id, adminId);
}
@Operation(summary = "盘点列表")
@PostMapping("/counting/list")
public Result<Map<String, Object>> listCounting(@RequestBody Map<String, Object> params) {
int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
String status = (String) params.get("status");
return inventoryService.listCountingRecords(page, size, status);
}
// ========== 库存预警 ==========
@Operation(summary = "库存预警列表")
@PostMapping("/alert/list")
public Result<List<Map<String, Object>>> listAlerts() {
return inventoryService.listStockAlerts();
}
// ========== 商品库存详情 ==========
@Operation(summary = "商品库存详情")
@PostMapping("/product/stock")
public Result<Map<String, Object>> productStock(@RequestBody Map<String, Object> params) {
Long productId = params.get("productId") != null ? Long.valueOf(params.get("productId").toString()) : null;
if (productId == null) return Result.error("productId不能为空");
return inventoryService.getProductStock(productId);
}
// ========== 套餐商品 ==========
@Operation(summary = "配置套餐商品组成")
@PostMapping("/bundle/items")
public Result<String> saveBundleItems(@RequestBody Map<String, Object> params) {
Long bundleProductId = params.get("bundleProductId") != null
? Long.valueOf(params.get("bundleProductId").toString()) : null;
if (bundleProductId == null) return Result.error("bundleProductId不能为空");
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) params.get("items");
return inventoryService.saveBundleItems(bundleProductId, items);
}
@Operation(summary = "查询套餐商品组成")
@PostMapping("/bundle/items/list")
public Result<List<ProductBundleItem>> listBundleItems(@RequestBody Map<String, Object> params) {
Long bundleProductId = params.get("bundleProductId") != null
? Long.valueOf(params.get("bundleProductId").toString()) : null;
if (bundleProductId == null) return Result.error("bundleProductId不能为空");
return inventoryService.listBundleItems(bundleProductId);
}
@Operation(summary = "检查套餐库存")
@PostMapping("/bundle/check-stock")
public Result<String> checkBundleStock(@RequestBody Map<String, Object> params) {
Long bundleProductId = params.get("bundleProductId") != null
? Long.valueOf(params.get("bundleProductId").toString()) : null;
Integer quantity = params.get("quantity") != null
? Integer.valueOf(params.get("quantity").toString()) : 1;
if (bundleProductId == null) return Result.error("bundleProductId不能为空");
return inventoryService.checkBundleStock(bundleProductId, quantity);
}
}
[ ] 步骤 2:编译验证
cd cfc-backend && mvn clean compile
[ ] 步骤 3:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminInventoryController.java
git commit -m "feat: 库存管理 - AdminInventoryController"
文件:
修改:cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
[ ] 步骤 1:注入 InventoryService
在 ProductOrderService 的 @Resource 区域添加:
@Resource
private InventoryService inventoryService;
在 handlePaymentSuccess() 方法中,找到 productService.decreaseStock(...) 调用之后,添加流水记录。同时处理 bundle 商品穿透扣减。
将原本的库存扣减逻辑:
// 仅对实物商品扣减库存
Product paidProduct = productMapper.selectById(order.getProductId());
if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
boolean stockOk = productService.decreaseStock(order.getProductId(), order.getQuantity());
if (!stockOk) {
log.error("库存扣减失败: orderNo={}, productId={}, quantity={}",
orderNo, order.getProductId(), order.getQuantity());
order.setRemark("库存扣减失败");
order.setUpdatedAt(new Date());
orderMapper.updateById(order);
throw new RuntimeException("库存不足");
}
}
替换为:
// 仅对实物商品扣减库存
Product paidProduct = productMapper.selectById(order.getProductId());
if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
boolean stockOk = productService.decreaseStock(order.getProductId(), order.getQuantity());
if (!stockOk) {
log.error("库存扣减失败: orderNo={}, productId={}, quantity={}",
orderNo, order.getProductId(), order.getQuantity());
order.setRemark("库存扣减失败");
order.setUpdatedAt(new Date());
orderMapper.updateById(order);
throw new RuntimeException("库存不足");
}
// 记录出库流水
inventoryService.recordOutbound(order.getProductId(), null, order.getQuantity(),
"order", order.getId(), null, "订单支付:" + orderNo);
} else if (paidProduct != null && "bundle".equals(paidProduct.getProductType())) {
// 套餐商品:穿透到子商品扣减库存
List<ProductBundleItem> bundleItems = bundleItemMapper.selectList(
new LambdaQueryWrapper<ProductBundleItem>()
.eq(ProductBundleItem::getBundleProductId, paidProduct.getId()));
for (ProductBundleItem bi : bundleItems) {
int need = bi.getQuantity() * order.getQuantity();
if (bi.getChildSkuId() != null) {
boolean skuOk = productSkuService.decreaseStock(bi.getChildSkuId(), need);
if (!skuOk) {
throw new RuntimeException("套餐子商品库存不足: bundleItemId=" + bi.getId());
}
inventoryService.recordOutbound(bi.getChildProductId(), bi.getChildSkuId(), need,
"order", order.getId(), null, "套餐出库:" + orderNo);
} else {
boolean stockOk = productService.decreaseStock(bi.getChildProductId(), need);
if (!stockOk) {
throw new RuntimeException("套餐子商品库存不足: bundleItemId=" + bi.getId());
}
inventoryService.recordOutbound(bi.getChildProductId(), null, need,
"order", order.getId(), null, "套餐出库:" + orderNo);
}
}
}
注意:需要添加 ProductBundleItemMapper 的注入:
@Resource
private ProductBundleItemMapper bundleItemMapper;
在 cancel() 方法中,找到 productService.increaseStock(...) 调用之后,添加:
// 记录退款入库流水
inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
"order", order.getId(), null, "取消订单:" + orderNo);
在 approveRefund() 方法中,找到 productService.increaseStock(...) 调用之后,添加:
inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
"order", order.getId(), adminId, "退款:" + orderNo);
在 create() 方法中,找到库存检查逻辑:
if ("physical".equals(product.getProductType())
&& product.getStock() != null
&& product.getStock() < dto.getQuantity()) {
return Result.error("库存不足");
}
在之前添加 bundle 检查:
// 套餐商品:检查子商品库存
if ("bundle".equals(product.getProductType())) {
Result<String> bundleCheck = inventoryService.checkBundleStock(product.getId(), dto.getQuantity());
if (bundleCheck.getCode() != 200) {
return Result.error(bundleCheck.getMessage());
}
}
类似地,在 createMultiItem() 方法中做同样的处理。
[ ] 步骤 6:编译验证
cd cfc-backend && mvn clean compile
[ ] 步骤 7:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
git commit -m "feat: 库存管理 - 订单集成(支付/取消/退款库存流水 + bundle穿透)"
文件:
修改:cfc-web/src/api/admin.js
[ ] 步骤 1:在 admin.js 末尾添加库存管理 API
// ========== 库存管理 API ==========
export function createInboundOrder(data) {
return request({
url: '/api/admin/inventory/inbound/create',
method: 'post',
data
})
}
export function getInboundOrderList(params) {
return request({
url: '/api/admin/inventory/inbound/list',
method: 'post',
data: params
})
}
export function getInboundOrderDetail(id) {
return request({
url: '/api/admin/inventory/inbound/detail',
method: 'post',
data: { id }
})
}
export function confirmInbound(id) {
return request({
url: '/api/admin/inventory/inbound/confirm',
method: 'post',
data: { id }
})
}
export function cancelInbound(id) {
return request({
url: '/api/admin/inventory/inbound/cancel',
method: 'post',
data: { id }
})
}
export function getInventoryTransactions(params) {
return request({
url: '/api/admin/inventory/transactions/list',
method: 'post',
data: params
})
}
export function createCountingRecord(data) {
return request({
url: '/api/admin/inventory/counting/create',
method: 'post',
data
})
}
export function confirmCounting(id) {
return request({
url: '/api/admin/inventory/counting/confirm',
method: 'post',
data: { id }
})
}
export function getCountingList(params) {
return request({
url: '/api/admin/inventory/counting/list',
method: 'post',
data: params
})
}
export function getStockAlerts() {
return request({
url: '/api/admin/inventory/alert/list',
method: 'post'
})
}
export function getProductStock(productId) {
return request({
url: '/api/admin/inventory/product/stock',
method: 'post',
data: { productId }
})
}
export function saveBundleItems(bundleProductId, items) {
return request({
url: '/api/admin/inventory/bundle/items',
method: 'post',
data: { bundleProductId, items }
})
}
export function getBundleItems(bundleProductId) {
return request({
url: '/api/admin/inventory/bundle/items/list',
method: 'post',
data: { bundleProductId }
})
}
export function checkBundleStock(bundleProductId, quantity) {
return request({
url: '/api/admin/inventory/bundle/check-stock',
method: 'post',
data: { bundleProductId, quantity }
})
}
[ ] 步骤 2:Commit
git add cfc-web/src/api/admin.js
git commit -m "feat: 库存管理 - 管理端API调用"
文件:
创建:cfc-web/src/views/admin/InventoryManage.vue
[ ] 步骤 1:创建 InventoryManage.vue(4 标签页:库存流水 / 入库单 / 库存预警 / 盘点记录)
<template>
<div class="inventory-manage admin-page">
<el-card>
<div slot="header" class="admin-page-header">
<span class="admin-page-title">库存管理</span>
<el-button type="success" size="mini" icon="el-icon-plus" @click="$router.push('/inventory/inbound-create')">创建入库单</el-button>
</div>
<el-tabs v-model="activeTab">
<!-- 库存流水 -->
<el-tab-pane label="库存流水" name="transactions">
<div class="filter-bar" style="margin-bottom:12px;">
<el-input v-model="txFilters.productName" placeholder="商品名称" clearable style="width:160px;margin-right:8px;" />
<el-select v-model="txFilters.type" placeholder="类型" clearable style="width:120px;margin-right:8px;">
<el-option label="全部" value="" />
<el-option label="入库" value="inbound" />
<el-option label="出库" value="outbound" />
<el-option label="调账" value="adjustment" />
<el-option label="盘点" value="counting" />
<el-option label="退款入库" value="refund" />
</el-select>
<el-date-picker v-model="txFilters.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" style="width:260px;margin-right:8px;" />
<el-button type="primary" size="mini" @click="loadTransactions">查询</el-button>
</div>
<el-table :data="txList" v-loading="txLoading" border stripe max-height="500">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="商品" min-width="150">
<template slot-scope="{ row }">{{ row.productName || 'ID:' + row.productId }}</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template slot-scope="{ row }">
<el-tag :type="row.type === 'inbound' || row.type === 'refund' ? 'success' : row.type === 'outbound' ? 'danger' : 'warning'" size="mini">
{{ {'inbound':'入库','outbound':'出库','adjustment':'调账','counting':'盘点','refund':'退款入库'}[row.type] || row.type }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="数量" width="80">
<template slot-scope="{ row }">
<span :style="{ color: row.direction === 1 ? '#67c23a' : '#f56c6c' }">
{{ row.direction === 1 ? '+' : '-' }}{{ row.quantity }}
</span>
</template>
</el-table-column>
<el-table-column prop="beforeStock" label="变动前" width="70" />
<el-table-column prop="afterStock" label="变动后" width="70" />
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column prop="createdAt" label="时间" width="160" />
</el-table>
<el-pagination @size-change="txPageSize=$event;loadTransactions()" @current-change="txPage=$event;loadTransactions()" :current-page="txPage" :page-sizes="[10,20,50]" :page-size="txPageSize" :total="txTotal" layout="total, sizes, prev, pager, next, jumper" class="pagination-wrap" />
</el-tab-pane>
<!-- 入库单 -->
<el-tab-pane label="入库单" name="inbound">
<el-table :data="inboundList" v-loading="inboundLoading" border stripe max-height="500">
<el-table-column prop="orderNo" label="入库单号" width="180" />
<el-table-column prop="supplierName" label="供应商" width="150" />
<el-table-column prop="totalItems" label="商品项数" width="80" />
<el-table-column label="状态" width="90">
<template slot-scope="{ row }">
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'cancelled' ? 'info' : 'warning'" size="mini">
{{ {'pending':'待确认','completed':'已完成','cancelled':'已取消'}[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160" />
<el-table-column label="操作" width="200" fixed="right">
<template slot-scope="{ row }">
<el-button size="mini" @click="showInboundDetail(row)">详情</el-button>
<el-button v-if="row.status === 'pending'" size="mini" type="success" @click="handleConfirmInbound(row)">确认入库</el-button>
<el-button v-if="row.status === 'pending'" size="mini" type="danger" @click="handleCancelInbound(row)">取消</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination @size-change="inboundPageSize=$event;loadInboundOrders()" @current-change="inboundPage=$event;loadInboundOrders()" :current-page="inboundPage" :page-sizes="[10,20,50]" :page-size="inboundPageSize" :total="inboundTotal" layout="total, sizes, prev, pager, next, jumper" class="pagination-wrap" />
</el-tab-pane>
<!-- 库存预警 -->
<el-tab-pane label="库存预警" name="alerts">
<el-button type="primary" size="mini" @click="loadAlerts" style="margin-bottom:12px;">刷新</el-button>
<el-table :data="alertList" v-loading="alertLoading" border stripe max-height="500">
<el-table-column prop="productName" label="商品名称" min-width="200" />
<el-table-column prop="specs" label="SKU规格" width="150">
<template slot-scope="{ row }">{{ row.specs || '-' }}</template>
</el-table-column>
<el-table-column prop="currentStock" label="当前库存" width="80">
<template slot-scope="{ row }"><span style="color:#f56c6c;font-weight:bold;">{{ row.currentStock }}</span></template>
</el-table-column>
<el-table-column prop="minStockAlert" label="预警下限" width="80" />
</el-table>
</el-tab-pane>
<!-- 盘点记录 -->
<el-tab-pane label="盘点记录" name="counting">
<el-button type="primary" size="mini" @click="showCreateCounting = true" style="margin-bottom:12px;">新建盘点</el-button>
<el-table :data="countingList" v-loading="countingLoading" border stripe max-height="500">
<el-table-column prop="productId" label="商品ID" width="70" />
<el-table-column label="系统库存" width="80">
<template slot-scope="{ row }">{{ row.expectedStock }}</template>
</el-table-column>
<el-table-column label="实际库存" width="80">
<template slot-scope="{ row }">{{ row.actualStock }}</template>
</el-table-column>
<el-table-column label="差异" width="80">
<template slot-scope="{ row }">
<span :style="{ color: row.difference > 0 ? '#67c23a' : row.difference < 0 ? '#f56c6c' : '#999' }">
{{ row.difference > 0 ? '+' : '' }}{{ row.difference }}
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template slot-scope="{ row }">
<el-tag :type="row.status === 'confirmed' ? 'success' : 'warning'" size="mini">
{{ row.status === 'confirmed' ? '已确认' : '待确认' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="150" />
<el-table-column prop="createdAt" label="时间" width="160" />
<el-table-column label="操作" width="100" fixed="right">
<template slot-scope="{ row }">
<el-button v-if="row.status === 'pending'" size="mini" type="success" @click="handleConfirmCounting(row)">确认</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination @size-change="countingPageSize=$event;loadCountingRecords()" @current-change="countingPage=$event;loadCountingRecords()" :current-page="countingPage" :page-sizes="[10,20,50]" :page-size="countingPageSize" :total="countingTotal" layout="total, sizes, prev, pager, next, jumper" class="pagination-wrap" />
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 入库单详情弹窗 -->
<el-dialog title="入库单详情" :visible.sync="detailDialogVisible" width="700px">
<el-descriptions :column="2" border v-if="inboundDetail">
<el-descriptions-item label="入库单号">{{ inboundDetail.order.orderNo }}</el-descriptions-item>
<el-descriptions-item label="供应商">{{ inboundDetail.order.supplierName || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ inboundDetail.order.status }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ inboundDetail.order.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-table :data="inboundDetail.items" border stripe style="margin-top:12px;" v-if="inboundDetail">
<el-table-column prop="productId" label="商品ID" width="70" />
<el-table-column prop="skuId" label="SKU ID" width="70" />
<el-table-column prop="quantity" label="数量" width="70" />
</el-table>
</el-dialog>
<!-- 新建盘点弹窗 -->
<el-dialog title="新建盘点" :visible.sync="showCreateCounting" width="450px">
<el-form label-width="100px">
<el-form-item label="商品ID">
<el-input v-model="countingForm.productId" type="number" />
</el-form-item>
<el-form-item label="SKU ID">
<el-input v-model="countingForm.skuId" type="number" placeholder="选填" />
</el-form-item>
<el-form-item label="实际库存">
<el-input v-model="countingForm.actualStock" type="number" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="countingForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="showCreateCounting = false">取消</el-button>
<el-button type="primary" @click="handleCreateCounting">提交</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { getInventoryTransactions, getInboundOrderList, getInboundOrderDetail, confirmInbound, cancelInbound, getStockAlerts, getCountingList, createCountingRecord, confirmCounting } from '@/api/admin'
export default {
name: 'InventoryManage',
data() {
return {
activeTab: 'transactions',
// 流水
txList: [], txLoading: false, txPage: 1, txPageSize: 20, txTotal: 0,
txFilters: { productName: '', type: '', dateRange: null },
// 入库单
inboundList: [], inboundLoading: false, inboundPage: 1, inboundPageSize: 20, inboundTotal: 0,
inboundDetail: null, detailDialogVisible: false,
// 预警
alertList: [], alertLoading: false,
// 盘点
countingList: [], countingLoading: false, countingPage: 1, countingPageSize: 20, countingTotal: 0,
showCreateCounting: false, countingForm: { productId: '', skuId: '', actualStock: '', remark: '' }
}
},
mounted() {
this.loadTransactions()
this.loadInboundOrders()
this.loadAlerts()
this.loadCountingRecords()
},
methods: {
async loadTransactions() {
this.txLoading = true
try {
const params = {
page: this.txPage, size: this.txPageSize,
type: this.txFilters.type || null,
startDate: this.txFilters.dateRange ? this.txFilters.dateRange[0] : null,
endDate: this.txFilters.dateRange ? this.txFilters.dateRange[1] : null
}
const res = await getInventoryTransactions(params)
this.txList = res.data.records || []
this.txTotal = res.data.total || 0
} catch (e) { this.$message.error('加载失败') }
finally { this.txLoading = false }
},
async loadInboundOrders() {
this.inboundLoading = true
try {
const res = await getInboundOrderList({ page: this.inboundPage, size: this.inboundPageSize })
this.inboundList = res.data.records || []
this.inboundTotal = res.data.total || 0
} catch (e) { this.$message.error('加载失败') }
finally { this.inboundLoading = false }
},
async showInboundDetail(row) {
try {
const res = await getInboundOrderDetail(row.id)
this.inboundDetail = res.data
this.detailDialogVisible = true
} catch (e) { this.$message.error('加载失败') }
},
async handleConfirmInbound(row) {
try {
await this.$confirm('确认入库?库存将被增加,不可撤销。', '提示')
await confirmInbound(row.id)
this.$message.success('入库成功')
this.loadInboundOrders()
} catch (e) { if (e !== 'cancel') this.$message.error('操作失败') }
},
async handleCancelInbound(row) {
try {
await this.$confirm('确认取消该入库单?', '提示')
await cancelInbound(row.id)
this.$message.success('已取消')
this.loadInboundOrders()
} catch (e) { if (e !== 'cancel') this.$message.error('操作失败') }
},
async loadAlerts() {
this.alertLoading = true
try {
const res = await getStockAlerts()
this.alertList = res.data || []
} catch (e) { this.$message.error('加载失败') }
finally { this.alertLoading = false }
},
async loadCountingRecords() {
this.countingLoading = true
try {
const res = await getCountingList({ page: this.countingPage, size: this.countingPageSize })
this.countingList = res.data.records || []
this.countingTotal = res.data.total || 0
} catch (e) { this.$message.error('加载失败') }
finally { this.countingLoading = false }
},
async handleCreateCounting() {
if (!this.countingForm.productId || !this.countingForm.actualStock) {
return this.$message.warning('请填写必填项')
}
try {
await createCountingRecord({
productId: parseInt(this.countingForm.productId),
skuId: this.countingForm.skuId ? parseInt(this.countingForm.skuId) : null,
actualStock: parseInt(this.countingForm.actualStock),
remark: this.countingForm.remark
})
this.$message.success('盘点记录已创建')
this.showCreateCounting = false
this.countingForm = { productId: '', skuId: '', actualStock: '', remark: '' }
this.loadCountingRecords()
} catch (e) { this.$message.error('操作失败') }
},
async handleConfirmCounting(row) {
try {
await this.$confirm('确认盘点结果?差异将自动调账。', '提示')
await confirmCounting(row.id)
this.$message.success('盘点已确认')
this.loadCountingRecords()
} catch (e) { if (e !== 'cancel') this.$message.error('操作失败') }
}
}
}
</script>
<style scoped>
.inventory-manage { padding: 20px; }
.filter-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.pagination-wrap { margin-top: 12px; text-align: right; }
</style>
[ ] 步骤 2:Commit
git add cfc-web/src/views/admin/InventoryManage.vue
git commit -m "feat: 库存管理 - InventoryManage.vue(4标签页)"
文件:
创建:cfc-web/src/views/admin/InventoryInboundCreate.vue
[ ] 步骤 1:创建 InventoryInboundCreate.vue
<template>
<div class="inbound-create admin-page">
<el-card>
<div slot="header" class="admin-page-header">
<span class="admin-page-title">创建入库单</span>
<el-button @click="$router.push('/inventory')">返回</el-button>
</div>
<el-form label-width="100px">
<el-form-item label="供应商">
<el-input v-model="form.supplierName" placeholder="供应商名称" style="width:300px;" />
</el-form-item>
<el-form-item label="入库明细">
<div v-for="(item, idx) in form.items" :key="idx" style="display:flex;gap:8px;margin-bottom:8px;align-items:center;">
<el-input v-model="item.productId" placeholder="商品ID" type="number" style="width:120px;" />
<el-input v-model="item.skuId" placeholder="SKU ID(选填)" type="number" style="width:120px;" />
<el-input v-model="item.quantity" placeholder="数量" type="number" style="width:100px;" />
<el-button type="danger" size="mini" @click="form.items.splice(idx,1)">删除</el-button>
</div>
<el-button type="primary" size="mini" @click="form.items.push({productId:'',skuId:'',quantity:1})">添加行</el-button>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" style="width:400px;" />
</el-form-item>
<el-form-item>
<el-button type="success" @click="handleSubmit" :loading="submitting">提交入库单</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import { createInboundOrder } from '@/api/admin'
export default {
name: 'InventoryInboundCreate',
data() {
return {
submitting: false,
form: {
supplierName: '',
remark: '',
items: [{ productId: '', skuId: '', quantity: 1 }]
}
}
},
methods: {
async handleSubmit() {
const validItems = this.form.items.filter(i => i.productId && i.quantity > 0)
if (validItems.length === 0) {
return this.$message.warning('请添加入库明细')
}
this.submitting = true
try {
const items = validItems.map(i => ({
productId: parseInt(i.productId),
skuId: i.skuId ? parseInt(i.skuId) : null,
quantity: parseInt(i.quantity)
}))
await createInboundOrder({
supplierName: this.form.supplierName || null,
remark: this.form.remark || null,
items
})
this.$message.success('入库单已创建')
this.$router.push('/inventory')
} catch (e) {
this.$message.error('创建失败')
} finally {
this.submitting = false
}
}
}
}
</script>
<style scoped>
.inbound-create { padding: 20px; }
</style>
[ ] 步骤 2:Commit
git add cfc-web/src/views/admin/InventoryInboundCreate.vue
git commit -m "feat: 库存管理 - InventoryInboundCreate.vue"
文件:
修改:cfc-web/src/views/admin/ProductManage.vue
[ ] 步骤 1:在操作列增加「库存」按钮
在 <el-dropdown> 之前添加:
<el-button size="mini" type="info" @click="showInventoryDialog(row)">库存</el-button>
在 </el-card> 之前添加:
<el-dialog title="库存管理" :visible.sync="inventoryDialogVisible" width="800px">
<div v-loading="inventoryLoading">
<el-descriptions :column="2" border v-if="inventoryData">
<el-descriptions-item label="商品名称">{{ inventoryData.productName }}</el-descriptions-item>
<el-descriptions-item label="当前库存">{{ inventoryData.stock }}</el-descriptions-item>
<el-descriptions-item label="预警下限">{{ inventoryData.minStockAlert ?? 10 }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin:16px 0 8px;">SKU 库存</h4>
<el-table :data="inventoryData.skus" border stripe v-if="inventoryData && inventoryData.skus && inventoryData.skus.length">
<el-table-column prop="id" label="SKU ID" width="70" />
<el-table-column prop="specs" label="规格" min-width="150" />
<el-table-column prop="stock" label="库存" width="70" />
<el-table-column prop="minStockAlert" label="预警下限" width="70" />
</el-table>
<div v-else-if="inventoryData" style="color:#999;padding:8px;">无SKU数据</div>
<h4 style="margin:16px 0 8px;">最近 10 条流水</h4>
<el-table :data="inventoryData.recentTransactions" border stripe v-if="inventoryData && inventoryData.recentTransactions && inventoryData.recentTransactions.length">
<el-table-column label="类型" width="80">
<template slot-scope="{ row }">
<el-tag :type="row.type === 'inbound' ? 'success' : row.type === 'outbound' ? 'danger' : 'warning'" size="mini">
{{ {'inbound':'入库','outbound':'出库','adjustment':'调账','counting':'盘点','refund':'退款入库'}[row.type] || row.type }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="数量" width="70">
<template slot-scope="{ row }">
<span :style="{ color: row.direction === 1 ? '#67c23a' : '#f56c6c' }">{{ row.direction === 1 ? '+' : '-' }}{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column prop="beforeStock" label="变动前" width="70" />
<el-table-column prop="afterStock" label="变动后" width="70" />
<el-table-column prop="remark" label="备注" min-width="150" />
<el-table-column prop="createdAt" label="时间" width="160" />
</el-table>
<div v-else style="color:#999;padding:8px;">暂无流水记录</div>
</div>
<span slot="footer">
<el-button @click="inventoryDialogVisible = false">关闭</el-button>
<el-button type="primary" @click="$router.push('/inventory')">进入库存管理</el-button>
</span>
</el-dialog>
在 data() 中添加:
inventoryDialogVisible: false,
inventoryLoading: false,
inventoryData: null,
在 methods 中添加:
async showInventoryDialog(row) {
this.inventoryDialogVisible = true
this.inventoryLoading = true
try {
const { getProductStock } = await import('@/api/admin')
const res = await getProductStock(row.id)
this.inventoryData = res.data
} catch (e) {
this.$message.error('加载库存信息失败')
} finally {
this.inventoryLoading = false
}
}
[ ] 步骤 4:Commit
git add cfc-web/src/views/admin/ProductManage.vue
git commit -m "feat: 库存管理 - ProductManage.vue增加库存按钮+弹窗"
文件:
修改:cfc-web/src/router/index.js
[ ] 步骤 1:添加库存管理路由
在 product-manage 路由附近(约第 224 行后)添加:
{
path: 'inventory',
name: 'InventoryManage',
component: () => import('@/views/admin/InventoryManage.vue'),
meta: { title: '库存管理', perm: 'commerce:products' }
},
{
path: 'inventory/inbound-create',
name: 'InventoryInboundCreate',
component: () => import('@/views/admin/InventoryInboundCreate.vue'),
meta: { title: '创建入库单', perm: 'commerce:products' }
},
在 Layout.vue 中找到商品管理的菜单项,添加「库存管理」子菜单。
[ ] 步骤 3:Commit
git add cfc-web/src/router/index.js
git commit -m "feat: 库存管理 - 路由+侧边栏"
[ ] 步骤 1:后端编译
cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 2:前端编译
cd cfc-web && npm run build
预期:无报错