简介:这是一份基于SaaS模式的进销存多商户系统前端资源,适用于中小商户及软件开发人员,用于解决多仓库、多门店、商品多规格与多单位场景下的采购管理、销售管理、仓库管理及简单财务核算问题。包内共653个文件,以313个JS逻辑文件、244个Vue组件文件为核心,辅以SVG图标、SCSS样式、TTF字体及配置文件等,资源包仅1.76MB,整体结构紧凑,便于按模块查阅。已有438人学习下载。系统源码覆盖采购、销售、仓库、报表查询、系统管理等业务模块,包含库存状况、出入库统计等报表页面,并细致实现了角色与权限控制。通过阅读这套代码,可快速理解SaaS多商户进销存系统的前端架构、组件划分与权限交互逻辑,适合作为相关项目开发或课程设计的参考。
1. 多商户进销存系统到底解决谁的什么问题
做进销存系统最容易被一句话带偏:很多团队接到需求就照着单门店的进销存开做,等上线才发现,客户要的是“总公司下挂十几个门店,各自进货、各自销货,老板要能看见所有店的数据”。这时候回炉重造,成本翻一倍都不止。这款基于SaaS模式的多商户进销存系统,核心就是一套代码服务几十上百个商户,商户之间数据隔离,商户内部又能分出多门店、多仓库、多用户,再附带一点够用的简单财务。适合两类人:一类是做中小企业软件服务的开发团队,想找一条按年订阅收费的标准化产品路线;另一类是自己有连锁门店或档口生意,想选型系统的管理者。下面我把架构、建模和踩坑按落地顺序拆开讲。
2. 把SaaS地基打好:多租户架构选型与数据隔离方案
2.1 三种数据隔离方案怎么选
SaaS多商户系统第一件事不是写进销存,而是决定租户数据怎么隔离。常见做法有三个:独立数据库、独立Schema、共享表加租户字段。
独立数据库隔离最彻底,备份和恢复都简单,但一台服务器撑不住几百个库,运维成本也高。独立Schema介于两者之间,一个实例里建几百个Schema,照样会拖垮性能。共享表加租户字段,是目前进销存软件最常用的做法。进销存的表量不大,但每个商户的单据量却可能很大,用tenant_id做分区键,配合索引和分表策略,能同时控制成本和扩展性。
我的选型建议是:起步阶段直接选共享表加tenant_id,所有核心表都把tenant_id作为第一索引列。理由很现实——多商户系统的很多坑是业务上的串数据,技术上只要租户字段不漏加,后面迁移到独立Schema或独立库都有路可走。反过来,一开始拆库拆Schema,等业务模型稳定了再合并,几乎等于重写。
2.2 共享表加租户字段:建表与查询的样板
先给一个最常见的商品表结构,把多商户的关键字段落进去:
CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL COMMENT '商户ID,数据隔离第一键', store_id BIGINT NOT NULL COMMENT '门店ID,支持商户内部多门店', sku_code VARCHAR(64) NOT NULL COMMENT 'SKU编码,同一商户内唯一', name VARCHAR(128) NOT NULL, spec VARCHAR(64) DEFAULT '' COMMENT '规格', unit VARCHAR(16) NOT NULL COMMENT '单位', sale_price DECIMAL(12,2) NOT NULL DEFAULT 0.00, cost_price DECIMAL(12,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_tenant_sku (tenant_id, sku_code), KEY idx_tenant_store (tenant_id, store_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';这段建表SQL有几个关键点要说明。唯一约束必须挂在tenant_id + sku_code上,sku_code本身不设唯一,否则一个商户用了“SKU001”,另一个商户就不能再用了,这就是典型的多商户全局冲突。所有查询的WHERE子句必须带上tenant_id,不是只带store_id。因为store_id也是全局生成的序列,不带tenant_id的查询一旦路由错,就会把别的商户的门店数据查出来。索引顺序上,tenant_id放在最左边,让数据库先按商户裁剪数据,再按门店过滤,这个顺序直接决定多商户场景的查询性能。
2.3 商户、门店、仓库三层维度怎么摆
多商户进销存里,商户和门店的关系是上下级,但很多现成框架只做了“用户-角色-权限”,没有“商户-门店”这个业务维度。我一般会单独建tenant表,再建store表,store挂在tenant下面。用户再挂在store层级或tenant层级。这样设计的理由是进销存的单据、库存、报表全部挂在store上;商品、客户、供应商部分挂在tenant层共享、部分按store隔离,比如售价可以按门店覆盖,成本价只能商户层看。
写SQL时,凡是查库存、查销售汇总,都要带着tenant_id + store_id两个条件。这里有个容易忽略的边界:导出报表时“按商户汇总”和“按门店汇总”实际是两个不同的权限视图。商户管理员能看到所有门店合计,门店店长只能看自己的门店。这套字段设计如果一开始没预留store_id,后面加权限维度就要改一堆表结构,血泪经验告诉我,这个坑在项目第三天就会踩到。
2.4 主键不能自增:多商户下的ID生成与租户初始化
多商户系统的主键不能用单表自增,因为跨商户的数据合并、分表、数据导出时,自增ID会发生冲突。常见做法是用雪花算法生成全局ID,它全局唯一、趋势递增,能直接当业务主键用。如果团队不想引入额外组件,也可以用“租户前缀 + 毫秒时间戳 + 自增序号”拼业务主键,但这种方式在并发量高时容易乱序,最终还是要落到分布式ID方案上。
租户初始化是一个容易被忽略的环节。新商户开通时,不光要插入一条tenant记录,还要在若干共享表里写入默认数据:默认仓库、默认门店、默认角色、默认管理员用户、默认结算方式、常用单位字典。这个初始化动作要设计成幂等,避免重复调用时插入两套默认数据。我习惯把它做成一个init_tenant的程序化脚本,每次商户开通都执行同一套脚本,跑完先自检再开放给商户使用。
3. 进销存核心建模:SKU、库存流水和多仓库调拨
3.1 用SKU和批次把商品维度立住
进销存的商品维度,说起来就是“商品是什么,怎么标识”。单门店系统可以用商品ID,多商户系统一展开就有几个问题:不同商户的商品编码可能相同,同一种商品在不同门店可能有不同售价,同一批次进货价格还不同。我建议第一步是引入SKU概念,它是“商户+商品+规格+单位”的组合,而不仅仅是商品名称。
CREATE TABLE stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, store_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, batch_no VARCHAR(64) DEFAULT '' COMMENT '批次号,空串表示不管理批次', quantity DECIMAL(16,4) NOT NULL DEFAULT 0 COMMENT '当前库存量', avg_cost DECIMAL(12,4) NOT NULL DEFAULT 0 COMMENT '移动加权平均成本', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_tenant_store_sku_batch (tenant_id, store_id, sku_id, batch_no), KEY idx_tenant_sku (tenant_id, sku_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';库存不是按“商品”存,而是按“门店+SKU+批次”存。加入batch_no字段,是为了处理食品、建材这类有保质期或不同进价的商品。不管理批次的商户,batch_no默认填空字符串,不影响查询。quantity用DECIMAL而不是FLOAT,因为涉及数量和金额加减,浮点会积累误差,这属于基础但很要命的细节。
SKU编码生成策略上,常见做法是让商户自定义前缀,系统自动按序号补位。前端输入“商品名称+规格”,后台把商户ID、规格、单位拼接成SKU编码。真正需要留意的边界是:一个SKU如果被历史单据引用过,就不能再允许编辑单位,否则库存数量和单据数量会失去对应关系。
3.2 单据驱动库存:采购、销售、退货的流水设计
很多新手做进销存,直接把库存字段写进商品表,每次采购就UPDATE商品表的库存数量。这种设计在单门店小数据量下能跑,一旦有并发、有退货、有多门店调拨,就会出现库存被覆盖、对不上账的问题。正规做法是“库存只由流水驱动”:所有业务动作都生成一张单据,单据审核后写入库存流水,再汇总流水得到当前库存。
CREATE TABLE stock_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id BIGINT NOT NULL, store_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, batch_no VARCHAR(64) DEFAULT '', flow_type TINYINT NOT NULL COMMENT '10采购入库 20销售出库 30采购退货 40销售退货 50调拨出 60调拨入 70盘点调整', quantity DECIMAL(16,4) NOT NULL COMMENT '正数为入库,负数为出库', before_quantity DECIMAL(16,4) NOT NULL COMMENT '流水发生前库存', after_quantity DECIMAL(16,4) NOT NULL COMMENT '流水发生后库存', ref_bill_type VARCHAR(32) NOT NULL COMMENT '来源单据类型', ref_bill_id BIGINT NOT NULL COMMENT '来源单据ID', remark VARCHAR(255) DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tenant_store_sku (tenant_id, store_id, sku_id), KEY idx_tenant_ref (tenant_id, ref_bill_type, ref_bill_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存流水表';写入流水的时机,必须放在单据审核时而不是单据保存时。保存只是草稿,审核才真正影响库存。每次写入流水,都带着before_quantity和after_quantity,这样盘点对账时可以直接看到哪个批次哪个动作把库存改掉了。库存汇总口径是SUM(l.quantity),而不是读商品表里的冗余库存字段。这个设计在出问题的时候能直接定位,是进销存的底线。
生成流水和更新库存,必须在同一个数据库事务里完成,并且要锁住对应的库存行,防止两个门店同时卖同一个SKU导致超卖。常见写法是:先SELECT quantity FROM stock WHERE tenant_id=? AND store_id=? AND sku_id=? FOR UPDATE,然后判断库存够不够,再写流水,最后更新库存数量。
单据编号在多商户系统里也要特别注意。如果直接用数据库自增ID做单据编号,商户看到的是“10086号单据”而不是“XS20250101-001”。常见做法是每个租户维护一个单号序列,格式为“单据类型前缀+日期+租户内自增序号”,这个序列可以放redis里做incr,也可以单独建一张sequence表用行锁保证唯一。给商户看的编号要连续、可读,但数据库内部的主键仍然是雪花ID,两者分开存。
3.3 多仓库与调拨单的边界问题
多商户系统里经常把“门店”和“仓库”混为一谈。门店是可以收银开单的销售点,仓库是物理存货点,一个门店可以有多个仓库,一个仓库也可以服务多个门店。如果只做store_id,生鲜档口、中央厨房这类客户就会抱怨“卖了A店的货但货在B库”。我一般把store和warehouse分开建模,销售单引用warehouse_id,报表再按store汇总。
调拨单是进销存里最容易翻车的功能。调拨语义是“从A仓出,向B仓入”,中间还有“在途库存”概念。简单做法是调拨单审核时,A仓生成调拨出库流水,B仓生成调拨入库流水,不引入在途状态;复杂做法是加“调拨在途”字段,B仓收货确认后库存才生效。引入在途看上去严谨,但会让库存汇总变得很绕。对大多数中小商户,先做简单版就够,等真有“货在途中”的实时库存诉求再升级,否则月末对账会多很多说不清的差异。
4. 简单财务模块:从业务单据生成记账凭证的轻量做法
4.1 财务模块到底做多重才算“简单”
标题里写明“简单财务”,这对系统边界是个很好的约束。进销存真正的财务功能可以做到总账、明细账、三大报表、固定资产折旧,但如果全都塞进SaaS多商户系统,开发和运维成本都会失控。常见做法是把财务限定在四类能力:应收应付、收支流水、成本核算、简单利润表。
应收应付来自销售单和采购单:销售单审核后生成“应收账款”,客户付款后核销;采购单生成“应付账款”,向供应商付款后核销。收支流水管理日常费用,比如房租、工资、水电。成本核算用移动加权平均算销售成本。利润表等于销售收入减销售成本减期间费用。这套口径不是会计准则,但足以让老板看清“这个月到底是赚是亏”。
4.2 应收应付:从销售单和采购单自动生成往来
不要让人手工去录应收应付,一定从业务单据自动生成。这也是“简单财务”的核心原则——业务单据是唯一数据源,财务数据是业务数据的衍生视图。
INSERT INTO receivable (tenant_id, customer_id, source_bill_type, source_bill_id, amount, status) SELECT tenant_id, customer_id, 'SALE', id, total_amount, 0 FROM sale_order WHERE id = ? AND status = 2;这段SQL的触发时机是销售单审核后。其中status用0表示未核销、1表示部分核销、2表示已核销。核销时更新receivable状态,同时在payment_flow流水表里记录收款金额、收款方式和核销时间。这里有个边界:一张销售单可能分多次收款,所以核销不能直接改成“已核销”,要累加已收金额,当已收金额大于等于应收金额时才置为已核销。
如果商户有预收款或定金,就要再加一个customer_balance表,但这并不算“简单财务”的必需内容。遇到客户有这类需求,我一般先问清楚是不是高频场景,低频的用备注记录就行,不要把简单财务做成标准财务软件。
4.3 成本核算:移动加权平均的实时计算与月末调整
成本核算直接决定利润表准不准。进销存里常见的成本口径有三种:移动加权平均、月末一次加权、先进先出。多商户SaaS系统最推荐移动加权平均,它实时性好、不用月末大批量重算,实现也最简单。但移动加权平均有一个被忽视的坑:购入时更新均价,销售时不更新均价只减少数量,退货时如果按原单退回,要反冲原销售流水;如果按新进货退回,要重新计算均价。
UPDATE stock SET avg_cost = ((quantity * avg_cost) + (in_qty * purchase_price)) / (quantity + in_qty) WHERE tenant_id = ? AND store_id = ? AND sku_id = ?;上面这段SQL在批量采购入库时没问题,但quantity和avg_cost必须先从库里读出来再算,不能直接在UPDATE里用新的quantity,否则会重复计算。另一个边界是负库存:如果允许先卖后进,库存数量减到负数,avg_cost的计算就会失真。很多进销存系统允许负库存出库,这是业务上的妥协,但成本核算在这种时候必须按“暂估成本”处理,否则成本会变成负数或异常值。
月末还会遇到“货到票未到”的问题:货到了发票没到,采购单按暂估价入库,发票到了再调整。如果做简单财务,我建议直接不做暂估,让商户把采购单金额改成实际发票金额重新审核,或者直接记一笔“采购差价”支出。强行实现暂估流程,会给SaaS多租户系统增加大量对账复杂度,不值。
4.4 财务对账:业务台账和总账对不平时的排查顺序
简单财务上线后,最常听到的抱怨是“利润表数字和我想的不一样”。排查顺序一般是这样:先看销售成本,再看应收核销,最后看费用流水。销售成本对不平,九成是负库存出库导致成本异常,回库存流水里找负数库存的时间点;应收对不平,八成是部分收款后状态没更新;费用对不平,多半是手工收支单和银行流水时间对不上。
这套排查顺序应该做成报表页面旁边的“对账助手”,把几个关键差异用红字标出来。多商户场景下,每个商户的财务口径还可能不一样,有的商户要含税价核算,有的要按不含税价。在建表时就要留一个tax_mode字段,不要写死在代码里。
5. 多商户间的权限隔离与计量收费:五个高频踩坑
5.1 商户、门店、用户、角色四层权限的映射
现象:系统上线后被商户投诉,A店的店长能查到B店的销售数据和利润,而且查得毫无痕迹。原因:照着单应用的RBAC设计,只有“用户-角色-权限”两层,少了“商户”和“门店”维度。解决:拆成租户、门店、用户、角色四层,用户必须挂在tenant_id下,再通过user_store关联表绑定可访问的门店集合。
CREATE TABLE user_store ( user_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, store_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, store_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户门店角色关联表';这张关联表解决的是“一个用户在多门店有不同角色”的场景,比如总部运营在A店是店长,在B店只是导购。查询数据时,先根据用户ID查出所有可访问的tenant_id集合,再拼进WHERE tenant_id IN (...) AND store_id IN (...)。这里最隐蔽的坑是:很多SQL新手在拼接IN条件时,忘记把用户所属租户过滤条件同时带上,结果就是用户能看到本租户所有门店的数据。权限过滤必须放在service层统一处理,不能散落在每个Mapper里手动拼条件。
5.2 文件存储目录泄漏:一个真实翻车现场
现象:商户A的合同扫描件,被商户B猜出URL路径直接下载了。原因:文件按日期存到同一个目录,文件名用了原文件名,路径里没有租户维度。解决:文件存储路径强制带上租户ID,文件名用UUID。
// 强制租户目录隔离 String dir = uploadRoot + "/" + tenantId + "/" + LocalDate.now(); String fileName = UUID.randomUUID().toString() + "." + ext;这段代码有两个细节。第一,路径中必须带tenantId,哪怕预览时需要临时签名URL,也不要让商户之间共享目录。第二,文件访问的鉴权不能只依赖URL不可猜测,要在文件服务里做租户校验,比如从token解析出tenantId后,再检查请求的路径是否以该租户目录开头。否则一旦URL泄露,任何人都能下载。这个翻车现场让我后来养成了一个习惯:所有上传文件,一律按租户隔离,宁可多建一层目录,也不要省。
5.3 盘点与库存流水时间戳的顺序错乱
现象:盘点审核后,库存汇总流水和实际库存对不上,而且找不到任何记录解释差异。原因:盘点功能直接把“盘点数量”覆盖成新库存,没有生成对应的库存流水。解决:盘点审核时生成一条“盘点调整”流水,把差异数量记录成盘点盈亏。
更隐蔽的坑是时间戳排序问题。如果流水表用created_at来重建时间轴,同一秒内有多笔并发操作,排序就是不稳定的。排查“库存怎么突然多了”时,单靠时间戳经常看不出真实顺序。解决方法是加一个自增sequence或全局递增的业务版本号,不能用数据库的auto_increment id做全局排序,因为不同表的id各自独立。给流水加sequence字段,即便跨表,也能通过序列号还原全局先后顺序。
5.4 月末结转与公共表的并发锁冲突
现象:某商户做月末结账时,其他商户开单变得很慢,数据库监控里出现大量锁等待。原因:结转逻辑把该商户的数据全表UPDATE,锁的范围超出了该租户。解决:错峰结转,排他锁只在商户维度生效。
结转逻辑必须在事务里对该商户的相关表加锁,但不能锁全表。比如做月末库存结转时,只UPDATE该tenant_id的stock_flow,把本月已结账的流水标记为locked,并用tenant_id维度做分布式锁,让同一商户的月末操作串行,其他商户不受影响。这类锁冲突可以从数据库的锁等待日志里找到具体表名和索引键,判断是tenant_id没走索引导致的全表锁,还是跨商户误锁。
5.5 数据迁移与导出:商户要退出时的后悔药
现象:商户合同到期要自己部署,找我们要全部业务数据,最后只能导出一堆CSV,字段还看不懂。原因:没有预置标准化的租户数据导出任务。解决:做“商户数据包”导出功能,一键导出该租户下的商品、往来单位、库存、单据和流水,导出格式用通用JSON或CSV目录包。
这个导出任务有个技术细节:遍历所有表时不能直接SELECT *,因为很多表里有其它租户的关联数据或内部日志;必须严格按tenant_id过滤,并且要把字典表翻译成人类可读文案,比如单据类型字段10对应“采购入库”,否则商户拿到数据也看不懂。数据导出要设计成异步任务,小批量分页查数据再压缩,完成后给商户管理端发下载链接,不能实时执行,否则数据量大时会拖垮正常业务。
6. 快速验证这套系统的做法:从单商户跑通到压测多租户
把上面这些设计做完,第一版系统怎么验证?我的习惯是先把“单商户闭环”跑通,再做“跨商户隔离”的压测,后者才是SaaS系统的命门。
单商户闭环验证是指:建一个测试商户,录入商品、供应商、客户,走一遍采购入库、销售出库、退货、盘点、调拨,在系统里核对库存数量变化、应收应付余额、利润表数字。这个闭环如果半小时内跑不通,说明数据模型有问题,先别管SaaS,回去改表。
跨商户隔离压测,最直观的做法是用脚本并发创建两个商户,对同一SKU编码分别做销售单,查询时校验双方的库存不能互相看到。下面给一个简单压测思路:
# 并发模拟两个商户同时开单,观察库存流水是否串数据 for i in 1 2; do curl -X POST "http://localhost/api/sale/order" \ -H "Authorization: Bearer Token_商户$i" \ -d '{"skuCode":"SKU001","quantity":5,"storeId":10}' & done wait这段脚本的价值不在于压测性能,而在于验证“两个商户使用相同SKU编码时,数据是否被正确隔离”。测试完还要去数据库里查stock和stock_flow,确认两个tenant_id的数据各自正确,然后验证报表维度,看看商户A的报表会不会混入商户B的流水。
压测多租户时,我一般还会额外验证一个场景:A商户的店长,带着A的token去请求B的store_id,服务端必须返回无权限而不是返回数据。这个测试很容易漏,但漏一次就是事故。把这些验证都跑过,再考虑商户扩容和分库分表,后面的运维压力会小很多。希望这些经验能帮你在多商户进销存这条路上少交点学费。
本文还有配套的精品资源,点击获取