|
|
@@ -0,0 +1,1964 @@
|
|
|
+# 库存管理 实现计划
|
|
|
+
|
|
|
+> **面向 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**
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移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 — 创建入库单表**
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移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 — 创建入库单明细表 + 盘点表 + 套餐组成表**
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移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 — 商品表加库存预警列**
|
|
|
+
|
|
|
+```java
|
|
|
+// 迁移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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```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 添加字段:
|
|
|
+```java
|
|
|
+private Integer minStockAlert; // 库存预警下限
|
|
|
+```
|
|
|
+并添加 getter 方法:
|
|
|
+```java
|
|
|
+public Integer getMinStockAlert() { return minStockAlert; }
|
|
|
+```
|
|
|
+
|
|
|
+ProductSku.java 添加字段:
|
|
|
+```java
|
|
|
+private Integer minStockAlert; // 库存预警下限
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 7:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 8:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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` 注解:
|
|
|
+
|
|
|
+```java
|
|
|
+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:编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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:编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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:编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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` 区域添加:
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private InventoryService inventoryService;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:修改 handlePaymentSuccess() — 支付成功后记录出库流水**
|
|
|
+
|
|
|
+在 `handlePaymentSuccess()` 方法中,找到 `productService.decreaseStock(...)` 调用之后,添加流水记录。同时处理 bundle 商品穿透扣减。
|
|
|
+
|
|
|
+将原本的库存扣减逻辑:
|
|
|
+```java
|
|
|
+// 仅对实物商品扣减库存
|
|
|
+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("库存不足");
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+替换为:
|
|
|
+```java
|
|
|
+// 仅对实物商品扣减库存
|
|
|
+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` 的注入:
|
|
|
+```java
|
|
|
+@Resource
|
|
|
+private ProductBundleItemMapper bundleItemMapper;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:修改 cancel() — 取消订单后记录退款流水**
|
|
|
+
|
|
|
+在 `cancel()` 方法中,找到 `productService.increaseStock(...)` 调用之后,添加:
|
|
|
+```java
|
|
|
+// 记录退款入库流水
|
|
|
+inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
|
|
|
+ "order", order.getId(), null, "取消订单:" + orderNo);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:修改 approveRefund() — 退款后记录退款流水**
|
|
|
+
|
|
|
+在 `approveRefund()` 方法中,找到 `productService.increaseStock(...)` 调用之后,添加:
|
|
|
+```java
|
|
|
+inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
|
|
|
+ "order", order.getId(), adminId, "退款:" + orderNo);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:修改 create() — 库存检查时支持 bundle**
|
|
|
+
|
|
|
+在 `create()` 方法中,找到库存检查逻辑:
|
|
|
+```java
|
|
|
+if ("physical".equals(product.getProductType())
|
|
|
+ && product.getStock() != null
|
|
|
+ && product.getStock() < dto.getQuantity()) {
|
|
|
+ return Result.error("库存不足");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+在之前添加 bundle 检查:
|
|
|
+```java
|
|
|
+// 套餐商品:检查子商品库存
|
|
|
+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:编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 7:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// ========== 库存管理 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 标签页:库存流水 / 入库单 / 库存预警 / 盘点记录)**
|
|
|
+
|
|
|
+```vue
|
|
|
+<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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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**
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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>` 之前添加:
|
|
|
+```html
|
|
|
+<el-button size="mini" type="info" @click="showInventoryDialog(row)">库存</el-button>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:添加库存弹窗对话框**
|
|
|
+
|
|
|
+在 `</el-card>` 之前添加:
|
|
|
+```html
|
|
|
+<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() 中添加:
|
|
|
+```javascript
|
|
|
+inventoryDialogVisible: false,
|
|
|
+inventoryLoading: false,
|
|
|
+inventoryData: null,
|
|
|
+```
|
|
|
+
|
|
|
+在 methods 中添加:
|
|
|
+```javascript
|
|
|
+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**
|
|
|
+
|
|
|
+```bash
|
|
|
+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 行后)添加:
|
|
|
+```javascript
|
|
|
+{
|
|
|
+ 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**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/router/index.js
|
|
|
+git commit -m "feat: 库存管理 - 路由+侧边栏"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### 任务 12:编译验证 — 全量编译
|
|
|
+
|
|
|
+- [ ] **步骤 1:后端编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-backend && mvn clean compile
|
|
|
+```
|
|
|
+预期:BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **步骤 2:前端编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-web && npm run build
|
|
|
+```
|
|
|
+预期:无报错
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检
|
|
|
+
|
|
|
+- [ ] 规格覆盖度:所有 5 张表已实现,所有 API 端点已实现,管理端 4 标签页 + 入库单创建 + 商品列表库存按钮
|
|
|
+- [ ] 无占位符
|
|
|
+- [ ] 类型一致性:所有实体字段名与数据库列名一致,所有 API 参数名与 controller 代码一致
|