Project Lean-ERP

极简主义 MVP 架构落地实战路线图

核心目标:3个月内上线
项目概览 先跑通主流程,再完善细节

本项目旨在快速交付一个可用的、业财关联的进销存 SaaS 系统。我们将摒弃复杂的批次管理、自动财务凭证等过度设计,聚焦于库存核心引擎、业务闭环和基础的业财关联,确保产品能在最短时间内验证市场价值。

技术栈确定为 IIS (前端静态托管) + Node.js (后端API) + MySQL

Phase 01 第 1 - 4 周

地基与核心库存

目标:建立多租户环境,实现最底层的库存变动逻辑。

  • 基础设施与多租户 数据库搭建,Middleware 实现 org_id 注入,RBAC 权限模型。
  • 基础数据管理 商品表 (products)、多单位换算、仓库表。
  • 核心库存引擎 (关键) 实现 increaseStock/decreaseStock 原子操作;编写移动加权平均算法;编写单元测试。
Phase 02 第 5 - 8 周

业务闭环

目标:打通销售和采购全流程,自动驱动库存变动。

  • 采购流程 采购订单 -> 审核入库 -> 触发库存增加
  • 销售流程 销售订单 -> 出库发货 -> 触发库存扣减 (校验库存可用性)。
  • 资金流 独立的收付款单据,关联客户/供应商,累加已收/已付金额。
Phase 03 第 9 - 11 周

极简财务

目标:实现"记账"功能,提供"半自动"工具而非自动生成。

  • 财务基础 标准会计科目表,手工录入凭证界面(借贷平衡校验)。
  • 业财关联 (核心) 录入凭证时,可选择关联业务单据(如出库单),系统自动抓取金额辅助填表,但不自动生成。
  • 报表 实时库存表、库存流水账、销售利润表(收入-成本)。
Phase 04 第 12 周

测试与上线

目标:系统稳定,部署到生产环境。

  • 数据修复与校准 开发"期初库存"和"期初余额"导入工具;集中测试并发扣减与成本计算。
  • 部署与文档 Nginx 配置、SSL 证书、数据库连接池调优;编写用户操作指南。
🎯 M4: 至少3个种子用户使用且无致命Bug
关键里程碑
里程碑 验收标准
M1: 库存引擎跑通 录入采购入库单,查询库存表数量增加,且 avg_cost 自动更新正确。
M2: 销售闭环 录入销售出库单,库存扣减;录入收款单,客户应收余额计算正确。
M3: 业财可查 查看凭证可关联出库单;查看出库单可看到对应凭证号。
M4: MVP 发布 至少有 3 个种子用户在内网/公网环境使用,且无致命 Bug。
🚀 代码设计仓库

📁 LeanERP-Codebase

持续更新的设计代码

没问题,推倒重来最干净。

这是一份**完整的、可直接执行**的 SQL 脚本。我已经修复了之前提到的所有细节(包括主键缺失、外键逻辑等),并按业务顺序整理好了。

您可以直接复制下面的代码,在 MySQL (erp_main) 中全部执行。

