多仓库多门店SAAS进销存系统架构解析
发布时间:2026/10/22 14:18:35 锦皓数字建站

简介这是一套面向中小零售与连锁企业的SaaS化进销存ERP源码系统聚焦多仓库、多门店协同管理场景适用于Java/前端开发者学习企业级应用架构设计与权限控制实践。资源包含653个文件以244个Vue组件和313个JS脚本为核心支撑采购、销售、库存、财务及报表等完整业务模块56个SVG图标与7个SCSS样式文件保障界面可定制性Lodop打印插件集成支持单据模板灵活输出。压缩包仅1.76MB轻量但结构完整含系统首页、图标字体、配置文件及基础静态资源目录组织清晰便于快速启动与二次开发。目前已有337人学习下载读者可直接获取具备角色权限分级、多规格商品管理、出入库统计报表等生产级功能的可运行代码同时掌握SaaS多租户基础实现思路与前后端协作规范。1. 为什么一个“进销存ERP源码”要强调“多仓库多门店SAAS版本”——它不是套模板而是解决连锁扩张时库存对不上的血泪现场你手上有家社区生鲜店刚开了第二家分店发现老系统里A店明明显示还有50箱牛奶但实际调货过去只剩32箱B店申请补货总部仓单已发物流却说没这单财务月底对账系统里成本结转比实际高了7.3%。这不是操作失误是架构缺陷——传统单体进销存系统默认“一个库一个店一个账套”而真实业务里调拨、寄售、联营、代管、在途、待验收、临期预警这些状态横跨多个物理空间和管理主体。所谓“多仓库多门店SAAS版本”本质是把库存从“静态数字”变成“带时空坐标的动态实体”每个商品在任意时刻都必须能回答三个问题它在哪物理位置、归谁管权属关系、算谁的财务归属。这套源码的价值不在功能列表有多长而在它用租户隔离组织树建模库存维度解耦这三根柱子撑住了从单店到50家门店、从自营业态到含加盟店/前置仓/中心仓混合模式的平滑演进。适合正在做数字化选型的中小连锁企业IT负责人、想二次开发的独立开发者以及需要快速验证SaaS化改造路径的技术团队。2. 源码结构拆解看清“多仓库多门店SAAS”不是加个下拉框而是四层模型重构2.1 租户层SAAS化的根基不是数据库隔离而是上下文注入点设计SAAS版本最常被误解的点就是以为“每个客户建一个库”就是SAAS。这套源码采用单库多租户Shared Database, Shared Schema 动态租户上下文注入。核心在于TenantContext类——它不依赖HTTP Header传tenant_id而是在MyBatis拦截器中通过ThreadLocal绑定当前租户标识并自动为所有SQL注入AND tenant_id ?条件。关键代码如下// TenantContext.java public class TenantContext { private static final ThreadLocalString currentTenant new ThreadLocal(); public static void setTenantId(String tenantId) { currentTenant.set(tenantId); } public static String getTenantId() { return currentTenant.get(); } }提示该设计避免了Hibernate多数据源切换的性能损耗但要求所有Mapper XML中显式添加if testcom.xxx.TenantContextgetTenantId() ! nullAND tenant_id #{tenantId}/if。源码里已全局替换但若你新增表必须手动补上——这是新手最容易漏的点。2.2 组织架构层门店≠仓库用“组织树角色标签”解耦管理权与物理权系统里org_unit表不是简单存门店名而是构建三级组织树根节点集团总部typeGROUP中间节点区域分公司/城市运营中心typeREGION叶子节点门店/仓库/配送中心typeSTORE / WAREHOUSE / DC每个节点可打多个标签is_sales_point(是否销售点)、is_inventory_center(是否库存中心)、is_finance_unit(是否独立核算单位)。例如某前置仓打标is_inventory_centertrue, is_sales_pointfalse某加盟店打标is_sales_pointtrue, is_finance_unitfalse。这种设计让同一物理仓库可同时服务多家门店如中心仓配货给3家社区店也支持一家门店挂靠多个仓库如主仓临期品专仓。2.3 库存维度层一个SKU在系统里有6种“存在形态”传统系统里SKU库存而本源码将库存拆解为6个正交维度维度示例值作用warehouse_idWH-001物理存放位置owner_typeSTORE / SUPPLIER / THIRD_PARTY所有权归属方owner_idST-005 / SP-102归属方ID门店或供应商编码stock_statusNORMAL / DAMAGED / ON_WAY / IN_TRANSIT当前状态batch_no20240501-A批次号支持效期、溯源lock_reasonSALE_ORDER / PURCHASE_IN / TRANSFER_OUT锁定原因防超卖注意所有库存查询必须至少指定warehouse_id owner_type owner_id三元组否则会跨租户泄露数据。源码中StockQueryService的queryByDimension()方法强制校验此规则。2.4 单据流层调拨单不是“出库入库”而是状态机驱动的跨组织事务多门店场景下最易出错的是调拨。源码将调拨单(transfer_order)设计为状态机DRAFT → SUBMITTED → APPROVED → PICKED → SHIPPED → RECEIVED → COMPLETED每个状态变更触发对应动作APPROVED冻结调出仓库存lock_reasonTRANSFER_OUTSHIPPED生成物流单号更新in_transit库存RECEIVED校验实物与单据差异走inventory_adjustment流程COMPLETED释放冻结扣减调出仓增加调入仓关键逻辑在TransferOrderService.processStateChange()中状态跳转非线性如SHIPPED可回退至APPROVED但不可跳过PICKED直接到SHIPPED避免人工误操作导致库存黑洞。3. 部署与初始化从源码到可运行系统的最小闭环路径3.1 环境准备避开JDK和MySQL版本陷阱该源码基于Spring Boot 2.7.x构建必须使用JDK 11非JDK 17因部分国产中间件SDK未适配高版本JVM。MySQL要求8.0.22且需开启sql_modeSTRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO——这是为保障库存负数校验严格生效。初始化脚本位于/sql/init.sql执行前需确认tenant表中已插入默认租户tenant_codedemo,tenant_name演示租户org_unit表中已建好根节点parent_idnull,codeROOTsys_config表中default_warehouse_id配置项指向一个有效仓库ID3.2 启动配置application.yml里3个必改参数# application.yml 关键片段 spring: datasource: url: jdbc:mysql://localhost:3306/erp_saas?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: erp_user password: secure_password_123 erp: saas: # 【必改】当前部署租户标识首次启动必须设为已存在的tenant_code current-tenant: demo # 【必改】默认库存中心ID所有新门店创建时自动关联此仓库 default-warehouse-id: WH-001 # 【必改】多门店价格策略AUTO按门店设置/ GLOBAL全集团统一 price-strategy: AUTO3.3 初始化门店与仓库用API而非后台页面系统提供初始化脚本/scripts/init-store-and-warehouse.sh但更推荐用Postman调用以下接口避免页面操作遗漏标签# 创建新门店POST /api/v1/org-unit curl -X POST http://localhost:8080/api/v1/org-unit \ -H Content-Type: application/json \ -d { name: 朝阳路旗舰店, code: ST-001, type: STORE, parentId: ROOT, tags: [is_sales_point:true, is_finance_unit:true] }# 创建对应仓库POST /api/v1/org-unit curl -X POST http://localhost:8080/api/v1/org-unit \ -H Content-Type: application/json \ -d { name: 朝阳路仓, code: WH-001, type: WAREHOUSE, parentId: ROOT, tags: [is_inventory_center:true] }逻辑说明parentId必须为ROOT或已有区域节点IDtags字段是字符串数组格式为key:value系统会自动解析并存入org_unit_tag关联表。若漏掉is_inventory_center:true后续无法为该门店分配库存。3.4 首单验证走通“门店采购→中心仓入库→门店销售”全链路采购单登录ST-001门店账号创建采购单供应商选SP-001商品选SKU-001数量100→ 审批通过后中心仓WH-001库存in_transit100入库单登录WH-001仓库账号查收采购单→生成入库单→扫描实物入库→WH-001的NORMAL库存100in_transit-100销售单ST-001门店创建销售单商品SKU-001数量30→ 系统实时校验WH-001中owner_idST-001且stock_statusNORMAL的可用库存≥30 → 扣减成功此流程验证了租户隔离ST-001看不到ST-002的单据、库存维度只扣减归属ST-001的库存、状态机入库单状态流转驱动库存变更三重机制。4. 多仓库多门店场景下的5个高频避坑指南4.1 现象调拨单审批后调出仓库存未冻结导致超卖原因调拨单审批接口/api/v1/transfer-order/{id}/approve未校验调出仓当前可用库存是否充足。源码中该接口默认只检查单据合法性不触发库存预占。解决在TransferOrderApprovalService.approve()方法末尾添加库存冻结逻辑// 冻结调出仓库存伪代码 stockService.lockStock( transferOrder.getFromWarehouseId(), transferOrder.getOwnerId(), // 调出方所有权ID transferOrder.getItems(), LockReason.TRANSFER_OUT );血泪经验某次上线漏加此逻辑导致3家门店同时调拨同一SKU中心仓库存被重复冻结实际发货时发现缺货——冻结必须在审批通过瞬间完成不能等到发货环节。4.2 现象加盟店门店无法看到总部定价商品显示“价格未设置”原因价格策略配置为GLOBAL但加盟店门店的owner_type被错误设为STORE而非FRANCHISEE导致价格服务PriceService.getPrice()查询时owner_type不匹配。解决检查org_unit表中该门店记录的type字段必须为FRANCHISEE同时确认price_policy表中存在owner_typeFRANCHISEE的策略记录。若无需插入INSERT INTO price_policy (owner_type, policy_type, created_time) VALUES (FRANCHISEE, GLOBAL, NOW());4.3 现象跨区域调拨时物流单号无法同步至调入方门店原因物流信息更新接口/api/v1/logistics/update仅更新logistics_order表未触发transfer_order状态机向SHIPPED推进导致调入方门店的RECEIVED按钮不可点。解决在物流更新成功后调用TransferOrderService.updateStatusToShipped(transferOrderId)强制推进状态并发送MQ通知。4.4 现象财务月结时某门店成本结转金额异常偏高原因该门店存在大量stock_statusON_WAY在途库存而成本结转脚本CostSettlementJob默认将所有NORMAL状态库存计入当月成本未排除在途库存。解决修改结转逻辑增加状态过滤// 原逻辑stockService.findByWarehouseId(warehouseId) // 新逻辑stockService.findByWarehouseIdAndStatus(warehouseId, StockStatus.NORMAL)4.5 现象SAAS版API被其他租户调用返回了错误租户的数据原因某个自定义报表接口/api/v1/report/sales-summary未在Controller层调用TenantContext.setTenantId()导致MyBatis拦截器无法注入tenant_id条件。解决所有新增Controller方法必须在入口处显式设置租户GetMapping(/sales-summary) public Result salesSummary(RequestHeader(X-Tenant-ID) String tenantId) { TenantContext.setTenantId(tenantId); // 【关键】 return reportService.generateSalesSummary(); }注意该Header名X-Tenant-ID在application.yml中可配置但必须与前端网关传递的Header一致。若用Nginx反向代理需在location块中添加proxy_set_header X-Tenant-ID $http_x_tenant_id;。5. 进阶实战用“库存快照时间旅行”功能定位跨月库存差异根源5.1 为什么需要库存快照——因为月末对账时你永远不知道“当时系统里到底有多少”多门店场景下库存差异往往滞后暴露6月30日财务对账发现A门店账实差50件但此时7月1日已发生多笔销售、调拨、报损。传统方案只能查当前库存而本源码内置inventory_snapshot表每日凌晨2点自动抓取各仓库各SKU的NORMAL库存快照包含snapshot_date: 2024-06-30warehouse_id: WH-001sku_id: SKU-001quantity: 1200locked_quantity: 80created_time: 2024-06-30 02:00:03快照表不存历史明细只存聚合结果体积可控百万级SKU日增约20MB。5.2 时间旅行查询三步还原6月30日库存真相假设A门店ST-0016月30日账面应有1200件但盘点只有1150件差50件。用快照定位第一步查快照基准值SELECT quantity, locked_quantity FROM inventory_snapshot WHERE snapshot_date 2024-06-30 AND warehouse_id WH-001 AND sku_id SKU-001; -- 返回quantity1200, locked_quantity80第二步查当日锁定明细谁锁了80件SELECT order_type, order_id, quantity, lock_reason FROM stock_lock_log WHERE warehouse_id WH-001 AND sku_id SKU-001 AND lock_time 2024-06-30 00:00:00 AND lock_time 2024-07-01 00:00:00 AND status LOCKED; -- 发现2笔销售单SO-20240630-001/002共锁定60件1笔调拨单TO-20240630-001锁定20件第三步查解锁记录哪些锁被释放了SELECT unlock_time, unlock_reason FROM stock_lock_log WHERE warehouse_id WH-001 AND sku_id SKU-001 AND unlock_time 2024-06-30 00:00:00 AND unlock_time 2024-07-01 00:00:00; -- 发现仅SO-20240630-001的60件被释放因客户取消订单TO-20240630-001的20件未释放结论调拨单未完成导致20件库存被长期占用另30件差异需查销售单发货记录——快照帮你把“大海捞针”变成“按图索骥”。5.3 自定义快照策略按需开启高精度监控快照默认每日1次但对高周转商品如生鲜可配置小时级快照# application.yml erp: inventory: snapshot: # 全局策略DAILY / HOURLY strategy: HOURLY # 高频商品白名单逗号分隔SKU high_freq_skus: SKU-001,SKU-002 # 小时快照仅对白名单生效其他仍按日启用后inventory_snapshot表将增加snapshot_hour字段值为0-23查询时需组合snapshot_date snapshot_hour。5.4 快照数据导出生成可审计的PDF对账单系统提供/api/v1/snapshot/export-pdf接口传入warehouse_id、date_from、date_to返回带数字签名的PDF。其核心逻辑在SnapshotExportService.exportAsPdf()读取快照数据按日期分页每页顶部加盖“本快照由ERP系统于[时间]自动生成哈希值[SHA256]”水印底部嵌入二维码扫码可跳转至系统内该快照详情页防篡改我的习惯是每月初导出上月全部快照PDF与财务共享云盘链接并邮件抄送法务——不是信不过系统而是信得过留痕。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。