简介:这是一套面向零售与供应链领域开发者、技术团队及企业信息化选型人员的高度可定制供应链中台基础系统,全部源码开放,可用于商用目的。系统覆盖零售管理、电子商务、供应链、财务、车队、仓库、人员、产品、订单、会员、连锁与加盟等业务模块,前端采用React与Ant Design,后端基于Java Spring及自有开源框架,通过REST接口实现前后端完全分离,并兼容MySQL、PostgreSQL及国产数据库南大通用GBase 8s,适合需要快速搭建或二次开发供应链平台的团队参考。资源包共6801个文件,约28.63MB,以Java后端源码、JavaScript与TypeScript前端脚本、Less与SCSS样式文件为主,另含JSP页面、Markdown说明文档、SQL脚本及少量图片与配置文件,目录结构完整,便于按模块检索与二次开发。目前已有436人学习下载,可作为供应链系统从架构设计到业务落地的完整参考。
1. 全流程供应链系统到底在管什么:从一张采购单到最终结算的链路拆解
很多团队做供应链系统,第一反应是“上个 ERP 就完了”,结果上线三个月发现采购、仓储、销售三套数据对不上,财务月底关账靠 Excel 手工拼。全流程供应链系统的核心不是某个模块,而是把“需求→采购→入库→出库→结算”这条链上的状态变更串成一条可追溯的事件流。标题里“高度可定制”意味着业务对象、审批流、单据字段都能改,而不是写死几张表;“可用于商用目的”则要求权限隔离、数据审计、并发扣减这些工程底线必须过关。这套东西适合两类人:一是中小型贸易或制造企业里被 Excel 和微信群折磨的 IT 负责人,二是想拿一套可二次开发的供应链底座做行业定制的开发者。源码在手,改的是业务规则,不是从零造轮子。
2. 先定领域模型再写代码:供应链系统的四张核心表怎么设计
2.1 为什么先画状态机而不是先建表
供应链系统最容易翻车的地方,是采购单状态和库存数量各写各的。我一般会先把单据状态机定死,再倒推表结构。以采购单为例,状态至少要有:草稿、待审批、已审批、部分到货、全部到货、已结算、已关闭。每个状态迁移都要有触发条件和操作角色,否则后面加审批流时你会发现代码里全是 if-else。
状态机定完后,表结构自然就出来了:主表存单据头,明细表存行项目,状态变更单独一张流水表。这样查历史、做审计、回滚都有依据。很多开源供应链项目把状态直接塞在主表的一个字段里,改一次状态覆盖一次,出了问题连“谁在什么时候改的”都查不到,这是血泪经验。
2.2 四张核心表的字段与关系
下面是我在多个模拟项目里反复用到的四张基础表,用 SQL 表达。注意这里只给结构,具体字段长度和索引按业务量调整。
-- 采购单主表:一张单一个头 CREATE TABLE purchase_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '单号,业务生成', supplier_id BIGINT NOT NULL COMMENT '供应商ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1待审 2已审 3部分到货 4全部到货 5已结算 6关闭', total_amount DECIMAL(14,2) NOT NULL DEFAULT 0, created_by BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_supplier_status (supplier_id, status) ) COMMENT '采购单主表'; -- 采购单明细:一行一个SKU CREATE TABLE purchase_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, qty DECIMAL(14,3) NOT NULL COMMENT '采购数量', received_qty DECIMAL(14,3) NOT NULL DEFAULT 0 COMMENT '已到货数量', price DECIMAL(14,4) NOT NULL COMMENT '单价', INDEX idx_order (order_id) ) COMMENT '采购单明细'; -- 库存流水:只追加不修改 CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, change_qty DECIMAL(14,3) NOT NULL COMMENT '正数入库负数出库', biz_type TINYINT NOT NULL COMMENT '1采购入库 2销售出库 3调拨 4盘点', biz_no VARCHAR(32) NOT NULL COMMENT '关联单号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_sku_warehouse (sku_id, warehouse_id), INDEX idx_biz (biz_type, biz_no) ) COMMENT '库存流水'; -- 库存余额:由流水汇总,可定时重算 CREATE TABLE stock_balance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, qty DECIMAL(14,3) NOT NULL DEFAULT 0, UNIQUE KEY uk_sku_warehouse (sku_id, warehouse_id) ) COMMENT '库存余额';逻辑说明:采购单主表和明细表是一对多,状态只在主表上流转;库存流水表只追加,任何库存变动都写一条记录,余额表是流水表的物化视图。参数上,qty用 DECIMAL 而不是 INT,是因为很多行业存在拆零和称重场景;received_qty单独存,是为了支持部分到货,避免每次到货都去汇总明细。
2.3 可定制字段的落地方式
“高度可定制”不是让用户随便加列,而是预留扩展字段表。常见做法是给主表加一个ext_json字段存非结构化扩展,同时建一张field_define表描述哪些字段可配、类型是什么、是否必填。这样前端渲染表单时读配置,后端校验也读配置,不用改代码就能加一个“项目编号”字段。
CREATE TABLE field_define ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT '业务类型如purchase_order', field_key VARCHAR(64) NOT NULL, field_label VARCHAR(64) NOT NULL, field_type VARCHAR(16) NOT NULL COMMENT 'text/number/date/select', required TINYINT NOT NULL DEFAULT 0, options VARCHAR(512) DEFAULT NULL COMMENT 'select类型的选项JSON', UNIQUE KEY uk_biz_field (biz_type, field_key) ) COMMENT '可定制字段定义';参数说明:biz_type用来区分是采购单还是销售单,field_key是程序里用的键,field_label是界面显示名。options只在 select 类型时用,存 JSON 数组。这样一套配置可以同时服务多个单据类型,改字段不用动表结构。
3. 从采购到入库的完整链路:用代码把状态流转跑通
3.1 采购单创建与审批的接口实现
先看创建采购单的接口。这里用 Python 伪代码表达,重点在事务边界和状态校验。
def create_purchase_order(supplier_id, items, operator_id): # 1. 校验供应商是否存在且启用 supplier = supplier_repo.get(supplier_id) if not supplier or supplier.status != 1: raise BizError("供应商不可用") # 2. 校验明细非空且数量为正 if not items: raise BizError("采购明细不能为空") for it in items: if it["qty"] <= 0 or it["price"] < 0: raise BizError("数量或价格非法") # 3. 生成单号:日期+序列,序列用Redis原子自增 order_no = "PO" + today() + str(redis.incr("po_seq")) # 4. 事务内写主表和明细 with db.transaction(): order_id = purchase_order_repo.insert({ "order_no": order_no, "supplier_id": supplier_id, "status": 0, # 草稿 "total_amount": sum(i["qty"] * i["price"] for i in items), "created_by": operator_id }) for it in items: purchase_order_item_repo.insert({ "order_id": order_id, "sku_id": it["sku_id"], "qty": it["qty"], "price": it["price"] }) return order_id逻辑说明:单号生成用 Redis 自增而不是数据库自增,是为了避免高并发下锁表;事务只包住写库操作,校验放在事务外减少锁持有时间。参数上,status初始为 0 表示草稿,草稿状态可以随意改,提交审批后才进入 1。
审批接口的核心是状态机校验:
# 允许的状态迁移:当前状态 -> 可迁移到的状态集合 TRANSITIONS = { 0: [1], # 草稿 -> 待审批 1: [2, 0], # 待审批 -> 已审批 或 退回草稿 2: [3, 4, 6], # 已审批 -> 部分到货/全部到货/关闭 3: [4, 6], # 部分到货 -> 全部到货/关闭 4: [5], # 全部到货 -> 已结算 } def approve_order(order_id, action, operator_id): order = purchase_order_repo.get(order_id) if action == "approve" and order.status == 1: new_status = 2 elif action == "reject" and order.status == 1: new_status = 0 else: raise BizError("当前状态不允许该操作") if new_status not in TRANSITIONS.get(order.status, []): raise BizError("非法状态迁移") with db.transaction(): purchase_order_repo.update_status(order_id, new_status) order_log_repo.insert(order_id, order.status, new_status, operator_id)参数说明:TRANSITIONS字典是状态机的核心,任何新增状态都要在这里注册,否则接口直接拒绝。order_log_repo记录每次变更,方便审计。注意退回草稿时要把已审批的痕迹保留,不能删日志。
3.2 入库扣减库存的并发处理
入库是整个链路里最容易出并发问题的地方。常见做法是“先写流水,再更新余额”,并且余额更新用条件更新防止超卖。
def receive_goods(order_id, items, warehouse_id, operator_id): order = purchase_order_repo.get(order_id) if order.status not in (2, 3): raise BizError("当前状态不可入库") with db.transaction(): for it in items: # 1. 写库存流水 stock_flow_repo.insert({ "sku_id": it["sku_id"], "warehouse_id": warehouse_id, "change_qty": it["qty"], "biz_type": 1, "biz_no": order.order_no }) # 2. 更新余额,用 INSERT ... ON DUPLICATE KEY UPDATE stock_balance_repo.upsert_add( it["sku_id"], warehouse_id, it["qty"] ) # 3. 更新采购明细已到货数量 purchase_order_item_repo.add_received( order_id, it["sku_id"], it["qty"] ) # 4. 判断是否全部到货,更新主表状态 all_received = purchase_order_item_repo.check_all_received(order_id) new_status = 4 if all_received else 3 purchase_order_repo.update_status(order_id, new_status)逻辑说明:upsert_add对应 SQL 的INSERT INTO stock_balance ... ON DUPLICATE KEY UPDATE qty = qty + VALUES(qty),这样即使两个请求同时到,数据库行锁也能保证不丢更新。参数上,biz_type=1表示采购入库,biz_no存采购单号,方便反查。注意这里没有用“先查再改”的写法,那种写法在并发下必然翻车。
3.3 结算与对账的数据一致性校验
结算前必须做一次对账,核对采购单、入库流水、发票三方的数量金额。我一般会写一个校验任务,按供应商和月份跑。
-- 核对采购单已到货数量与入库流水汇总是否一致 SELECT poi.order_id, poi.sku_id, poi.received_qty AS order_received, COALESCE(SUM(sf.change_qty), 0) AS flow_received FROM purchase_order_item poi LEFT JOIN stock_flow sf ON sf.biz_no = (SELECT order_no FROM purchase_order WHERE id = poi.order_id) AND sf.sku_id = poi.sku_id AND sf.biz_type = 1 GROUP BY poi.order_id, poi.sku_id, poi.received_qty HAVING order_received <> flow_received;参数说明:biz_type=1限定采购入库,避免把销售出库的负数流水算进来。HAVING过滤出不一致的行,这些行就是需要人工介入的差异。这个查询建议每天跑一次,差异超过阈值就告警,不要等到月底关账才发现。
4. 避坑与排查:供应链系统上线后最容易踩的五个坑
4.1 库存扣减出现负数
现象:销售出库时库存扣成了负数,但系统没有拦截。原因:扣减逻辑用的是“先查余额再更新”,两个请求同时查到相同余额,各自扣减后覆盖。解决:改成条件更新,UPDATE stock_balance SET qty = qty - ? WHERE sku_id = ? AND warehouse_id = ? AND qty >= ?,根据影响行数判断是否成功,失败就抛异常回滚。
4.2 单据状态跳变导致流程卡死
现象:采购单从“已审批”直接变成“已结算”,跳过了到货环节。原因:状态更新接口没有校验迁移合法性,前端传什么状态就写什么状态。解决:所有状态变更必须走状态机校验,TRANSITIONS字典里没注册的迁移一律拒绝,并且记录操作日志。
4.3 可定制字段导致查询变慢
现象:加了十几个扩展字段后,列表查询从 200ms 涨到 3 秒。原因:扩展字段存在ext_json里,查询时用JSON_EXTRACT过滤,无法走索引。解决:高频查询的扩展字段单独建列并加索引,低频的才放 JSON;或者建一张扩展字段索引表,用触发器或应用层同步。
4.4 审批流改动后历史单据无法查看
现象:改了审批流配置,老单据打开报错。原因:审批流版本没有和单据绑定,新流程去解析老数据。解决:每张单据创建时记录flow_version,查看时按版本加载对应的流程定义,流程定义表只追加不修改。
4.5 并发入库时采购单状态更新丢失
现象:两批货同时到,采购单状态只更新了一次,另一批的到货数量没算进去。原因:状态判断和更新不在同一个事务里,或者用了乐观锁但没重试。解决:把“更新明细到货数量”和“判断是否全部到货”放在同一个事务,并且对主表加行锁(SELECT ... FOR UPDATE),确保串行化。
5. 让系统真正可商用的三个进阶技巧
5.1 用事件表替代直接调用,解耦模块
商用系统里,采购入库后可能要触发通知、生成应付、更新报表。如果全写在入库接口里,改一个需求就要动核心代码。我一般会加一张domain_event表,入库成功后写一条事件,其他模块订阅处理。
CREATE TABLE domain_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_type VARCHAR(64) NOT NULL COMMENT '如purchase_received', biz_no VARCHAR(32) NOT NULL, payload JSON NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待处理 1已处理 2失败', retry_count INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_status_type (status, event_type) ) COMMENT '领域事件表';参数说明:status控制消费进度,retry_count限制重试次数,超过阈值转人工。这样入库接口只写事件,不关心谁消费,新增报表模块时不用改入库代码。
5.2 用快照表解决历史数据查询
单据关联的商品名称、供应商名称会变,如果直接 join 当前表,历史单据显示的是最新名称。商用系统必须存快照。做法是在明细表里冗余sku_name、supplier_name等字段,创建时写入,之后不更新。这样查历史单据时不需要 join,也避免了名称变更导致的对账歧义。
5.3 权限控制到字段级别
“高度可定制”往往意味着不同角色看到的字段不同。比如采购员看不到成本价,财务看不到库存明细。实现方式是在角色权限表里加field_scope,接口返回数据前按角色过滤字段。不要在前端隐藏,前端隐藏只是障眼法,后端必须过滤。
def filter_fields(data, role): allowed = role_field_repo.get_allowed_fields(role, data["biz_type"]) if not allowed: return data return {k: v for k, v in data.items() if k in allowed}参数说明:allowed为空表示不限制,否则只返回允许的字段。这个过滤要放在序列化之前,确保敏感字段根本不进入响应体。
这套系统我前后改过几版,最大的教训是:不要一开始就追求大而全,先把采购入库这条链路跑通,状态机和库存流水写对,后面加销售、加调拨都是复制模式。源码在手的好处是你可以按自己的业务节奏改,但前提是核心表结构和状态机不能乱。希望帮到你。
本文还有配套的精品资源,点击获取