```sql
-- ===============================
-- Project Lean-ERP Database Schema
-- 版本: 1.0 (MVP Ready)
-- 环境: MySQL 8.0+
-- ===============================

-- 1. 基础系统表
-- ===============================

-- 组织表 (租户)
CREATE TABLE `sys_organizations` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(100) NOT NULL COMMENT '组织名称',
  `code` VARCHAR(50) DEFAULT NULL COMMENT '组织编码',
  `status` TINYINT DEFAULT 1 COMMENT '状态: 1正常, 2禁用, 3过期',
  `expire_date` DATE DEFAULT NULL COMMENT '过期日期',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织表';

-- 用户表
CREATE TABLE `sys_users` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(50) NOT NULL COMMENT '登录账号',
  `password` VARCHAR(255) NOT NULL COMMENT '加密密码',
  `full_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名',
  `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
  `role` TINYINT DEFAULT 3 COMMENT '角色: 1超级管理员, 2管理员, 3普通员工',
  `status` TINYINT DEFAULT 1 COMMENT '状态: 1正常, 2禁用',
  `org_id` INT DEFAULT NULL COMMENT '如果是单租户管理员可为空,普通员工必填',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

-- 组织用户关联表 (多对多)
CREATE TABLE `sys_org_users` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `user_id` INT NOT NULL,
  `org_id` INT NOT NULL,
  `is_admin` TINYINT DEFAULT 0 COMMENT '是否该组织管理员',
  `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_org` (`user_id`, `org_id`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织成员表';


-- 2. 基础数据表
-- ===============================

-- 仓库表
CREATE TABLE `base_warehouses` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `name` VARCHAR(50) NOT NULL COMMENT '仓库名称',
  `address` VARCHAR(200) DEFAULT NULL COMMENT '地址',
  `type` TINYINT DEFAULT 1 COMMENT '类型: 1正常仓, 2次品仓, 3虚拟仓',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓库表';

-- 商品分类表
CREATE TABLE `base_categories` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `name` VARCHAR(50) NOT NULL,
  `parent_id` INT DEFAULT 0 COMMENT '父级ID',
  `sort` INT DEFAULT 0 COMMENT '排序',
  PRIMARY KEY (`id`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分类';

-- 商品表
CREATE TABLE `base_products` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `code` VARCHAR(50) NOT NULL COMMENT '商品编码(唯一)',
  `name` VARCHAR(100) NOT NULL COMMENT '商品名称',
  `category_id` INT DEFAULT NULL COMMENT '分类ID',
  `unit` VARCHAR(20) DEFAULT '个' COMMENT '基础单位',
  `purchase_price` DECIMAL(12,2) DEFAULT 0.00 COMMENT '参考进价',
  `sale_price` DECIMAL(12,2) DEFAULT 0.00 COMMENT '参考售价',
  `min_stock` INT DEFAULT 0 COMMENT '最小库存预警',
  `status` TINYINT DEFAULT 1 COMMENT '状态: 1启用, 0禁用',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_code` (`org_id`, `code`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

-- 客户表
CREATE TABLE `crm_clients` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `name` VARCHAR(100) NOT NULL COMMENT '客户名称',
  `contact` VARCHAR(50) DEFAULT NULL COMMENT '联系人',
  `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
  `address` VARCHAR(200) DEFAULT NULL COMMENT '地址',
  `remark` VARCHAR(500) DEFAULT NULL,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';

-- 供应商表
CREATE TABLE `crm_suppliers` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `name` VARCHAR(100) NOT NULL COMMENT '供应商名称',
  `contact` VARCHAR(50) DEFAULT NULL COMMENT '联系人',
  `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
  `address` VARCHAR(200) DEFAULT NULL COMMENT '地址',
  `remark` VARCHAR(500) DEFAULT NULL,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商表';

-- 财务科目表
CREATE TABLE `fin_accounts` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `code` VARCHAR(20) NOT NULL COMMENT '科目编码',
  `name` VARCHAR(50) NOT NULL COMMENT '科目名称',
  `type` TINYINT DEFAULT 1 COMMENT '类型: 1资产, 2负债, 3所有者权益, 4收入, 5费用',
  `direction` TINYINT DEFAULT 1 COMMENT '借贷方向: 1借, 2贷',
  `is_leaf` TINYINT DEFAULT 1 COMMENT '是否叶子节点',
  `parent_id` INT DEFAULT 0,
  PRIMARY KEY (`id`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会计科目表';


-- 3. 业务单据表
-- ===============================

-- 采购订单
CREATE TABLE `pur_orders` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `order_no` VARCHAR(50) NOT NULL COMMENT '采购单号',
  `supplier_id` INT NOT NULL,
  `order_date` DATE NOT NULL COMMENT '单据日期',
  `total_amount` DECIMAL(12,2) DEFAULT 0.00 COMMENT '订单总额',
  `status` TINYINT DEFAULT 1 COMMENT '状态: 1草稿, 2已审核',
  `created_by` INT NOT NULL,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_no` (`org_id`, `order_no`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购订单';

-- 采购订单明细
CREATE TABLE `pur_order_items` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `order_id` INT NOT NULL,
  `product_id` INT NOT NULL,
  `quantity` DECIMAL(12,2) DEFAULT 0.00 COMMENT '数量',
  `price` DECIMAL(12,2) DEFAULT 0.00 COMMENT '单价',
  `amount` DECIMAL(12,2) DEFAULT 0.00 COMMENT '小计',
  PRIMARY KEY (`id`),
  KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购明细';

-- 采购入库单
CREATE TABLE `pur_inbounds` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `inbound_no` VARCHAR(50) NOT NULL COMMENT '入库单号',
  `order_id` INT DEFAULT NULL COMMENT '来源采购单ID',
  `supplier_id` INT NOT NULL,
  `warehouse_id` INT NOT NULL,
  `inbound_date` DATE NOT NULL,
  `total_cost` DECIMAL(12,2) DEFAULT 0.00 COMMENT '实际入库总成本',
  `status` TINYINT DEFAULT 1 COMMENT '1待审核, 2已入库',
  `created_by` INT NOT NULL,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_no` (`org_id`, `inbound_no`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='采购入库单';

-- 采购入库明细
CREATE TABLE `pur_inbound_items` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `inbound_id` INT NOT NULL,
  `product_id` INT NOT NULL,
  `quantity` DECIMAL(12,2) DEFAULT 0.00 COMMENT '实收数量',
  `cost_price` DECIMAL(12,2) DEFAULT 0.00 COMMENT '成本单价(取入库时计算)',
  PRIMARY KEY (`id`),
  KEY `idx_inbound` (`inbound_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库明细';


-- 销售订单
CREATE TABLE `sale_orders` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `order_no` VARCHAR(50) NOT NULL COMMENT '销售单号',
  `client_id` INT NOT NULL,
  `order_date` DATE NOT NULL,
  `total_amount` DECIMAL(12,2) DEFAULT 0.00 COMMENT '订单总额',
  `status` TINYINT DEFAULT 1 COMMENT '状态: 1草稿, 2已审核',
  `created_by` INT NOT NULL,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_no` (`org_id`, `order_no`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售订单';

-- 销售订单明细
CREATE TABLE `sale_order_items` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `order_id` INT NOT NULL,
  `product_id` INT NOT NULL,
  `quantity` DECIMAL(12,2) DEFAULT 0.00,
  `price` DECIMAL(12,2) DEFAULT 0.00,
  `amount` DECIMAL(12,2) DEFAULT 0.00,
  PRIMARY KEY (`id`),
  KEY `idx_order` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售明细';

-- 销售出库单
CREATE TABLE `sale_outbounds` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `outbound_no` VARCHAR(50) NOT NULL COMMENT '出库单号',
  `order_id` INT DEFAULT NULL COMMENT '来源销售订单ID',
  `client_id` INT NOT NULL,
  `warehouse_id` INT NOT NULL,
  `outbound_date` DATE NOT NULL,
  `total_quantity` DECIMAL(12,2) DEFAULT 0.00,
  `total_amount` DECIMAL(12,2) DEFAULT 0.00,
  `received_amount` DECIMAL(12,2) DEFAULT 0.00 COMMENT '已收金额(累计)',
  `status` TINYINT DEFAULT 1 COMMENT '1待出库, 2已出库, 3已取消',
  `created_by` INT NOT NULL,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_no` (`org_id`, `outbound_no`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='销售出库单';

-- 销售出库明细
CREATE TABLE `sale_outbound_items` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `outbound_id` INT NOT NULL,
  `product_id` INT NOT NULL,
  `quantity` DECIMAL(12,2) DEFAULT 0.00,
  `price` DECIMAL(12,2) DEFAULT 0.00 COMMENT '销售单价',
  `cost_price` DECIMAL(12,2) DEFAULT 0.00 COMMENT '出库时的成本价(用于计算毛利)',
  `amount` DECIMAL(12,2) DEFAULT 0.00,
  PRIMARY KEY (`id`),
  KEY `idx_outbound` (`outbound_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库明细';


-- 4. 资金与财务 (极简版)
-- ===============================

-- 收款单
CREATE TABLE `fin_receipts` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `receipt_no` VARCHAR(50) NOT NULL,
  `client_id` INT NOT NULL,
  `amount` DECIMAL(12,2) NOT NULL COMMENT '收款金额',
  `receipt_date` DATE NOT NULL,
  `payment_method` VARCHAR(20) DEFAULT '现金' COMMENT '支付方式',
  `bill_id` INT DEFAULT NULL COMMENT '关联的销售单ID(用于自动核销)',
  `remark` VARCHAR(200),
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_no` (`org_id`, `receipt_no`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收款单';

-- 付款单
CREATE TABLE `fin_payments` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `payment_no` VARCHAR(50) NOT NULL,
  `supplier_id` INT NOT NULL,
  `amount` DECIMAL(12,2) NOT NULL,
  `payment_date` DATE NOT NULL,
  `payment_method` VARCHAR(20) DEFAULT '转账',
  `bill_id` INT DEFAULT NULL COMMENT '关联的采购入库单ID',
  `remark` VARCHAR(200),
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_no` (`org_id`, `payment_no`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='付款单';

-- 记账凭证头 (手工/半自动生成)
CREATE TABLE `fin_vouchers` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `voucher_no` VARCHAR(50) NOT NULL,
  `voucher_date` DATE NOT NULL,
  `bill_type` VARCHAR(20) DEFAULT NULL COMMENT '关联业务类型: SaleOutbound, PurInbound',
  `bill_id` INT DEFAULT NULL COMMENT '关联业务ID',
  `summary` VARCHAR(200) COMMENT '摘要',
  `status` TINYINT DEFAULT 0 COMMENT '0草稿, 1已审核',
  `created_by` INT NOT NULL,
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_org_no` (`org_id`, `voucher_no`),
  KEY `idx_org` (`org_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='记账凭证';

-- 记账凭证明细 (分录)
CREATE TABLE `fin_entries` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `voucher_id` INT NOT NULL,
  `account_id` INT NOT NULL COMMENT '科目ID',
  `direction` TINYINT DEFAULT 1 COMMENT '1借, 2贷',
  `amount` DECIMAL(12,2) NOT NULL,
  `summary` VARCHAR(200),
  PRIMARY KEY (`id`),
  KEY `idx_voucher` (`voucher_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='凭证分录';


-- 5. 库存核心 (MVP 必需)
-- ===============================

-- 实时库存表 (聚合表)
CREATE TABLE `inv_stocks` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `warehouse_id` INT NOT NULL,
  `product_id` INT NOT NULL,
  `quantity` DECIMAL(12,2) DEFAULT 0.00 COMMENT '现有库存数量',
  `avg_cost` DECIMAL(12,2) DEFAULT 0.00 COMMENT '移动加权平均成本',
  `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_stock` (`org_id`, `warehouse_id`, `product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实时库存表';

-- 库存流水表 (追溯表)
CREATE TABLE `inv_movements` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `org_id` INT NOT NULL,
  `warehouse_id` INT NOT NULL,
  `product_id` INT NOT NULL,
  `bill_type` VARCHAR(20) NOT NULL COMMENT '来源: PurInbound, SaleOutbound, Init',
  `bill_id` INT NOT NULL COMMENT '来源单据ID',
  `quantity` DECIMAL(12,2) NOT NULL COMMENT '变动数量(正入负出)',
  `cost_price` DECIMAL(12,2) DEFAULT 0.00 COMMENT '变动时的成本价',
  `balance` DECIMAL(12,2) DEFAULT 0.00 COMMENT '变动后余额',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), -- 修正点:这里必须显式声明主键
  KEY `idx_org_product` (`org_id`, `product_id`),
  KEY `idx_bill` (`bill_type`, `bill_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水账';
```           

------
关键设计点备注(开发时需注意):
单号生成:所有 *_no 字段(如 order_no)建议在后端代码生成,格式为 业务前缀 + YYYYMMDD + 随机4位(例如 PO20231027001),并在代码中利用 Redis 原子锁或数据库事务保证唯一性。
库存更新事务:
入库时:START TRANSACTION -> INSERT INTO pur_inbound_items -> INSERT INTO inv_movements -> UPDATE inv_stocks (quantity=quantity+?, avg_cost=新计算值) -> COMMIT。
出库时:START TRANSACTION -> INSERT INTO sale_outbound_items -> INSERT INTO inv_movements (quantity=-?) -> UPDATE inv_stocks (quantity=quantity-?) -> COMMIT。
数据隔离:所有 SQL 查询必须带上 WHERE org_id = ? 条件,建议在 Node.js 的 ORM/DB 中间件层自动注入此条件,防止数据泄露。
财务凭证:fin_vouchers 表设计为了“松耦合”。在销售出库后,不会自动创建凭证。财务人员点击“生成凭证”按钮时,后端会创建 fin_vouchers,并将 bill_type 设为 SaleOutbound,bill_id 设为出库单 ID。这允许了“先货后票”的场景。


 
/**
 * 库存核心服务
 * 实现移动加权平均成本计算
 */
@Service
@Transactional
public class StockService {
    
    @Autowired
    private StockCurrentRepository stockRepo;
    @Autowired
    private StockRecordRepository recordRepo;
    
    /**
     * 入库操作(增加库存)
     */
    public StockResult increaseStock(StockInRequest request) {
        // 1. 获取当前库存记录
        StockCurrent stock = stockRepo.findByOrgAndWarehouseAndProduct(
            request.orgId, request.warehouseId, request.productId)
            .orElseGet(() -> createNewStock(request));
        
        // 2. 计算移动加权平均成本
        BigDecimal oldTotalCost = stock.getAvgCost().multiply(stock.getQuantity());
        BigDecimal inCost = request.unitCost.multiply(request.quantity);
        BigDecimal newTotalCost = oldTotalCost.add(inCost);
        BigDecimal newQuantity = stock.getQuantity().add(request.quantity);
        
        BigDecimal newAvgCost = newTotalCost.divide(newQuantity, 4, RoundingMode.HALF_UP);
        
        // 3. 更新库存
        stock.setQuantity(newQuantity);
        stock.setAvgCost(newAvgCost);
        stockRepo.save(stock);
        
        // 4. 记录流水
        StockRecord record = StockRecord.builder()
            .orgId(request.orgId)
            .warehouseId(request.warehouseId)
            .productId(request.productId)
            .billType(request.billType)
            .billId(request.billId)
            .quantity(request.quantity)
            .unitCost(request.unitCost)
            .totalCost(inCost)
            .build();
        recordRepo.save(record);
        
        return StockResult.builder()
            .oldQuantity(stock.getQuantity().subtract(request.quantity))
            .newQuantity(newQuantity)
            .oldAvgCost(stock.getAvgCost())
            .newAvgCost(newAvgCost)
            .build();
    }
    
    /**
     * 出库操作(扣减库存)
     * 使用移动平均成本计算出库成本
     */
    public StockResult decreaseStock(StockOutRequest request) {
        // 1. 检查库存是否足够
        StockCurrent stock = stockRepo.findByOrgAndWarehouseAndProduct(
            request.orgId, request.warehouseId, request.productId)
            .orElseThrow(() -> new StockNotFoundException());
        
        if (stock.getQuantity().compareTo(request.quantity) < 0) {
            throw new InsufficientStockException();
        }
        
        // 2. 计算出库成本(使用当前平均成本)
        BigDecimal outCost = stock.getAvgCost().multiply(request.quantity);
        BigDecimal newQuantity = stock.getQuantity().subtract(request.quantity);
        
        // 3. 更新库存(出库不改变平均成本)
        stock.setQuantity(newQuantity);
        stockRepo.save(stock);
        
        // 4. 记录流水(出库数量为负)
        StockRecord record = StockRecord.builder()
            .orgId(request.orgId)
            .warehouseId(request.warehouseId)
            .productId(request.productId)
            .billType(request.billType)
            .billId(request.billId)
            .quantity(request.quantity.negate()) // 负数为出库
            .unitCost(stock.getAvgCost())
            .totalCost(outCost.negate())
            .build();
        recordRepo.save(record);
        
        return StockResult.builder()
            .oldQuantity(stock.getQuantity().add(request.quantity))
            .newQuantity(newQuantity)
            .avgCost(stock.getAvgCost())
            .outCost(outCost)
            .build();
    }
}
from django.db import models
from decimal import Decimal

class BaseModel(models.Model):
    """所有模型的基类"""
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    created_by = models.IntegerField(null=True, blank=True)
    
    class Meta:
        abstract = True

class Product(BaseModel):
    """商品模型"""
    org = models.ForeignKey('Organization', on_delete=models.CASCADE)
    code = models.CharField(max_length=50, db_index=True)
    name = models.CharField(max_length=100)
    category = models.ForeignKey('Category', null=True, on_delete=models.SET_NULL)
    base_unit = models.ForeignKey('Unit', on_delete=models.PROTECT)
    purchase_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    sale_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    tax_rate = models.DecimalField(max_digits=5, decimal_places=2, default=13)
    
    class Meta:
        unique_together = ['org', 'code']
        db_table = 'basic_products'
    
    def __str__(self):
        return f"{self.code} - {self.name}"

class StockCurrent(BaseModel):
    """实时库存"""
    org = models.ForeignKey('Organization', on_delete=models.CASCADE)
    warehouse = models.ForeignKey('Warehouse', on_delete=models.CASCADE)
    product = models.ForeignKey('Product', on_delete=models.CASCADE)
    quantity = models.DecimalField(max_digits=12, decimal_places=2, default=0)
    avg_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
    
    class Meta:
        unique_together = ['org', 'warehouse', 'product']
        db_table = 'stock_current'
        indexes = [
            models.Index(fields=['org', 'product']),
        ]
    
    @property
    def total_cost(self):
        """库存总成本"""
        return self.quantity * self.avg_cost

class StockRecord(BaseModel):
    """库存流水"""
    BILL_TYPES = (
        ('PUR_IN', '采购入库'),
        ('SALE_OUT', '销售出库'),
        ('CHECK', '盘点调整'),
        ('TRANSFER', '仓库调拨'),
    )
    
    org = models.ForeignKey('Organization', on_delete=models.CASCADE)
    warehouse = models.ForeignKey('Warehouse', on_delete=models.CASCADE)
    product = models.ForeignKey('Product', on_delete=models.CASCADE)
    bill_type = models.CharField(max_length=20, choices=BILL_TYPES)
    bill_id = models.IntegerField()
    quantity = models.DecimalField(max_digits=12, decimal_places=2)
    unit_cost = models.DecimalField(max_digits=12, decimal_places=4)
    total_cost = models.DecimalField(max_digits=14, decimal_places=2)
    
    class Meta:
        db_table = 'stock_records'
        indexes = [
            models.Index(fields=['org', 'bill_type', 'bill_id']),
            models.Index(fields=['org', 'product', 'created_at']),
        ]
/**
 * 库存API模块 - 前端调用接口
 */
class InventoryAPI {
    
    constructor(baseURL) {
        this.baseURL = baseURL || '/api/v1';
        this.headers = {
            'Content-Type': 'application/json',
            'X-Org-Id': localStorage.getItem('orgId')
        };
    }
    
    /**
     * 采购入库
     * @param {Object} data 入库数据
     */
    async purchaseInbound(data) {
        const response = await fetch(`${this.baseURL}/inventory/inbound`, {
            method: 'POST',
            headers: this.headers,
            body: JSON.stringify({
                bill_type: 'PUR_IN',
                bill_id: data.inboundId,
                warehouse_id: data.warehouseId,
                items: data.items.map(item => ({
                    product_id: item.productId,
                    quantity: item.quantity,
                    unit_cost: item.unitCost
                }))
            })
        });
        
        if (!response.ok) {
            throw new Error(`入库失败: ${response.statusText}`);
        }
        
        return await response.json();
    }
    
    /**
     * 销售出库
     * @param {Object} data 出库数据
     */
    async saleOutbound(data) {
        // 1. 预检查库存
        const stockCheck = await this.checkStockAvailability(data.items);
        if (!stockCheck.success) {
            throw new Error(`库存不足: ${stockCheck.message}`);
        }
        
        // 2. 执行出库
        const response = await fetch(`${this.baseURL}/inventory/outbound`, {
            method: 'POST',
            headers: this.headers,
            body: JSON.stringify({
                bill_type: 'SALE_OUT',
                bill_id: data.outboundId,
                warehouse_id: data.warehouseId,
                items: data.items.map(item => ({
                    product_id: item.productId,
                    quantity: item.quantity
                }))
            })
        });
        
        if (!response.ok) {
            throw new Error(`出库失败: ${response.statusText}`);
        }
        
        return await response.json();
    }
    
    /**
     * 检查库存可用性
     */
    async checkStockAvailability(items) {
        const response = await fetch(`${this.baseURL}/inventory/check-stock`, {
            method: 'POST',
            headers: this.headers,
            body: JSON.stringify(items)
        });
        
        return await response.json();
    }
    
    /**
     * 获取商品库存详情
     */
    async getProductStock(productId, warehouseId = null) {
        let url = `${this.baseURL}/inventory/product/${productId}`;
        if (warehouseId) {
            url += `?warehouse_id=${warehouseId}`;
        }
        
        const response = await fetch(url, { headers: this.headers });
        return await response.json();
    }
    
    /**
     * 获取库存流水记录
     */
    async getStockRecords(params = {}) {
        const query = new URLSearchParams(params).toString();
        const response = await fetch(
            `${this.baseURL}/inventory/records?${query}`,
            { headers: this.headers }
        );
        return await response.json();
    }
}

// 使用示例
/*
const inventoryAPI = new InventoryAPI();

// 采购入库
try {
    const result = await inventoryAPI.purchaseInbound({
        inboundId: 1001,
        warehouseId: 1,
        items: [
            { productId: 101, quantity: 100, unitCost: 25.5 },
            { productId: 102, quantity: 50, unitCost: 18.3 }
        ]
    });
    console.log('入库成功:', result);
} catch (error) {
    console.error('入库失败:', error);
}
*/
🚨 避坑指南 (给开发团队)
⚡ 严禁写触发器
库存更新必须在代码层 Service 中调用,严禁使用 Database Trigger。方便排错和逻辑扩展。
🔗 事务边界
入库+库存+流水 必须在 小事务 内。生成凭证不要放在入库事务里,业务与财务要拆分。
🔓 不要做并发锁
MVP 阶段假设操作员只有一个。如需锁,用 Redis,不要用 MySQL SELECT FOR UPDATE,易死锁。
🎯 拒绝完美主义
工程师要有"洁癖"管控。现在不需要的字段,一行代码都不要多写,防止 Phase 2 永远不来。