极简主义 MVP 架构落地实战路线图
本项目旨在快速交付一个可用的、业财关联的进销存 SaaS 系统。我们将摒弃复杂的批次管理、自动财务凭证等过度设计,聚焦于库存核心引擎、业务闭环和基础的业财关联,确保产品能在最短时间内验证市场价值。
技术栈确定为 IIS (前端静态托管) + Node.js (后端API) + MySQL
目标:建立多租户环境,实现最底层的库存变动逻辑。
目标:打通销售和采购全流程,自动驱动库存变动。
目标:实现"记账"功能,提供"半自动"工具而非自动生成。
目标:系统稳定,部署到生产环境。
| 里程碑 | 验收标准 |
|---|---|
| M1: 库存引擎跑通 | 录入采购入库单,查询库存表数量增加,且 avg_cost 自动更新正确。 |
| M2: 销售闭环 | 录入销售出库单,库存扣减;录入收款单,客户应收余额计算正确。 |
| M3: 业财可查 | 查看凭证可关联出库单;查看出库单可看到对应凭证号。 |
| M4: MVP 发布 | 至少有 3 个种子用户在内网/公网环境使用,且无致命 Bug。 |
没问题,推倒重来最干净。
这是一份**完整的、可直接执行**的 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);
}
*/
SELECT FOR UPDATE,易死锁。