做ERP的人都知道一个规律:钱、库存、审批这三样东西,只要沾上,小bug就会变得很贵。库存数差了几个单位,或者发票里出现一分钱的舍入误差,排查起来可能要花上几天。
市面上的成品ERP能覆盖大部分通用场景,但很多公司最后都会走到同一步——为某个大平台处理起来很别扭的流程,单独做一个自定义模块。
![]()
MERN栈在这件事上有它的优势:schema灵活,业务规则变了不用大动干戈;整条链路只用一种语言;MongoDB支持真正的多文档事务。这篇拆解围绕一个小的采购与库存模块展开,重点只有三件事:数据建模、一致性写入、审计追踪。
这个模块要解决什么
模块里有供应商、产品、带行项目的采购订单,以及每个仓库的库存水平。
最关键的操作是收货:系统必须同时更新采购订单行上的数量、改变订单状态、增加库存。这三件事只要做了一半,数字就和现实对不上了。
数据建模上有一条简单规则很好用:跟着父级一起生、一起死的东西,就内嵌进去,比如采购订单的行项目;有自己独立生命周期的,就用引用,比如供应商和产品。
行项目里有两个字段值得注意。SKU和单价被复制进每一行,作为下单时刻的快照——这样即使产品后来改了,老订单保留的仍然是当时实际谈定的内容。
金额用整数存。JavaScript的浮点数没法精确表示0.1,用“分”作单位可以避开一整类舍入bug。如果确实需要小数分,就用Decimal128。
另外两个开关也要打开:乐观并发控制,两个人同时编辑同一张采购订单时,后保存的那次会失败,而不是悄悄覆盖前一次;库存上加唯一复合索引,保证每个产品在每个仓库只有一行记录。
索引最好围绕人们真正会问的问题来设计。采购团队通常想看“某供应商的未结订单,最新的排前面”,那么供应商、状态、创建时间上的复合索引就能直接回答这个问题,不用全表扫描。写schema之前先看一遍原型图里的报表和列表页,从第一天就把索引加上。
事务:把相关的写入绑在一起
收货会碰到两个集合。没有事务的话,两次写入之间一旦崩溃,就会留下幽灵库存。
MongoDB的事务需要副本集,本地跑一个单节点副本集就够了。最省事的API是session.withTransaction(),它负责提交,也会在遇到瞬时错误时重试。
收货函数里,先按ID取出采购订单,检查状态是否允许收货;然后逐行校验数量,防止超收;接着更新库存,用$inc增加在库数量,并带上upsert;最后判断所有行是否都已收满,决定订单状态是“已收货”还是“部分收货”。
有三条规则保证这段逻辑安全:
- 每一次读和写都要把session传进去。
- 发邮件、调支付这类副作用不要放在回调里,因为withTransaction可能执行不止一次。
- 事务要短,避免锁竞争。
必须发生的副作用可以用发件箱模式处理。在事务内部往outbox集合插入一条记录,比如类型为po.received、带上订单ID。提交之后,后台worker再取走这些记录去发邮件或通知财务系统。如果事务回滚,这条记录也跟着消失,不会为一个从未发生的变更发出任何通知。
重试语义和写冲突在教程里很少出现,这也是为什么做金融相关模块的公司,往往倾向于找那些在真实负载下踩过这些坑的MERN开发者。仓库网络本身也不稳定,所以给每次收货配一个幂等键,在同一个事务里用唯一索引存下来,重复请求就会无害地失败。
审计追踪:谁改了什么,什么时候改的
审计人员会问“这张订单是谁批的”“价格为什么变了”。一个Mongoose插件配合AsyncLocalStorage,可以自动回答这些问题。
AsyncLocalStorage来自node:async_hooks,它能在异步调用链中保存上下文,插件借此拿到当前操作人,把变更记录写下来,而不需要在每个业务函数里手动传递用户信息。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.