2026/10/13 13:45:54

SaaS进销存多商户系统设计:多租户架构与库存财务闭环

SaaS进销存多商户系统设计:多租户架构与库存财务闭环 简介这是一套基于SaaS模式的进销存多商户系统源码融合简单财务功能面向多仓库、多门店经营场景适合中小型商贸企业管理者、SaaS产品开发者及进销存系统二次开发人员。系统覆盖采购管理、销售管理、仓库管理、报表查询和系统管理提供库存状况、出入库统计等核心报表并对商品多规格、多单位以及角色权限做了细致控制支持按功能范围、操作权限进行授权方便企业实现精细化管理。资源共653个文件以313个js脚本、244个vue组件为主辅以svg图标、scss样式及少量png、json等配置与素材压缩包仅1.76MB结构紧凑目录中包含图标字体、全局样式与基础配置文件前端工程按页面与逻辑分层组织便于定位采购、销售、仓库等模块。已有438人学习压缩包内提供完整前端工程适合研究SaaS产品的权限设计、进销存业务流程建模也可在此基础上扩展财务与报表模块节省从零开发成本。1. SaaS进销存多商户系统值不值得做先看清它服务的真实场景在地方批发市场或连锁门店的日常里库存靠店长手工记Excel、欠款靠本子记的情况并不罕见。这类管理者最需要的往往不是大型ERP而是能按商户隔离数据、在浏览器里就能用的SaaS进销存再带一点应收应付和收支记录也就是标题里的“简单财务”。多商户系统在这里的实际价值不是“一套界面分角色菜单”而是同一套代码托管几十上百家商户每家只看到自己的商品、库存、价格和账单互不相干。拆开来看进销存解决的是采购、销售、库存三个高频动作的记录问题简单财务解决的是“这个月到底赚了多少、谁还欠我钱”SaaS模式和多商户这两个词则决定你如何用一套系统服务多家商户而不做重复部署。它适合的落地形态通常是小团队做垂直SaaS产品或者是集成商给连锁品牌做多店版后台。代价也很明确你要长时间补业务细节而不是做完底层架构就收工。下面从架构选型开始把这条路上的关键决策一次讲清楚。2. 多租户架构怎么选独立库、共享库还是共享表带tenant_id先给结论做SaaS进销存绝大多数项目会先落在“共享数据库、共享表、所有业务表带tenant_id”这条路上。原因很现实同样的服务器和数据库成本能支撑更多商户版本升级时也不会因为几十个独立schema而拖慢上线节奏。但这不代表独立库方案没用——你后面的客户里总会出现“数据要绝对隔离”的付费要求。2.1 三种隔离方案的成本与运维真相方案隔离强度资源成本备份恢复升级迁移独立数据库最高高连接数和存储成本翻倍每库独立备份逻辑清晰每个库都要跑一遍脚本共享库独立schema中中同库多schema按schema筛选导出脚本遍历所有schema共享库共享表tenant_id经济低适合上线初期整库备份按租户导出一次升级全局生效实际落地上独立数据库方案通常只在“单商户数据量巨大”或“客户有安全合规硬性要求”时才会走。共享库独立schema是个看着美好、用着别扭的折中方案MySQL里schema的回收和权限管理并不顺手schema一多运维脚本的迭代速度会明显拖后腿。所以从0到1做多商户进销存我一般直接建议上共享表加tenant_id把精力留给业务一致性。注意选shared-table方案意味着你的每一条SQL都必须带租户条件。漏掉一个就是数据越权事故。2.2 共享表tenant_id的字段设计与索引细节以库存表为例这是进销存系统里最核心的表之一CREATE TABLE inv_sku_stock ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, tenant_id BIGINT UNSIGNED NOT NULL COMMENT 商户ID, warehouse_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 仓库ID0表示默认仓, product_id BIGINT UNSIGNED NOT NULL COMMENT 商品ID, qty_on_hand DECIMAL(19,4) NOT NULL DEFAULT 0 COMMENT 可用库存, qty_ordered DECIMAL(19,4) NOT NULL DEFAULT 0 COMMENT 在途/锁定量, avg_cost DECIMAL(19,6) NOT NULL DEFAULT 0 COMMENT 移动加权平均成本, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_tenant_product_warehouse (tenant_id, product_id, warehouse_id), KEY idx_tenant_product (tenant_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品库存汇总表;这里有几个反复踩过的细节。第一数量用DECIMAL而不是INT因为进销存里会出现按“斤”“包”“箱”计量的商品小数点后4位够用也不会触发浮点误差。第二唯一索引必须包含tenant_id否则A商户创建商品后B商户的同ID商品会冲突这是共享表最常见的第一坑。第三平均成本精度用DECIMAL(19,6)金额核算时再四舍五入避免累计误差在月末暴露。另外tenant_id在所有业务表里的类型必须一致统一用BIGINT UNSIGNED不要有的表是INT、有的表是VARCHAR。联表查询一旦类型不一致MySQL会放弃索引走全表扫数据量上来后性能下降是几何级的。2.3 从请求到SQL杜绝串租户的三道防线共享表方案最大的隐患是串租户。光靠程序员自觉“每个查询都带tenant_id”不可靠要上机制。常见做法是三层拦截。第一层在业务入口的中间件里解析登录态把当前商户ID放进线程上下文比如Spring的ThreadLocal、PHP框架的全局容器。第二层重写数据访问层的基础查询方法凡是走框架查询构造器的操作自动把and tenant_id ?拼进where条件让多数业务代码不用显式写也实际生效。第三层在自动化测试里加一条“跨租户断言”用例用A商户的session去访问B商户的数据接口断言结果为空包括列表和详情两个维度都验。我一般会把第二层的自动拼接做成开关遇到需要跨租户查询的报表场景时显式关闭比如平台管理员要统计全平台SKU数避免平台侧功能被误伤。这个开关要写在独立的查询入口里不能散落在业务层否则后面维护的人根本不知道哪里开了、哪里没开。2.4 从共享表升级到独立库的低成本迁移路径做到一定规模后你会遇到少数大商户数据量动辄上百万行备份和查询都吃力。这时候再把所有表拆成独立库不现实常见做法是“分段路由”tenant_id小于某个阈值的走共享表超过阈值的新商户落在独立库。业务层增加一张租户路由表中间件先按tenant_id查路由把DB连接句柄注入当前线程后面的查询构造器就不需要感知库名。这条路径的关键在于表结构必须从一开始就在所有库里保持一致。我在早期项目里吃过亏共享库里给流水表加了一个字段忘了同步到独立库结果迁移后报表直接报错。后来定了一条规矩任何表结构变更都同时生成两个环境的DDL脚本并且CI里跑一次结构比对。迁移不是拖到最后一刻才考虑的事而是从第一天就要留好这个口子。3. 进销存简单财务怎么建模库存流水、移动加权平均、应收应付闭环很多第一次做进销存的朋友会把“库存数量”设计成直接加减字段销售单审核时给qty_on_hand减掉销售数量。听起来直接但遇到反审核、改单、跨月调整时你根本说不清这笔库存是怎么变的。我的做法是把库存流水作为唯一事实来源汇总表只做缓存和查询加速。3.1 用库存流水表把每一步变更记下来CREATE TABLE inv_stock_flow ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, tenant_id BIGINT UNSIGNED NOT NULL, warehouse_id BIGINT UNSIGNED NOT NULL, product_id BIGINT UNSIGNED NOT NULL, flow_type VARCHAR(20) NOT NULL COMMENT PURCHASE_IN/SALE_OUT/RETURN_IN/ADJUST_UP等, qty_change DECIMAL(19,4) NOT NULL COMMENT 入库为正出库为负, unit_cost DECIMAL(19,6) NOT NULL COMMENT 本次出入库的单价快照, before_qty DECIMAL(19,4) NOT NULL COMMENT 变动前库存, after_qty DECIMAL(19,4) NOT NULL COMMENT 变动后库存, biz_type VARCHAR(30) NOT NULL COMMENT 业务单据类型, biz_ref_id BIGINT UNSIGNED NOT NULL COMMENT 业务单据ID, remark VARCHAR(255) NOT NULL DEFAULT , created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tenant_product_time (tenant_id, product_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存流水表;写入流水的原则是入库或出库动作与单据审核在同一个数据库事务里完成不允许存在“单据已审核但流水还没写”的中间状态。before_qty和after_qty看上去冗余却是排查负库存和对账时的救命字段能让你直接看到连续变化的断点。很多问题的排查方式就是按商品和时间倒序扫流水看前后的跳变发生在哪一张单据上。3.2 移动加权平均成本怎么算才不会算错价SaaS进销存里最常见的成本核算是移动加权平均即每进一批货按加权方式刷新成本单价。这里用Python伪代码表达核心逻辑def purchase_in(tenant_id, product_id, warehouse_id, qty, price, ref_id): with db.transaction(): # 给该商品的库存行加行锁防止同一时刻两单入库把均价算偏 row db.fetch_one( SELECT * FROM inv_sku_stock WHERE tenant_id%s AND product_id%s AND warehouse_id%s FOR UPDATE, (tenant_id, product_id, warehouse_id) ) old_qty row.qty_on_hand old_cost row.avg_cost new_qty old_qty qty # 加权均价 (旧数量*旧单价 新入库数量*新单价) / 新总数量 new_cost (old_qty * old_cost qty * price) / new_qty db.execute( UPDATE inv_sku_stock SET qty_on_hand%s, avg_cost%s WHERE id%s, (new_qty, new_cost, row.id) ) db.execute( INSERT INTO inv_stock_flow (tenant_id, warehouse_id, product_id, flow_type, qty_change, unit_cost, before_qty, after_qty, biz_type, biz_ref_id) VALUES (%s, %s, %s, PURCHASE_IN, %s, %s, %s, %s, PURCHASE, %s), (tenant_id, warehouse_id, product_id, qty, price, old_qty, new_qty, ref_id) )注意这里必须SELECT ... FOR UPDATE把该租户该商品的行锁住后再算均价。没有这把行锁两个并发采购入库会把均价算偏。price参数有个细节如果是跨月调价单价快照直接取供应商采购价不要反向推算否则流水上的unit_cost会失真。出库逻辑同理先锁行把qty_on_hand减掉再按当时均价写一条SALE_OUT流水成本和数量一并在事务里落库。3.3 简单财务从销售单到核销的闭环逻辑标题里的“简单财务”落在数据模型上通常是两张表应收应付表和收付款表。业务发生时生成应收收款时做核销而不是把每次收款直接改单据金额。CREATE TABLE fin_receivable ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, tenant_id BIGINT UNSIGNED NOT NULL, biz_type VARCHAR(20) NOT NULL COMMENT SALE或SALE_RETURN, biz_ref_id BIGINT UNSIGNED NOT NULL COMMENT 销售单ID, customer_id BIGINT UNSIGNED NOT NULL, amount DECIMAL(19,2) NOT NULL COMMENT 应收金额, settled_amount DECIMAL(19,2) NOT NULL DEFAULT 0 COMMENT 已核销金额, status VARCHAR(20) NOT NULL DEFAULT OPEN, due_date DATE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tenant_customer (tenant_id, customer_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT应收表;收款的逻辑是插入一张收付款记录然后update fin_receivable的settled_amount当settled_amount等于amount时把状态置为已结清。这就是“简单财务”最简单的闭环足以回答“客户欠多少钱、回款了多少、账龄多久”这类老板最常问的问题。建议不要把简单财务做成会计凭证和科目余额两套账那会立刻拖累开发周期。做到应收应付、收付款流水、经营利润三类数据就足够绝大多数中小商户使用了。3.4 月末对账跑这几条SQL就够了-- 校验1流水合计与库存汇总一致 SELECT s.product_id, s.qty_on_hand, COALESCE(SUM(f.qty_change), 0) AS flow_total FROM inv_sku_stock s LEFT JOIN inv_stock_flow f ON f.tenant_id s.tenant_id AND f.product_id s.product_id AND f.warehouse_id s.warehouse_id AND f.created_at 2025-12-31 23:59:59 WHERE s.tenant_id 1001 GROUP BY s.product_id, s.warehouse_id HAVING s.qty_on_hand ! flow_total;-- 校验2应收余额逻辑正确性 SELECT r.tenant_id, SUM(r.amount - r.settled_amount) AS open_amount FROM fin_receivable r WHERE r.status OPEN GROUP BY r.tenant_id HAVING open_amount 0;校验1查出的是库存数字断裂的明细校验2查的是应收余额变成负数这种不可能发生的异常。我通常把这两条SQL封装成一个每日对账脚本凌晨跑一遍有异常就通知值班人。这是多商户进销存运营期最稳妥的止损方式别等问题被商户发现再来补救。4. 多商户进销存最容易翻车的五个坑从超卖到反审核这一章写的是踩过、也帮别人排查过的典型问题每一条都按现象、原因、解决三个层次讲完。4.1 并发扣库存导致超卖现象促销活动开始后100件库存被卖出120件库存变成负数对账全乱。原因扣库存的代码是先select再update两个并发请求都读到qty_on_hand100各自减1回写时没有条件约束最终把库存写成了99而不是98。这就是典型的读改写竞态。解决用带条件的原子更新一条SQL完成判断和扣减UPDATE inv_sku_stock SET qty_on_hand qty_on_hand - 1 WHERE tenant_id 1001 AND product_id 888 AND warehouse_id 0 AND qty_on_hand 1;受影响行数为1代表成功为0代表库存不足业务层抛错并提示用户。这个更新和后面的流水插入放到同一个事务里就能满足绝大多数SaaS场景的并发扣减需求。不要为了“更稳妥”引入Redis分布式锁数据一致性没问题但运维复杂度会成倍增加。4.2 缓存key没带租户ID商户A读到商户B的库存现象商户A在商品详情页看到了另一个商户的价格和库存刷新后有时又正常排查半天才发现是缓存问题。原因缓存key设计成product:stock:888这种只带商品ID的形式两个租户的同ID商品互相覆盖Redis返回给前一个商户写入的数据。多商户系统里一切缓存key都要考虑租户维度。解决缓存key必须带租户ID最保险的写法是stock:tenant:{tenant_id}:product:{product_id}:warehouse:{warehouse_id}。同时在更新库存时主动删除对应缓存键不要等过期避免用户反复点击刷出“还有库存”的假象。4.3 负库存开关一开对账永远对不平现象老板说“生意先做库存后面补”开了允许负库存参数结果每到月底负库存商品和流水对不上还定位不了到底是哪一单先透支的。原因允许负库存后出库单不再校验qty_on_hand 0商品库存出现中间负态又被后续入库的加权平均“抹平”流水的连续性断裂后就难以复原。解决在业务上区分“可容忍负库存的快速开单模式”和“期末盘点模式”。负库存只在一个独立参数里开启且每次出库仍要记录流水。负库存商品必须在月结前补齐如果对不上账就按after_qty 0的流水反查发生时刻强制补一张入库调整单抵消。这个开关默认应该关闭只有特定商户按需开放。4.4 反审核改单引发成本重新分摊现象销售单审核后财务发现单价填错反审核修改但库存数量和销售成本出现偏差利润报表和实际不符。原因反审核只撤销了库存流水没把销售对应的成本回滚重新生成新单后商品均价已经变了成本按新单重新计算利润报表自然对不上。解决反审核必须设计成可逆操作闭环。固定顺序是先撤销收款核销再回滚库存流水最后删除原单据回滚用的单价必须取原流水的unit_cost快照而不是当前均价。任何直接改原单的做法都是给自己埋雷正确姿势是“作废原单生成新单”。4.5 期初Excel导入的重复和错位现象客户给了一份有多个sheet的期初库存Excel导入后商品数量翻倍有些商品成本还串到了别的商品上。原因导入时没有用商品编码做唯一性校验同一行数据被重复处理商品名、编码、仓库三列顺序错位数据写到了错误商品名下。Excel导入看着简单其实是事故高发区。解决导入三步走。第一步模板固定校验“商品编码仓库编码”为唯一键重复行直接标红。第二步做幂等给导入批次生成批次号失败可以重试重试时跳过已成功的记录。第三步入库后把“本次导入总行数、成功行数、失败行数”回显给操作人每条失败原因写进日志。只给一个“导入失败”提示客户会原地爆炸。5. 从交付角度看系统落地商户开户、权限配置、期初数据导入与备份代码写得再漂亮要交付给十几家商户时还是会被开户流程和初始化数据卡住。这里按我自己的交付习惯讲一条顺的落地路径。5.1 商户开户不仅仅是一行INSERT多商户系统的商户开通远不止向商户表插入一条记录。一个可用的账套至少包括基础参数、仓库、商品分类、计量单位、角色权限、操作员账号、默认打印模板。我一般会用一个统一开户服务在一个事务里完成以下动作插入tenant表状态设为试用中生成默认参数包括允许负库存0、小数精度2、默认成本核算移动加权创建默认仓库和默认往来单位写入管理员账号并绑定超级管理员角色再生成一份商户初始化事项清单业务人员后续按清单补齐商品和期初数据。这套流程看起来琐碎少了哪一步都会在商户第一次使用时露馅。最典型的翻车是开户后商户能登录但进“库存查询”直接报错原因是没建默认仓库所有单据都找不到仓库ID。所以开户接口一定要做成可重入的跑了一次报错修复后能继续跑不能把托管数据写到一半。5.2 权限怎么分老板、店长、录单员三套默认角色角色能看数据能做的操作典型案例老板全部商品、全部订单、成本、利润审核、反审核、改价、导出小老板管全盘店长本店商品、本店订单收货、发货、开单、审核连锁门店店长录单员本店商品价格和库存录入单据不能审核前台收银开单员权限颗粒度很重要不要把“查看成本”和“修改价格”默认塞给录单员。很多商户的数据事故不是系统bug而是权限太宽。默认角色在开户时生成商户可以自行调整但“新建角色”的能力要保留给商户管理员不要开放给所有操作员口的入口。5.3 期初数据导入Excel导入的校验与幂等设计期初数据通常是商品档案、供应商、客户、期初库存、期初应收应付五类。这里以期初库存为例做导入接口时的幂等设计def import_opening_stock(tenant_id, batch_no, rows): for row in rows: if exists_imported(tenant_id, batch_no, row[line_no]): continue # 已成功处理过的行不再处理 product get_product_by_code(tenant_id, row[product_code]) if not product: log_failure(batch_no, row[line_no], product_code not found) continue adjust_stock( tenant_id, product.id, row[warehouse_code], qtyrow[qty], unit_costrow[cost], flow_typeOPENING_IN, batch_nobatch_no ) mark_imported(tenant_id, batch_no, row[line_no])batch_no加line_no作为导入记录的唯一键实现幂等。导入任务放到后台队列异步执行跑完给前端一个总数汇总总行数、成功数、失败数、失败原因。这样数据库连接断开时重试能安全续跑不会重复加库存。5.4 备份与恢复多租户模式下的备份粒度共享库共享表方案在备份上其实比独立库更集中每天整库物理备份binlog保留一定周期恢复时先整库恢复再对单商户做数据导出。这也带来一个常见需求客户要迁走你得能按tenant_id导出他名下的全部数据。mysqldump -u deploy -p --no-create-info \ --wheretenant_id 1001 \ saas_db inv_product inv_sku_stock inv_stock_flow \ tenant_1001_opening_data.sql注意这里有个坑mysqldump的--where条件会作用于导出的每一张表所以只把包含tenant_id的表放进来像那些没有租户列的基础配置表要单独导出。另外多商户SaaS的“删除”从来不是物理删除而是软删封存。把tenant_id标记为已注销再做敏感数据脱敏。我在多个项目里见过客户要求“删库跑路”真物理删了三个月后他说数据找不回来了那种售后场景极其难受。软删加导出包才是这类系统的体面退场。6. 上线前用一套演示账套做全链路验收从采购到回款一次跑通6.1 拟定一个六步全链路演练脚本我不喜欢在测试环境里东点一下西点一下来验证功能。最有效的方式是给一家商户配一个演示账套按真实业务从头到尾走一遍开户并设置初始参数导入商品档案和期初库存做一张采购入库单并审核做一张销售单并收款核销做一张费用单确认支出最后跑一次月结查看利润和应收余款。这六步覆盖了进销存和简单财务的全部核心路径。6.2 重点验证两个账能不能平库存账要平期初加采购减销售减损耗等于期末库存流水明细能对上。财务账要平应收余额等于销售单总额减已核销金额利润等于销售收入减销售成本减费用。这两条账能平交付质量底线就守住了。然后我会回头把演示账套里那张销售单做一次反审核再把流程重新走一遍重点确认成本有没有被重复滚掉。这个动作检验的是对账最脆弱的环节状态流转。大多数进销存项目的月末报表出错根源都在反审核和冲销逻辑不完整而不在期初设计。上线前反复打断这个流程能找出不少隐藏的问题。说一个我自己的血泪经验上线前我会把演示账套的数据保留下来作为项目验收样例而不是只跑一遍自动化测试就宣布完成。原因是客户提需求时你拿一套真实走通过的数据给他看比口头解释高效得多。做这类系统我的习惯是先跑通一个账套再谈功能迭代。一次全链路验收跑通胜过改完几十个bug之后自以为一切顺利。这也是为什么我想要一套能随时重放的演示账套而不是靠临时造数据。希望帮到你。本文还有配套的精品资源点击获取