跳到正文
Joeplover
工程·2026-07-27·约 10 分钟阅读

Workface ERP:用 Django 把采购、销售与库存串成业务闭环

Workface ERP 是一个基于 Django 5.2 的多租户 ERP SaaS。本文从真实代码出发,拆解主数据、采购销售、库存流水、审批流、订阅计费和审计如何协同工作。

办公桌上的企业数据报表与笔记本电脑

Workface ERP:用 Django 把采购、销售与库存串成业务闭环

先问一个问题:ERP 到底在解决什么?

很多管理系统看起来只是一些列表、表单和按钮:客户列表、商品列表、采购单、销售单、库存表。但如果这些页面之间没有稳定的业务关系,系统只能算“数据录入工具”,不能真正支撑企业流程。

Workface ERP 的核心目标,是把企业最常见的一条链路连接起来:

主数据准备 → 创建采购或销售订单 → 审批 → 入库或出库 → 库存变化 → 报表与审计

项目基于 Django 5.2,采用前后端不分离的服务端渲染方式,首期覆盖多租户组织、主数据、采购、销售、库存、审批、订阅账单和审计日志。

一、为什么第一层必须是多租户隔离?

没有租户隔离会怎样?

如果所有企业共用同一套查询逻辑,一个用户只要猜到对象 ID,就可能读取另一个企业的客户、商品或库存。这不是普通的查询 Bug,而是数据边界失效。

项目怎么做?

项目用 TenantOwnedModel 作为大多数业务模型的基类:

class TenantOwnedModel(TimestampedModel):
tenant = models.ForeignKey(
"tenants.Tenant",
on_delete=models.PROTECT,
)
is_archived = models.BooleanField(default=False)

同时提供 TenantOwnedQuerySet.for_tenant(),让查询显式带上当前租户:

class TenantOwnedQuerySet(models.QuerySet):
def for_tenant(self, tenant):
return self.filter(tenant_id=tenant.pk)

这层设计的意义不只是少写一段 filter。它把“业务数据属于谁”变成模型和查询层共同遵守的约束。

项目还把组织拆成租户、法人、部门、仓库和成员。这样既能支持一个企业管理多个法人,也能让库存准确落到某个法人和仓库,而不是停留在一个模糊的“公司库存”概念上。

二、主数据为什么不能直接删?

商品和供应商被删除后,历史订单怎么办?

采购单和销售单需要长期保留。如果历史单据关联的商品被物理删除,报表就无法解释过去发生了什么。

因此项目的主数据模型使用软归档:客户、供应商、商品、SKU、单位和仓库都继承 TenantOwnedModel,通过 is_archived 表示是否仍可使用。

业务服务层在真正创建订单前还会再次校验:

def validate_sku(sku, tenant):
if sku.tenant_id != tenant.pk or sku.is_archived or sku.product.is_archived:
raise BusinessError("SKU 不可用", "invalid_sku")
return sku

这解决了一个容易被忽略的问题:前端表单提交时 SKU 可能还是有效的,但用户点击保存前它已经被归档。最终校验必须放在服务层,而不能只相信页面状态。

项目还支持单位换算和采购、销售价格表。商品是基础对象,SKU 才是订单和库存实际引用的对象,这个区分让同一个商品可以拥有不同规格和条码。

三、采购和销售为什么要使用“订单 + 单据”的两段式设计?

采购单批准后,库存应该立刻增加吗?

不应该。采购单表示“计划采购”,货物真正到达仓库后,才应该产生入库事实。

因此采购流程拆成两类对象:

  • PurchaseOrder:采购计划、供应商、仓库、金额和订单状态。
  • GoodsReceipt:实际收货记录,以及本次收了哪些 SKU、多少数量。

销售流程同理:

  • SalesOrder 表示客户订单。
  • Delivery 表示实际发货。

订单明细中保存计划数量和已执行数量:采购单是 quantity 与 received_quantity,销售单是 quantity 与 delivered_quantity。这样就能支持部分入库和部分出库,而不是把订单简单地分成“完成”和“未完成”。

