ERP系统管的是钱、库存和审批,一个小bug的代价往往高得离谱。库存数差了几个单位,或者发票里出现一个四舍五入的误差,排查起来可能要花好几天。 市面上的成品ERP能覆盖大部分通用场景,但很多公司最终还是会遇到一个尴尬的流程——大平台处理起来别扭,只能自己写一个定制模块。 MERN技术栈在这类需求上表现不错:schema灵活,能跟着业务规则一起演进;前后端用同一门语言;MongoDB还支持真正的多文档事务。 下面以一个小的采购与库存模块为例,重点讲三件事:数据建模、一致性写入、审计追踪。 **这个模块要解决什么** 模块里包含供应商、产品、带行项目的采购订单(PO),以及每个仓库的库存水平。 最关键的操作是收货:系统必须同时更新PO行项目的已收数量、修改PO状态、增加库存。这三件事只要做了一半,账面上的数字就和现实对不上了。 **数据建模:该嵌的嵌,该引的引** ERP数据有一条简单好用的规则:跟着父对象一起生、一起死的东西,就嵌进去,比如PO的行项目。 有自己独立生命周期的,就用引用,比如供应商和产品。 行项目schema里,产品用ObjectId引用,但SKU和单价是直接快照进来的。这样即使产品后来改了,老订单里保留的仍是当时实际约定的内容。 金额一律用整数存。JavaScript的浮点数没法精确表示0.1,用“分”作单位能避开一整类四舍五入的坑。如果确实需要小数分,就用Decimal128。 采购订单schema上开了乐观并发控制。两个人同时编辑同一张PO时,后保存的那次会失败,而不是悄悄覆盖前一次的结果。 库存schema上加了一个唯一复合索引,保证每个产品在每个仓库只有一行记录,由数据库来兜底。 索引还要围绕人们真正会问的问题来设计。采购团队通常想看“某供应商的未结订单,最新的排前面”,那么供应商、状态、创建时间上的复合索引就能直接命中,不用全表扫描。 写schema之前,先翻一遍原型图里的报表和列表页,从第一天起就把对应的索引加上。 **事务:让相关的写入保持一致** 收货会同时动到两个集合。没有事务的话,两次写入之间一旦崩溃,就会留下幽灵库存。 MongoDB的事务需要副本集;本地开发用单节点副本集就够了。 最省事的API是session.withTransaction(),它会自动提交,并在遇到瞬时错误时重试。 收货函数里,先按ID查出PO,如果订单不存在,或者状态不是“已批准”“部分收货”,就直接抛错。 接着遍历每一条收货明细,找到对应的行项目。如果已收数量加上本次数量超过了订购数量,就抛出超收错误。 然后累加行项目的已收数量,同时对库存执行$inc增加,并用upsert保证记录存在。 最后判断所有行项目是否都已收满,据此把PO状态改成“已收货”或“部分收货”,再保存。 有三条规则能保证这段逻辑安全: 1. 每一次读和写都要把session传进去。 2. 发邮件、调支付这类副作用不能放在回调里,因为withTransaction可能执行不止一次。 3. 事务要尽量短,避免锁竞争。 对于必须发生的副作用,可以用outbox模式。在事务内部往outbox集合里插一条记录,比如类型是“po.received”、带上PO的ID。 提交之后,由后台worker把这些记录捞出来,去发邮件或者通知会计系统。如果事务回滚了,outbox记录也跟着消失,不会为一个从未发生的变更发出通知。 重试语义和写冲突在教程里很少出现,这也是为什么做金融相关模块的公司,往往想找那些在真实负载下踩过这些坑的MERN开发者。 仓库网络还经常不稳定,所以给每次收货配一个幂等键,在同一个事务里用唯一索引存下来,重复请求就会无害地失败。 **审计追踪:谁改了什么,什么时候改的** 审计人员会问“这张订单是谁批的”“价格为什么变了”。一个Mongoose插件配合AsyncLocalStorage,就能自动回答这些问题。
![]()
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
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.