第四章:数据库设计与建模
好,咱们进入第四章。这一章可以说是整个ERP系统的地基。地基没打好,后面盖多少层楼都得塌。我见过太多项目,前期数据库设计随便搞搞,结果后期改得死去活来。嗯,咱们不犯这个错。
4.1 从业务到数据:ER图怎么画?
先问个问题:你拿到一个ERP需求,第一步做什么?
我个人习惯,不是写代码,而是拿张白纸,画框框和连线。这就是ER图——实体关系图。
说白了,ER图就是把现实世界的业务对象,抽象成数据模型。比如:
- 实体:用户、部门、产品、订单……这些是名词
- 属性:用户名、密码、邮箱……这些是实体的特征
- 关系:一个部门有多个用户,一个用户属于一个部门
我在项目中遇到过最典型的坑:把「关系」当成了「属性」。比如直接在用户表里加一个「部门名称」字段。这看起来省事,但部门改名时你就得改所有用户记录。嗯,这就是没画ER图的后果。
核心原则:先画关系,再定属性。关系对了,表结构就稳了。
4.2 核心数据表结构设计
咱们这个ERP系统,核心表就那么几张。我按模块一个一个说。
4.2.1 用户与权限体系
用户、部门、角色、权限,这四个是基础中的基础。我建议用RBAC模型(基于角色的访问控制)。
用户表(users)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT (PK) | 主键 |
| username | VARCHAR(50) | 用户名,唯一 |
| password_hash | VARCHAR(128) | 密码哈希值,别存明文 |
| VARCHAR(100) | 邮箱 | |
| department_id | INT (FK) | 所属部门 |
| is_active | BOOLEAN | 是否启用 |
| created_at | DATETIME | 创建时间 |
部门表(departments)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT (PK) | 主键 |
| name | VARCHAR(50) | 部门名称 |
| parent_id | INT (FK, nullable) | 上级部门,支持树形结构 |
角色表(roles)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT (PK) | 主键 |
| name | VARCHAR(50) | 角色名,如管理员、采购员 |
| description | TEXT | 角色描述 |
权限表(permissions)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT (PK) | 主键 |
| codename | VARCHAR(100) | 权限代码,如 'can_add_product' |
| name | VARCHAR(100) | 权限名称 |
你想想看,用户和角色是多对多,角色和权限也是多对多。所以需要两张中间表:user_roles 和 role_permissions。
我的经验:权限代码命名要规范。我习惯用「动词_模块_对象」的格式,比如 view_order、edit_product。这样后期维护时一眼就能看懂。
4.2.2 产品与库存
产品表是ERP的核心。库存表则记录每个产品的实时数量。
产品表(products)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT (PK) | 主键 |
| sku | VARCHAR(50) | 库存单位编码,唯一 |
| name | VARCHAR(200) | 产品名称 |
| category_id | INT (FK) | 分类 |
| unit_price | DECIMAL(10,2) | 单价 |
| cost_price | DECIMAL(10,2) | 成本价 |
| description | TEXT | 描述 |
库存表(inventory)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT (PK) | 主键 |
| product_id | INT (FK) | 关联产品 |
| warehouse_id | INT (FK) | 仓库 |
| quantity | INT | 当前库存数量 |
| last_updated | DATETIME | 最后更新时间 |
注意:千万不要直接用产品表里的字段存库存数量。库存是动态的,产品信息是静态的。混在一起,并发操作时数据会乱。我曾经见过一个项目,库存扣减时直接 UPDATE products SET stock = stock - 1,结果并发下单导致库存变负数。嗯,血的教训。
4.2.3 订单与订单明细
订单表记录一次交易,订单明细表记录这次交易买了哪些产品。
订单表(orders)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT (PK) | 主键 |
| order_number | VARCHAR(50) | 订单号,唯一 |
| user_id | INT (FK) | 下单用户 |
| status | VARCHAR(20) | 状态:待支付、已支付、已发货等 |
| total_amount | DECIMAL(12,2) | 总金额 |
| created_at | DATETIME | 下单时间 |
订单明细表(order_items)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT (PK) | 主键 |
| order_id | INT (FK) | 所属订单 |
| product_id | INT (FK) | 产品 |
| quantity | INT | 数量 |
| unit_price | DECIMAL(10,2) | 成交单价(可能和产品表不同) |
| subtotal | DECIMAL(12,2) | 小计金额 |
为什么会把单价存两份?因为产品价格会变。订单生成时的价格必须固定下来,不能跟着产品表变。这是设计上的一个细节。
4.3 Django ORM 模型定义
好,表结构设计完了,接下来用代码实现。我用Django ORM举例,Flask-SQLAlchemy思路一样,语法略有不同。
from django.db import models
from django.contrib.auth.models import AbstractUser
class Department(models.Model):
name = models.CharField(max_length=50)
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE)
def __str__(self):
return self.name
class User(AbstractUser):
department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True)
phone = models.CharField(max_length=20, blank=True)
class Role(models.Model):
name = models.CharField(max_length=50)
description = models.TextField(blank=True)
permissions = models.ManyToManyField('Permission')
class Permission(models.Model):
codename = models.CharField(max_length=100, unique=True)
name = models.CharField(max_length=100)
class Product(models.Model):
sku = models.CharField(max_length=50, unique=True)
name = models.CharField(max_length=200)
category = models.ForeignKey('Category', on_delete=models.SET_NULL, null=True)
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
cost_price = models.DecimalField(max_digits=10, decimal_places=2)
description = models.TextField(blank=True)
class Inventory(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
warehouse = models.ForeignKey('Warehouse', on_delete=models.CASCADE)
quantity = models.IntegerField(default=0)
last_updated = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('product', 'warehouse')
class Order(models.Model):
order_number = models.CharField(max_length=50, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.CharField(max_length=20, default='pending')
total_amount = models.DecimalField(max_digits=12, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='items')
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.IntegerField()
unit_price = models.DecimalField(max_digits=10, decimal_places=2)
subtotal = models.DecimalField(max_digits=12, decimal_places=2)
说个小技巧:Django的 auto_now_add=True 会在创建时自动填入当前时间,auto_now=True 会在每次保存时更新。别搞混了。我刚开始用Django时,把两个参数写反了,结果创建时间每次更新都被改掉。排查了半天才发现。
4.4 模型关系总结
咱们把关系理一理:
- 用户 ↔ 部门:多对一。一个部门有多个用户,一个用户属于一个部门。
- 用户 ↔ 角色:多对多。一个用户可以有多个角色,一个角色可以分配给多个用户。
- 角色 ↔ 权限:多对多。一个角色拥有多个权限,一个权限可以属于多个角色。
- 产品 ↔ 库存:一对多。一个产品在多个仓库有库存,一个库存记录对应一个产品。
- 订单 ↔ 订单明细:一对多。一个订单包含多个商品项。
这些关系,在ER图上就是连线。画清楚了,代码写起来就顺了。
最后提醒一句:数据库设计没有完美的,只有适合的。别追求过度范式化,适当冗余可以提高查询效率。但也不要为了省事乱加字段。平衡点在哪里?嗯,多踩几次坑就知道了。
下一章,咱们开始搭建开发环境,把Django项目跑起来。到时候这些模型就能真正用上了。