订单状态如何推进?

以采购单为例,状态可以是:

草稿 → 待审批 → 已审批 → 执行中 → 已完成
↘ 已驳回
草稿 → 已取消

服务层只允许合法状态转换,例如只有草稿单可以编辑,已经发生入库的采购单不能取消。状态规则集中在服务函数中,页面只负责收集输入和展示结果。

四、库存为什么同时保存余额和不可变流水?

只保存当前库存数量够不够?

不够。余额只能回答“现在有多少”,不能回答“为什么变成这个数量”。

项目用两张核心表解决两个不同问题:

  • InventoryBalance:某租户、法人、仓库和 SKU 的当前余额。
  • InventoryTransaction:每一次入库、出库、调拨和调整的不可变流水。

库存余额适合列表和看板快速读取,库存流水适合追溯、对账和审计。每一笔流水还记录来源类型、来源 ID、幂等键和备注,所以可以从库存变化反查到收货单或发货单。

并发出库会不会把库存扣成负数?

库存服务使用数据库事务和行级锁:

@transaction.atomic
def issue_stock(...):
balance = _balance_for_update(...)
if balance.available_quantity < quantity:
raise BusinessError("可用库存不足", "insufficient_stock")
balance.quantity = balance.quantity - quantity
balance.save(...)

select_for_update() 会锁住当前库存余额。在一个请求读取并扣减库存时,另一个并发请求不能同时修改同一行。检查可用数量和扣减动作都在同一个事务中,避免“两个请求都看到充足库存,最后扣成负数”。

为什么还要幂等键?

网络重试可能让同一个收货请求发送两次。如果没有幂等键,业务数据会被重复入库。

项目在 InventoryTransaction 上对租户、操作类型和幂等键设置唯一约束。服务方法收到重复请求时,直接返回已经存在的流水,而不是再次改变库存。采购入库和销售出库还会把单据 ID 与明细 ID 组合进幂等键,保证每一条明细都只能生效一次。

调拨则被实现为一对库存动作:从原仓库出库,再向目标仓库入库,两个动作放在同一个事务中。中途任何一步失败,整个操作回滚,避免库存凭空消失。

五、审批流为什么要做成可配置引擎?

如果采购金额变化,每次都改代码吗?

不应该。不同租户的审批规则可能完全不同:小额采购自动通过,大额采购需要部门负责人和财务逐级审批。

项目把审批拆成四个模型:

  • WorkflowDefinition:某类单据的一套流程和版本。
  • WorkflowNode:流程中的审批节点、顺序和审批人类型。
  • ApprovalInstance:某一张具体单据启动的审批实例。
  • ApprovalTask:当前需要某个用户处理的待办任务。

节点支持指定用户、角色和部门负责人,也支持顺序审批、或签和会签。节点还可以配置条件字段、运算符和值,例如只对总金额大于某个阈值的订单创建审批任务。

审批引擎的处理过程可以概括为:

提交订单
↓
寻找当前租户最新的有效流程
↓
匹配节点条件并创建 ApprovalTask
↓
审批人同意或驳回
↓
创建下一个节点,或同步订单为已审批

审批动作也使用事务和 select_for_update() 锁定任务,防止同一个待办被重复处理。审批通过或驳回后,订单状态会同步更新,业务单据和审批实例不会各自停留在不同状态。

六、订阅和权限如何保护 SaaS 的商业边界?

多租户 SaaS 不只是把数据分开,还要决定每个租户能使用哪些功能、多少额度。

平台模块提供订阅套餐、功能开关、额度、价格和支付订单:

  • PlanFeature 控制功能是否开通。
  • PlanQuota 定义某个指标的上限。
  • UsageRecord 按租户和周期累计使用量。
  • Subscription 维护试用、生效、宽限、暂停和取消等状态。
  • PaymentOrder 和 BillingInvoice 记录支付与账单。

