架构总览:三层架构、C++ 引擎与那套层级化主数据

上一章我讲了为什么选 frePPLe。这一章我们先不急着进求解算法,而是把两件地基性的事看明白:一是它这个"C++ 内核 + Django 外壳 + PostgreSQL"的混血架构到底是怎么拼起来的,进程之间怎么分工;二是它的主数据模型——物料、地点、客户、供应商——为什么全都设计成了可以层层聚合的"树"。看懂这两点,后面每一章你都能在脑子里找到它挂在哪根梁上。
三层架构:界面、Web 进程、数据库
frePPLe 的官方架构文档把它定义成一个典型的三层 Web 应用,但每一层都有值得说的细节。
第一层是浏览器前端。 现在的主线前端用 Vue 构建(仓库根目录有 vue.config.js、pnpm-workspace.yaml 和一堆 extract-vue-translations 脚本),完全跑在浏览器里,靠 AJAX 和后端交互,老版本那套 AngularJS/jQuery 已经在往 Vue 迁移。你在界面里编辑物料、点"生成计划"、看甘特图,背后都是 REST 调用。
第二层是 Web 服务层。 主体是 Django + Django REST Framework,既负责吐 HTML 页面,也通过 DRF 提供 REST API。这里有个非常关键的设计决策,架构文档里专门强调:像跑计划、备份数据库、复制场景这种长任务,绝不放在 Web 服务进程里执行,而是由 Web 进程派生一个独立的 worker 子进程去跑。 这个选择我非常认同——计划引擎一旦开算,可能要吃满 CPU 跑几十秒甚至几分钟,要是塞在处理 HTTP 请求的进程里,一个用户点"生成计划"就能把整个网站拖垮。拆成独立 worker,Web 层只管收发,重活在后台跑完再把状态写回库,界面轮询进度,这和我们做 Java 系统时用线程池或消息队列隔离慢任务是一个道理。
第三层是 PostgreSQL。 所有计划数据都存在关系库里,官方对这个 schema 的评价是"简单且稳定"(simple and stable),并且把"外部应用可以直接在数据库层面集成"当成一种正式支持的用法。这点我们到第 10 章还会展开——你甚至可以不碰它的界面,只让自己的 ERP 定时往这几张表里写主数据、跑完引擎后再从结果表里读计划。
C++ 引擎和 Django 到底是什么关系
这是新人最容易糊涂的地方,我先把它讲透。你会看到仓库里既有 57 个 C++ 文件(src/、include/),又有 367 个 Python 文件(freppledb/),它们不是两套各存各数据的系统,而是共用同一套数据库表的两种语言。
以"物料"这张表为例。Django 侧在 freppledb/input/models/item.py 里明明白白定义了 ORM 模型,class Meta 里写着 db_table = "item";而 C++ 引擎在 src/model/item.cpp 里也有一份对应的 Item 实体。两者映射的是 PostgreSQL 里同一张 item 表。Django 通过 Python 的原生扩展把那个 C++ 编译出来的计划引擎加载进来——命令行入口 frepplectl.py 触发"生成计划"时,真正干活的是 C++ 引擎,它直接用 SQL 读写这批表,算完把 operationplan(计划工单)之类的结果写回去;Django 再用 ORM 把结果展示出来。
所以可以这样理解三者的分工:
| 层 | 技术 | 负责什么 | 不负责什么 |
|---|---|---|---|
| 前端 | Vue + AJAX | 数据编辑、计划结果可视化 | 不做任何计算 |
| Web/API | Django + DRF | CRUD、权限、REST、派生 worker | 不在请求里跑计划 |
| 计划引擎 | C++23 原生库 | 预测、库存、排程等重计算 | 不管 HTTP、不管界面 |
| 存储 | PostgreSQL | 主数据与计划结果的唯一事实源 | 不含业务逻辑 |
这种"热计算下沉到原生层、事务和界面留在高级语言层"的分层,恰恰是我一个 Java 工程师读它最该学的东西。它没有为了语言统一而用 Python 硬扛大规模排程——那种运算量用解释型语言跑会慢到不可接受;也没有用 C++ 去写 CRUD 和权限这种典型的业务胶水。各取所长。
两个抽象基类:理解主数据的钥匙
现在进数据模型。frePPLe 的主数据实体——Item、Location、Customer、Supplier——几乎都继承自 freppledb/common/models.py 里同一对抽象基类:AuditModel 和 HierarchyModel。把这两个基类读懂,四张主数据表你就懂了一半。
先看审计基类 AuditModel,它很薄但每个字段都有明确的工程意图:
class AuditModel(models.Model):
# source:标识这条记录是哪个外部系统提供的
source = models.CharField(_("source"), db_index=True, null=True, blank=True)
lastmodified = models.DateTimeField(
_("last modified"), editable=False, db_index=True, default=timezone.now
)
objects = MultiDBManager() # 默认 manager 走多数据库
lastmodified 好理解,每次 save() 都会刷新成当前时间,用来做增量同步。source 这个字段我要单独点个赞:它记录这条主数据是从哪个上游系统来的——可能是 ERP、可能是 Odoo 连接器、可能是手工录入。在一个计划系统要和多个业务系统对接的现实里,这种"自带数据血缘"的设计能省掉大量对账麻烦。而 MultiDBManager 则是为它的场景功能服务的,模型可以在多个数据库(多个 scenario)之间路由,这点本章末尾再说。
再看更有意思的 HierarchyModel,它把"树"这件事做进了基类:
class HierarchyModel(models.Model):
lft = models.PositiveIntegerField(db_index=True, editable=False, null=True)
rght = models.PositiveIntegerField(null=True, editable=False, blank=True)
lvl = models.PositiveIntegerField(null=True, editable=False, blank=True)
name = models.CharField(_("name"), primary_key=True, help_text=_("Unique identifier"))
owner = models.ForeignKey(
"self", null=True, blank=True, related_name="xchildren",
help_text=_("Hierarchical parent"), on_delete=models.SET_NULL,
)
这里有两个设计决策值得拆开讲。
第一,主键用字符串业务键 name,而不是自增 id。 Item、Location、Customer、Supplier 全都是拿物料编码、仓库编码这种人能看懂的业务编号当主键。好处在集成场景里特别明显:你从 ERP 导数据、用 CSV 灌数据、在 SQL 里直接查,看到的主键就是业务编码,外键引用天然可读,跨系统也不会因为两边自增 id 错位而对不上。代价是改名很贵(改主键要级联一堆外键),所以实践里编码一旦定了就别改。这是一个明确偏向"集成与可读性"而非"纯技术规范"的取舍。
第二,用 owner 自引用加 lft/rght/lvl 三个冗余数字实现嵌套集模型(nested set)。 光有 owner 父指针其实就能表达一棵树,但"查某节点的所有后代""按层级汇总"这类查询用递归父指针会很慢。于是它额外维护了左值、右值、层级三个字段:父节点的 [lft, rght] 区间包住所有子孙,判断后代只需要一句 lft BETWEEN ... AND rght,做层级聚合和层级预测时飞快。代价是树一变这三个值就得重算——你看它的 save() 里干脆把 lft/rght/lvl 全置空,标记为"待重建",再由 rebuildHierarchy() 统一重算。
嵌套集重建:连"脏数据成环"都考虑到了
读 rebuildHierarchy() 的实现时我有点惊喜,因为它没假设数据永远干净。正常逻辑是把所有节点读进内存,从没有父节点的根开始递归 tagChildren,边遍历边给每个节点分配左右值。但现实中如果有人导入了一条"自己的父节点是自己"或者"A 的爹是 B、B 的爹又是 A"的脏数据,递归就会无限循环。
它的处理是两段式的:先在读取时直接拦掉自引用并打日志("Data error: '%s' points to itself as owner");重建后如果发现还有节点没被访问到,说明存在成环,它会迭代地把"没有别的坏节点指向自己"的节点逐个摘出,剩下摘不掉的就是环上的节点,打一条 "Hierarchy loops among ..." 错误日志,然后把这些环断开、让它们按顶层节点继续,而不是让整个重建崩掉。源码里甚至留着很诚实的 TODO:HierarchyModel.save() 现在"在太多不需要重算的场景也触发了重算",作者自己也知道这是个待优化点。这种"知道自己哪里不完美还写在注释里"的代码,比假装完美的代码可信得多。
四张主数据表长什么样
有了两个基类,再看具体实体就很轻松了,我把关键字段和它们的"性格"列一下:
| 实体 | 表名 | 关键字段 | 这张表回答什么问题 |
|---|---|---|---|
| Item 物料 | `item` | cost(Decimal 20,8)、weight、volume、type、uom、periodofcover | 要计划的东西是什么,值多少钱、多重多大 |
| Location 地点 | `location` | available(关联一个可用性日历) | 货在哪个厂/仓,这个地点什么时候可用 |
| Customer 客户 | `customer` | category/subcategory、层级 owner | 卖给谁,客户怎么按区域/层级聚合 |
| Supplier 供应商 | `supplier` | category、层级 owner | 向谁买,供应商怎么分组 |
Item 我单独说两个细节。其一,cost、weight、volume 全用的是 DecimalField(max_digits=20, decimal_places=8)——20 位总长、8 位小数。这是做计划系统的人才能体会的严谨:金额和数量绝不能用浮点,8 位小数是为了照顾单位换算后极小的用量,20 位总长又能扛住超大数。其二,type 只有两个取值:make to stock(备货生产)和 make to order(接单生产)。这个字段后面会直接影响计划的触发方式——MTS 靠预测和安全库存拉动,MTO 靠真实订单拉动。还有个 periodofcover(覆盖天数)字段被标成 editable=False,它不是人填的,是计划算完回填的结果。
Location 上那个 available 外键也值得注意:它指向一个日历(Calendar)。也就是说"这个地点/仓库是否可用"是可以随时间变化的——节假日、施工封闭都能表达。这种"几乎一切约束都日历化"的思路,是专业计划软件和业余排程脚本的分水岭,下一章讲资源和工序时你会反复看到它。
供应商侧还有一张关联表 ItemSupplier(表名 itemsupplier),表达"某个物料可以向哪个供应商买",上面挂着采购批量规则 sizeminimum/sizemultiple/sizemaximum、采购 cost、供应商 priority 和 resource_qty。priority 这个字段埋下一条伏笔:同一个物料可以有多个供货来源,引擎会按优先级和成本选——这就是后面要讲的"替代"。
一个伏笔:多数据库即多场景
读这两个基类时你可能一直好奇那个 MultiDBManager 是干嘛的。答案在 Scenario 模型上:frePPLe 的 what-if 场景,本质上是同一套 schema 的多个独立 PostgreSQL 数据库。Scenario 表里每个场景有 free/in use/busy 三种状态,syncWithSettings() 会把 Django settings.DATABASES 里配置的每个库同步成一行场景记录,默认库就叫 "Production"。
连前面那个薄薄的 Parameter(键值配置表 common_parameter)都在为这个设计服务:当你修改某些全局参数时,它的 save() 会遍历所有状态为 "In use" 的场景库,用原生 SQL 把参数值 UPSERT 到每一个数据库里(insert ... on conflict(name) do update),保证多个场景的基础配置一致。这也是为什么所有模型都要用支持多库的 objects.using(database) 写法。
这个设计的妙处要到第 9 章讲场景模拟时你才能完全体会:想试一套激进的排程参数又怕搞乱正式计划?复制一个数据库成新场景,随便折腾,对比完再决定要不要带回 Production。用数据库隔离换来的是 100% 无副作用的沙箱,简单粗暴但极其可靠。
小结
这一章我们建立了整张地图:浏览器里的 Vue、Django 的 Web 与 worker 进程、C++23 的计划引擎、PostgreSQL 这个唯一事实源,四者共用一套表、各司其职。主数据则由 AuditModel(带来源和时间戳、支持多库)和 HierarchyModel(字符串主键 + 嵌套集树)两个抽象基类统一了风格,Item 上那个 MTS/MTO 开关和 Location 上的可用性日历,已经在为后面的计划逻辑埋点。
下一章,我们就在这套主数据之上,搭出真正决定"计划长什么样"的供给网络:库存点 Buffer、日历 Calendar、资源 Resource,以及五种工序 Operation 是怎么连物料流和产能负载、拼出一张完整 BOM 和工艺路线的。
