2026-08-05-inventory-management.md 77 KB

库存管理 实现计划

面向 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 — 库存流水 Mapper
  • mapper/InventoryInboundOrderMapper.java — 入库单 Mapper
  • mapper/InventoryInboundItemMapper.java — 入库单明细 Mapper
  • mapper/InventoryCountingRecordMapper.java — 盘点记录 Mapper
  • mapper/ProductBundleItemMapper.java — 套餐组成 Mapper
  • service/InventoryService.java — 库存管理核心服务
  • controller/admin/AdminInventoryController.java — 管理端库存 API

后端修改

  • entity/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 — 每行加「库存」按钮

任务 1:数据库迁移 — 建表 + 加列

文件:

  • 修改: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 列定义。

  • 步骤 6:编译验证

运行: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张新表+预警列)"
    

任务 2:后端实体类

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/InventoryTransaction.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/InventoryInboundOrder.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/InventoryInboundItem.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/InventoryCountingRecord.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/entity/ProductBundleItem.java
  • 修改:cfc-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;  // 库存预警下限
  • 步骤 7:编译验证

运行: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扩展)"
    

任务 3:后端 Mapper 接口

文件:

  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/InventoryTransactionMapper.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/InventoryInboundOrderMapper.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/InventoryInboundItemMapper.java
  • 创建:cfc-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接口"
    

任务 4:InventoryService — 核心服务

文件:

  • 创建: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(入库单/流水/盘点/预警/套餐)"
    

任务 5:AdminInventoryController — 管理端 API

文件:

  • 创建: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"
    

任务 6:订单集成 — 修改 ProductOrderService

文件:

  • 修改:cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

  • [ ] 步骤 1:注入 InventoryService

在 ProductOrderService 的 @Resource 区域添加:

@Resource
private InventoryService inventoryService;
  • 步骤 2:修改 handlePaymentSuccess() — 支付成功后记录出库流水

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;
  • 步骤 3:修改 cancel() — 取消订单后记录退款流水

cancel() 方法中,找到 productService.increaseStock(...) 调用之后,添加:

// 记录退款入库流水
inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
        "order", order.getId(), null, "取消订单:" + orderNo);
  • 步骤 4:修改 approveRefund() — 退款后记录退款流水

approveRefund() 方法中,找到 productService.increaseStock(...) 调用之后,添加:

inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
        "order", order.getId(), adminId, "退款:" + orderNo);
  • 步骤 5:修改 create() — 库存检查时支持 bundle

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穿透)"
    

任务 7:Web Admin — API 调用

文件:

  • 修改: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调用"
    

任务 8:InventoryManage.vue — 库存管理主页

文件:

  • 创建: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标签页)"
    

任务 9:InventoryInboundCreate.vue — 创建入库单

文件:

  • 创建: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"
    

任务 10:ProductManage.vue — 增加库存按钮

文件:

  • 修改:cfc-web/src/views/admin/ProductManage.vue

  • [ ] 步骤 1:在操作列增加「库存」按钮

<el-dropdown> 之前添加:

<el-button size="mini" type="info" @click="showInventoryDialog(row)">库存</el-button>
  • 步骤 2:添加库存弹窗对话框

</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>
  • 步骤 3:添加 data 和方法

在 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增加库存按钮+弹窗"
    

任务 11:路由 + 侧边栏

文件:

  • 修改: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' }
},
  • 步骤 2:添加侧边栏菜单

Layout.vue 中找到商品管理的菜单项,添加「库存管理」子菜单。

  • [ ] 步骤 3:Commit

    git add cfc-web/src/router/index.js
    git commit -m "feat: 库存管理 - 路由+侧边栏"
    

任务 12:编译验证 — 全量编译

  • [ ] 步骤 1:后端编译

    cd cfc-backend && mvn clean compile
    

预期:BUILD SUCCESS

  • [ ] 步骤 2:前端编译

    cd cfc-web && npm run build
    

预期:无报错


自检

  • 规格覆盖度:所有 5 张表已实现,所有 API 端点已实现,管理端 4 标签页 + 入库单创建 + 商品列表库存按钮
  • 无占位符
  • 类型一致性:所有实体字段名与数据库列名一致,所有 API 参数名与 controller 代码一致