简介:这是一套基于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 '仓库ID,0表示默认仓', `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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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_hand=100,各自减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"], qty=row["qty"], unit_cost=row["cost"], flow_type="OPENING_IN", batch_no=batch_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 \ --where="tenant_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之后自以为一切顺利。这也是为什么我想要一套能随时重放的演示账套,而不是靠临时造数据。希望帮到你。
本文还有配套的精品资源,点击获取