简介:这是一套面向中小零售与连锁企业开发者的SaaS化进销存ERP源码系统,聚焦多仓库、多门店场景下的商品管理、采购销售协同及基础财务核算需求,适用于具备Vue.js和前端工程化能力的中高级开发者进行二次开发或教学实践。资源包共653个文件,以244个Vue组件文件构建核心业务界面,313个JS文件支撑逻辑与状态管理,辅以SVG图标、SCSS样式及Lodop打印集成相关资源,整体仅1.76MB,轻量易部署。已有337人学习下载,说明其在轻量化SaaS ERP领域具备一定实践参考价值。读者可直接获取完整前后端分离架构、细粒度RBAC权限控制实现、多规格多单位商品模型、库存动态预警逻辑、自定义单据打印模板(含GIF演示)及标准化项目配置(editorconfig、eslintignore、license等),代码结构清晰,适合快速理解零售业SaaS系统设计范式。
1. 这不是又一个“进销存Demo”:SaaS版多仓库多门店ERP源码,真能跑通采购→入库→调拨→销售→财务对账全链路?
你见过多少标着“进销存源码”的项目,解压后只有个登录页、三张静态表格、连商品库存加减都靠前端JS硬算?这次不一样。这是一套真实走通了「采购单生成→供应商收货→多仓自动分配→门店调拨申请→销售出库→成本结转→利润报表」闭环的SaaS架构ERP源码。它不只支持“多个仓库”,而是每个仓库可独立设置负责人、库存预警阈值、出入库审批流;不只支持“多个门店”,而是门店可绑定专属仓库、拥有独立销售价目表、支持POS式扫码开单。更关键的是——它把财务轻量化做进了业务流:每张销售单自动生成应收凭证,采购单触发应付暂估,月底一键结转主营业务成本。适合中小商贸公司快速私有化部署,也适合作为某高校信息管理专业课程设计的完整基线系统。如果你正卡在“多组织架构下库存同步不准”“跨门店调拨无法追溯责任”“销售毛利算不准”这些真实业务痛点上,这份源码不是玩具,是能直接切进去改、能跑通月度结账的生产级起点。
2. 架构拆解与核心模块定位:为什么选这套SaaS结构,而不是单体Spring Boot?
这套系统没用主流微服务框架堆砌,但通过清晰的分层和租户隔离设计,实现了SaaS本质能力。它不是把单体应用简单加个tenant_id字段就叫SaaS——而是从数据库连接池、缓存Key前缀、文件存储路径、打印模板加载逻辑,全部按租户维度做了硬隔离。下面拆解三个最影响落地的关键层:
2.1 数据层:租户ID不是字段,是连接路由规则
系统采用MySQL分库不分表策略(非共享库),每个租户对应独立数据库实例(如 tenant_001、tenant_002)。连接池初始化时,通过TenantContextHolder动态解析HTTP Header中的X-Tenant-ID,路由到对应数据源。关键代码在DataSourceRouter.java中:
public class DataSourceRouter extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { String tenantId = TenantContextHolder.getTenantId(); // 注意:此处不直接返回tenantId,而是映射为预注册的数据源名 return "ds_" + tenantId; // 实际注册时已将 ds_001 -> tenant_001库绑定 } }提示:
TenantContextHolder使用ThreadLocal存储,务必在Filter中清理,否则线程复用会导致租户错乱。源码里TenantFilter.java第47行有finally { TenantContextHolder.clear(); },这是血泪经验——漏掉这句,测试环境一切正常,压测时所有租户数据混写进同一库。
2.2 业务层:多仓库库存调度不是if-else,而是状态机驱动
库存变动(入库、出库、调拨)不直接操作stock_quantity字段,而是先生成StockMovement事件,再由StockStateMachine根据当前状态(如WAITING_APPROVAL→APPROVED→EXECUTED)触发库存扣减/增加。状态流转定义在StockStateConfig.java中:
@Configuration public class StockStateConfig { @Bean public StateMachine<StockStatus, StockEvent> stateMachine() { StateMachineBuilder.Builder<StockStatus, StockEvent> builder = StateMachineBuilder.builder(); return builder .configureConfiguration() .withConfiguration() .autoStartup(true) .listener(stateMachineListener()) .build() .configureState() .withStates() .initial(WAITING_APPROVAL) .state(WAITING_APPROVAL) .state(APPROVED) .state(EXECUTED) .end(EXECUTED) .configureTransitions() .withExternal() .source(WAITING_APPROVAL).target(APPROVED) .event(APPROVE).action(approveAction()) // 执行审批动作 .and() .withExternal() .source(APPROVED).target(EXECUTED) .event(EXECUTE).action(executeAction()); // 执行库存变更 } }这种设计让“调拨单被驳回后库存自动回滚”“部分出库时只扣减对应数量”等复杂逻辑变得可预测、可回溯。某导师曾用这套状态机给学生讲库存事务,学生反馈:“终于看懂为什么ERP不能用UPDATE SET quantity=quantity-1硬写”。
2.3 打印层:Lodop不是插件,是报表引擎的输出适配器
系统没把Lodop当浏览器控件用,而是将其抽象为PrintService接口的实现类。所有报表(采购单、销售单、库存盘点表)统一生成JSON格式的“打印数据模型”,再交由LodopPrintAdapter转换为Lodop脚本。关键在于模板热加载——模板文件(.clo)存于/print-templates/{tenantId}/目录,修改后无需重启服务。PrintTemplateLoader.java中:
public class PrintTemplateLoader { public String loadTemplate(String templateCode) { String path = String.format("print-templates/%s/%s.clo", TenantContextHolder.getTenantId(), templateCode); // 使用ClassLoader.getResourceAsStream避免jar包内路径问题 InputStream is = this.getClass().getClassLoader().getResourceAsStream(path); if (is == null) { // 降级:加载公共模板 path = "print-templates/common/" + templateCode + ".clo"; is = this.getClass().getClassLoader().getResourceAsStream(path); } return IOUtils.toString(is, StandardCharsets.UTF_8); } }注意:Lodop客户端需在Windows系统安装32位运行时(即使系统是64位),否则
getLodop()返回null。源码包里docs/lodop-install-guide.pdf第3页有详细截图步骤,别跳过。
3. 部署实操:从源码到可访问系统,绕不开的五个配置节点
拿到源码后,别急着mvn clean install。这套系统有5个必须手动确认的配置点,漏一个,页面能打开,但采购单提交必报500。我按部署顺序列出来,每步附验证方法:
3.1 数据库初始化:不是执行一个SQL,而是三阶段建库
系统不提供init.sql,因为租户库需动态创建。首次启动时,需手动创建基础元数据库(erp_meta),再填入初始租户信息:
-- 1. 创建元数据库(仅一次) CREATE DATABASE erp_meta CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 2. 在erp_meta中建tenant_info表(源码/docs/ddl/tenant_info.sql已提供) USE erp_meta; CREATE TABLE tenant_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, tenant_id VARCHAR(32) NOT NULL UNIQUE, db_url VARCHAR(255) NOT NULL, -- 如 jdbc:mysql://localhost:3306/tenant_001 db_username VARCHAR(64), db_password VARCHAR(128), status TINYINT DEFAULT 1 -- 1:启用, 0:停用 ); -- 3. 插入第一个租户(示例) INSERT INTO tenant_info (tenant_id, db_url, db_username, db_password) VALUES ('demo001', 'jdbc:mysql://localhost:3306/tenant_demo001', 'root', '123456');验证:启动后访问
http://localhost:8080/actuator/health,若返回{"status":"UP","components":{"db":{"status":"UP"}}},说明元库连通;若报Table 'erp_meta.tenant_info' doesn't exist,就是第一步漏了。
3.2 租户专属库初始化:用脚本生成,别手敲
tenant_demo001库不能空着,需执行docs/ddl/tenant_schema.sql(含商品、仓库、门店等23张表)。但注意:此SQL含CREATE TABLE IF NOT EXISTS,首次执行后,后续租户库可复用同一份SQL。我写了个小脚本避免手误:
#!/bin/bash # init_tenant_db.sh TENANT_ID="demo001" DB_NAME="tenant_${TENANT_ID}" MYSQL_CMD="mysql -h127.0.0.1 -P3306 -uroot -p123456" echo "Creating database ${DB_NAME}..." ${MYSQL_CMD} -e "CREATE DATABASE ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" echo "Importing schema..." ${MYSQL_CMD} ${DB_NAME} < docs/ddl/tenant_schema.sql echo "Done. Verify with: ${MYSQL_CMD} ${DB_NAME} -e 'SHOW TABLES;'"运行后,用mysql tenant_demo001 -e "SELECT COUNT(*) FROM warehouse;"应返回0(空表),证明建库成功。
3.3 application.yml:三个必须改的占位符
src/main/resources/application.yml里有3处{xxx}必须替换,否则启动报IllegalArgumentException: Could not resolve placeholder:
| 占位符 | 替换为 | 说明 |
|---|---|---|
{meta-db-url} | jdbc:mysql://localhost:3306/erp_meta?useSSL=false&serverTimezone=Asia/Shanghai | 元数据库地址,必须带时区参数,否则库存时间戳错乱 |
{redis-host} | 127.0.0.1 | Redis用于分布式锁(如库存扣减防超卖),若不用Redis,需注释掉RedisLockService相关Bean |
{lodop-server-url} | http://localhost:8000 | Lodop打印服务地址,需提前下载Lodop服务端并启动(见3.4节) |
提示:
{meta-db-url}中的serverTimezone=Asia/Shanghai是硬性要求。某次我漏写,导致所有出入库单据时间比系统时间慢8小时,财务对账直接崩盘。
3.4 Lodop服务端:必须独立进程,不能嵌入Java
Lodop打印依赖Windows服务端程序(LodopServices.exe),不能通过Java ProcessBuilder启动。必须:
- 下载Lodop服务端(源码包
/docs/lodop/LodopServices.zip已提供) - 解压到
C:\LodopServices\(路径不能含中文或空格) - 双击
LodopServices.exe启动(托盘图标显示绿色即成功) - 浏览器访问
http://localhost:8000,看到Lodop控制台即成功
验证:启动Java服务后,在浏览器F12 Console中执行
getLodop();,若返回LODOP对象而非null,说明打通。
3.5 前端资源路径:index.html不是入口,是代理目标
index.html里<script src="/static/js/app.js">指向/static/,但源码中静态资源在src/main/resources/static/。Maven打包后,这些文件会进入jar包BOOT-INF/classes/static/,必须通过Spring Boot静态资源规则暴露。检查application.yml是否保留:
spring: web: resources: static-locations: classpath:/static/,classpath:/public/若删了这行,访问http://localhost:8080会404。验证方法:启动后直接请求http://localhost:8080/static/js/app.js,应返回JS内容而非404。
4. 避坑指南:上线前必须扫清的五个高频翻车点
这套源码在某公司实际部署时,团队踩了至少17个坑。下面精选5个最高频、最隐蔽、一踩就导致业务中断的问题,按“现象→原因→解决”给出可立即执行的方案:
4.1 现象:采购单保存成功,但库存没增加,且日志无报错
原因:库存变动事件被发往RabbitMQ,但application.yml中spring.rabbitmq.host未配置,导致消息静默丢失。源码默认开启异步库存更新(stock.async.enabled=true),而RabbitMQ配置块被注释掉了。
解决:
- 取消
application.yml中spring.rabbitmq配置块的注释 - 启动本地RabbitMQ(Docker命令:
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management) - 或临时关闭异步:将
stock.async.enabled设为false,库存更新变为同步(性能略降,但保稳)
4.2 现象:多门店调拨单,A门店发起调拨,B门店收货后,A门店库存未扣减
原因:调拨单状态机中,EXECUTE事件触发时,只更新了目标仓库(B门店)库存,未触发源仓库(A门店)的DECREASE事件。bug在StockStateMachineConfig.java第128行,executeAction()中漏写了sourceWarehouse.decrease(quantity)调用。
解决:
- 定位到
executeAction()方法 - 在
targetWarehouse.increase(quantity);下方添加:
if (movement.getSourceWarehouseId() != null) { Warehouse sourceWarehouse = warehouseService.getById(movement.getSourceWarehouseId()); sourceWarehouse.decrease(movement.getQuantity()); }- 重新编译
stock-service模块
4.3 现象:Lodop打印时提示“未安装Lodop控件”,但服务端明明运行正常
原因:浏览器安全策略阻止了ActiveX控件加载。Chrome/Edge新版默认禁用,必须手动启用。
解决:
- Chrome地址栏输入:
chrome://settings/content/flash - 将“Flash”设为“询问(推荐)”
- 访问系统页面,点击地址栏左侧“盾牌”图标 → “站点设置” → “Flash” → “允许”
- 关键一步:在
index.html的<head>中添加:
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">否则IE兼容模式下Lodop不加载。
4.4 现象:商品多规格(如颜色+尺码)选择后,库存查询始终显示0
原因:规格组合库存存在spec_stock表,但ProductController.java中getStockBySku()方法未关联查询该表,只查了主商品表product的stock_quantity字段。
解决:
- 修改
ProductController.getStockBySku(),增加JPA关联查询:
@Query("SELECT s.quantity FROM SpecStock s WHERE s.sku = :sku") Integer getStockBySku(@Param("sku") String sku);- 在
ProductService中,先查SpecStock,查不到再查主商品库存(兼容无规格商品)
4.5 现象:财务报表中“本月毛利”计算结果与Excel手工核算差几毛钱
原因:成本结转使用加权平均法,但CostCalculationService.java中calculateAverageCost()方法未处理小数精度,Double运算导致累计误差。
解决:
- 将所有金额计算改为
BigDecimal,构造时指定MathContext.DECIMAL128 - 关键修复:
// 原错误写法 double avgCost = totalAmount / totalQuantity; // 正确写法 BigDecimal totalAmountBD = new BigDecimal(String.valueOf(totalAmount)); BigDecimal totalQuantityBD = new BigDecimal(String.valueOf(totalQuantity)); BigDecimal avgCostBD = totalAmountBD.divide(totalQuantityBD, 4, RoundingMode.HALF_UP); // 保留4位小数5. 权限与角色实战:如何用现有RBAC模型,快速配置“门店店长只能看本店数据”
系统权限模型是典型的RBAC(Role-Based Access Control),但比教科书多一层“数据范围控制”。默认角色(ADMIN、WAREHOUSE_MANAGER、STORE_MANAGER)已预置,但STORE_MANAGER角色默认能看到所有门店数据——这显然不符合业务。下面演示如何用现有机制,5分钟内完成“某门店店长只能查本店销售、库存、客户”的配置,不改一行Java代码。
5.1 理解数据范围控制的底层机制:@DataScope注解是开关
所有涉及门店数据的Controller方法(如SaleOrderController.list())都标注了@DataScope(type = DataScopeType.STORE)。这个注解不是摆设,它会触发DataScopeInterceptor.java,在SQL执行前动态拼接AND store_id = ?条件。关键在DataScopeType枚举:
public enum DataScopeType { ALL, // 不加限制 STORE, // 限制为当前用户所属门店 WAREHOUSE, // 限制为当前用户所属仓库 TENANT // 限制为当前租户(已由多租户框架保证) }提示:
STORE类型依赖SysUser表中的store_id字段。如果用户没绑定门店,@DataScope会自动过滤为WHERE 1=0,返回空列表——这是安全兜底,不是bug。
5.2 配置步骤:三步绑定门店与用户
假设要配置用户zhangsan(ID=1001)为store_002门店店长:
步骤1:确认门店已存在
后台管理 → 门店管理 → 新增门店,记录下store_id(如store_002)。若已存在,跳过。
步骤2:给用户分配角色并绑定门店
执行SQL(或用系统“用户管理”页面操作):
-- 1. 给用户分配STORE_MANAGER角色 INSERT INTO sys_user_role (user_id, role_id) SELECT 1001, id FROM sys_role WHERE code = 'STORE_MANAGER'; -- 2. 绑定用户到门店(关键!) UPDATE sys_user SET store_id = 'store_002' WHERE id = 1001;步骤3:验证数据隔离效果
用zhangsan账号登录,进入“销售单查询”页,抓包看请求URL:GET /api/sale-order/list?page=1&size=10
响应数据中,所有销售单的store_id字段均为store_002,且不会出现其他门店单据。此时,若手动修改请求参数加&store_id=store_001,后端仍会忽略该参数——因为@DataScope强制覆盖。
5.3 进阶技巧:一个用户管多个门店?用“门店组”替代
业务中常有区域经理管3个门店。系统不支持一个用户多个store_id(字段是VARCHAR单值),但可通过“门店组”变通:
- 新增角色
REGION_MANAGER,@DataScope(type = DataScopeType.ALL) - 在
sys_role_permission表中,为该角色授权sale-order:list接口 - 在业务代码中,重写
RegionDataScopeInterceptor,根据用户region_code字段查store_group表获取门店列表,动态拼接AND store_id IN ('store_001','store_002','store_003')
我一般会把这种定制拦截器放在
/custom/interceptor/包下,避免和主流程混淆。从那以后我每次接到“XX经理看N个店”的需求,都先翻custom/目录——90%已有现成实现,抄过来改两行SQL就行。希望帮到你。
本文还有配套的精品资源,点击获取