业务代码可以先检查功能:

require_feature(tenant, "inventory.export")

也可以检查本月额度:

require_quota(tenant, "inventory_export", requested=1)

支付回调不是“收到请求就改成已支付”。项目会锁定支付订单,检查订单号是否匹配,并用支付适配器验签。重复收到已经处理过的回调时直接返回,保证支付状态和订阅周期不会被重复延长。

当前仓库中的 FakeDomesticAdapter 只用于本地和测试环境。正式收款前必须替换成真实支付渠道,并补充正式密钥、回调重放防护和对账流程。

七、审计日志为什么不是普通的打印日志?

普通日志告诉开发者程序执行过什么;审计日志要回答业务问题:谁在什么时候,对哪个租户的哪个对象,执行了什么操作。

AuditLog 保存租户、操作者、动作、对象类型、对象 ID、对象描述、请求 ID 和结构化 metadata。采购创建、库存变更、审批动作等服务都会调用 record_audit()。

这样做有两个价值:

  1. 出现数据争议时,可以追溯业务动作,而不是只看一段无法关联对象的文本。
  2. 管理后台可以基于结构化字段查询和导出,而不必从字符串日志中猜测含义。

八、这个项目的技术结构是什么?

项目使用 Django 的模块化应用组织业务边界:

config/              项目配置、URL、Celery
apps/core/           公共模型、编号和基础异常
apps/tenants/        租户、法人、部门、仓库、成员
apps/identity/       角色、权限和数据范围
apps/masterdata/     客户、供应商、商品、SKU、单位
apps/procurement/    采购订单和收货
apps/sales/          销售订单和发货
apps/inventory/      库存余额、流水、调拨、盘点
apps/workflow/       审批定义、实例和任务
apps/platform/       套餐、额度、订阅和支付
apps/audit/          审计日志
apps/reports/        库存 CSV 报表

Web 层负责请求、表单和页面渲染;服务层负责事务、状态转换、租户校验和跨模块调用;模型层负责关系、约束和基础数据结构。这种分层让“按钮点击后发生什么”不会全部堆在视图函数里。

运行环境支持 PostgreSQL、Redis 和 Celery,Docker Compose 可以启动数据库、缓存、Web 和异步 Worker。项目也提供演示数据、完整性检查、健康检查和库存 CSV 导出。

九、如何验证系统没有只停留在页面层?

项目测试覆盖了业务主链路,而不是只测试首页能否返回 200:

  • 测试租户查询隔离和租户上下文。
  • 测试主数据软归档、单位换算和跨租户 SKU 拒绝。
  • 测试采购入库会增加库存,销售出库会消耗库存。
  • 测试库存幂等、库存不足、调拨和盘点调整。
  • 测试审批通过、驳回、顺序节点和订单状态同步。
  • 测试套餐功能、额度、支付回调验签和重复回调。
  • 测试角色权限、页面权限和报表导出。

常用验证命令如下:

python manage.py check
python -m pytest --reuse-db -q
python manage.py check_integrity

这些测试的共同点是:它们验证业务不变量。例如“库存不能无故变负”“不同租户不能互相读取 SKU”“重复回调不能重复延长订阅”,而不只是验证某个函数被调用过。

结语:ERP 的难点是把边界守住

ERP 的复杂度不在于页面数量,而在于每条业务边界都必须稳定:

  • 数据必须属于正确的租户。
  • 被归档的主数据不能继续参与新业务。
  • 订单计划和实际执行必须分开。
  • 库存变化必须可追溯、可并发控制、可重试。
  • 审批状态必须和业务单据同步。
  • 功能和额度必须受到订阅约束。
  • 关键动作必须留下结构化审计记录。

Workface ERP 的第一版没有追求覆盖所有企业场景,而是先把这些边界落实到模型、服务、事务、约束和测试中。对于一个 ERP SaaS,这些基础能力比堆叠更多菜单更重要。