
简介这是一份基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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服务端必须返回无权限而不是返回数据。这个测试很容易漏但漏一次就是事故。把这些验证都跑过再考虑商户扩容和分库分表后面的运维压力会小很多。希望这些经验能帮你在多商户进销存这条路上少交点学费。本文还有配套的精品资源点击获取