ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL原生ERP源码:事务/触发器/存储过程实现进销存闭环

MySQL原生ERP源码:事务/触发器/存储过程实现进销存闭环

简介:这是一套面向中小企业开发者与Java后端学习者的ERP进销存系统开源源码,聚焦采购、销售、库存及基础财务管理四大核心场景,助力初学者理解企业级业务系统设计逻辑,也支持中高级开发者进行二次开发与架构实践。资源包共1448个文件,涵盖213个Java后端业务类、172个HTML与182个JS/CSS前端页面、74个JSP视图组件、417个PNG图标与界面素材,以及关键的MySQL建库脚本(sql)、配置文件(properties)和权限控制相关XML定义;整体压缩包仅14.26MB,轻量易部署。已有771人下载学习,适合通过真实项目掌握MVC分层结构、数据库事务处理、前后端交互及RBAC权限模型等关键技术点。源码结构清晰,含完整easyui前端框架集成、WeatherProxy等典型代理模块及多套样式资源,便于快速启动与功能拓展。

1. 这不是又一个“Demo级ERP”:一套能跑通采购入库→销售出库→库存实时扣减→财务凭证自动生成的MySQL原生ERP源码

你见过多少标着“ERP源码”的压缩包?解压进去,index.php跳转到登录页,账号密码写死在 config 文件里,点开“库存查询”,返回SELECT * FROM goods—— 没分页、没搜索、没状态过滤,更别提“采购单审核后自动更新库存”这种基础闭环。这套 ERP 进销存系统源码不是那样。它用纯 MySQL 5.7+ 原生特性(存储过程、触发器、事务隔离级别控制)把进、销、存三环节拧成一个可验证的数据流:一张采购入库单提交,库存表stock_detail实时增,stock_summary汇总值同步刷新;一张销售出库单审核通过,库存扣减走的是带FOR UPDATE的行锁事务,同时自动生成一条account_voucher记账凭证,科目、金额、摘要全部由业务动作驱动。它不依赖 ORM 层做逻辑兜底,所有关键状态变更都压在数据库层完成。适合正在从 Excel 管理转向轻量级数字化的中小制造/贸易团队,也适合想搞懂“真实业务系统里事务怎么落地”的后端开发者——不是学概念,是看proc_update_stock_on_sale这个存储过程里怎么用DECLARE CONTINUE HANDLER捕获库存不足异常并回滚。


2. 为什么选 MySQL 原生方案而不是 Laravel + Eloquent?三处硬核设计告诉你边界在哪

2.1 库存扣减必须原子化:为什么不用应用层加锁而用SELECT ... FOR UPDATE

很多团队第一反应是:“我在 PHP 里先查库存,够了再 update 扣减”。这在并发场景下必然翻车。比如两个销售单几乎同时查到某商品有 100 件库存,都判定“足够”,然后都执行UPDATE stock SET qty = qty - 10 WHERE id = 123—— 最终库存变成 80,但实际应为 90。本系统直接放弃应用层判断,所有出库动作统一走存储过程:

DELIMITER $$ CREATE PROCEDURE proc_deduct_stock(IN p_goods_id INT, IN p_qty DECIMAL(10,2)) BEGIN DECLARE v_current_qty DECIMAL(10,2) DEFAULT 0; DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 关键:加行锁,阻塞其他会话对同一商品的读写 SELECT qty INTO v_current_qty FROM stock_summary WHERE goods_id = p_goods_id FOR UPDATE; IF v_current_qty < p_qty THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '库存不足'; END IF; UPDATE stock_summary SET qty = qty - p_qty WHERE goods_id = p_goods_id; INSERT INTO stock_log (goods_id, change_type, change_qty, operator) VALUES (p_goods_id, 'OUT', p_qty, USER()); COMMIT; END$$ DELIMITER ;

参数说明:p_goods_id是商品主键,p_qty是本次扣减数量;FOR UPDATE在SELECT时即锁定该行,后续UPDATE不会脏写;SIGNAL主动抛出业务异常,由调用方(PHP 层)捕获并提示用户。这不是“炫技”,是当你的仓库每天处理 200+ 出库单时,避免月底盘亏的后悔药。

2.2 凭证生成不靠“事后补录”:销售单审核触发器如何联动财务模块

财务凭证不是等月底汇总再手工填,而是每张销售单状态变为approved时,由数据库触发器自动落库。这样保证业务流和财务流时间戳严格对齐,审计时能直接追溯“这笔应收账款是哪张销售单产生的”。

DELIMITER $$ CREATE TRIGGER tr_after_sale_approved AFTER UPDATE ON sale_order FOR EACH ROW BEGIN IF OLD.status != 'approved' AND NEW.status = 'approved' THEN -- 插入主凭证:应收账款 + 主营业务收入 INSERT INTO account_voucher (voucher_no, voucher_date, description, status) VALUES ( CONCAT('VCH-', DATE_FORMAT(NOW(), '%Y%m%d'), LPAD(FLOOR(RAND()*9000+1000), 4, '0')), CURDATE(), CONCAT('销售单 ', NEW.order_no, ' 审核通过'), 'posted' ); -- 获取刚插入的 voucher_id SET @voucher_id = LAST_INSERT_ID(); -- 插入明细:借:应收账款;贷:主营业务收入 + 应交税费 INSERT INTO account_voucher_line (voucher_id, account_code, debit, credit, remark) VALUES (@voucher_id, '1122', NEW.total_amount, 0.00, '客户:' + NEW.customer_name), (@voucher_id, '6001', 0.00, ROUND(NEW.total_amount / 1.13, 2), '销售收入'), (@voucher_id, '22210101', 0.00, ROUND(NEW.total_amount * 0.13 / 1.13, 2), '销项税额'); END IF; END$$ DELIMITER ;

注意:触发器中account_code使用标准会计科目编码(如1122应收账款,6001主营业务收入),非业务字段;ROUND(..., 2)强制保留两位小数,避免浮点误差导致凭证借贷不平衡;voucher_no采用日期+随机4位生成,杜绝并发重复。这套联动机制让财务人员无需再问业务:“这张凭证对应哪张单?”——数据库已用外键sale_order_id在account_voucher_line表里埋好线索。

2.3 为什么坚持不用视图做“实时库存汇总”,而用物化汇总表+触发器维护

有人会说:“用视图CREATE VIEW v_stock_summary AS SELECT goods_id, SUM(qty) FROM stock_detail GROUP BY goods_id不就完了?”不行。视图每次查询都要全表扫描stock_detail(可能百万级记录),且无法建立高效索引。本系统采用“物化汇总表 + 触发器维护”策略:

  • stock_summary表结构极简:goods_id(PK)、qty(当前可用库存)、updated_at
  • 所有入库/出库/调拨操作,均通过INSERT/UPDATE/DELETE触发器实时更新该表
  • 查询库存时SELECT qty FROM stock_summary WHERE goods_id = ?,毫秒级响应
-- 入库触发器示例(简化) CREATE TRIGGER tr_after_purchase_in AFTER INSERT ON purchase_in_detail FOR EACH ROW BEGIN INSERT INTO stock_summary (goods_id, qty, updated_at) VALUES (NEW.goods_id, NEW.qty, NOW()) ON DUPLICATE KEY UPDATE qty = qty + NEW.qty, updated_at = NOW(); END;

关键点:ON DUPLICATE KEY UPDATE替代INSERT ... SELECT ... UNION,避免因并发插入相同goods_id导致主键冲突;updated_at字段为后续“按最后更新时间筛选滞销品”提供依据。这是用空间换时间的务实选择——当你的stock_detail日增 5000 条记录时,视图性能衰减是线性的,而物化表查询是 O(1)。


3. 部署前必做的五项校验:从 MySQL 配置到权限模型,漏一项就卡在登录页

3.1 MySQL 5.7+ 必须启用的三项配置(否则存储过程报错)

系统大量依赖存储过程与触发器,若 MySQL 未开启对应权限或配置,PHP 连接后执行CALL proc_login_check(...)会直接返回空结果或ERROR 1418。需在my.cnf中确认以下配置已生效:

# my.cnf 或 my.ini [mysqld] log_bin_trust_function_creators = 1 sql_mode = STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION max_connections = 500

解释:log_bin_trust_function_creators = 1允许创建存储过程(否则主从复制环境下被拒绝);sql_mode中STRICT_TRANS_TABLES强制事务表报错而非静默截断;max_connections设为 500 是为应对高峰期 20+ 并发单据提交。改完必须重启 MySQL 服务,用SHOW VARIABLES LIKE 'log_bin_trust_function_creators';验证是否为ON。

3.2 数据库用户权限不能只给ALL PRIVILEGES,要精确到对象级

很多部署失败源于权限过大或过小。系统要求数据库用户具备:

  • 对erp_*所有表的SELECT, INSERT, UPDATE, DELETE
  • 对所有存储过程的EXECUTE
  • 对所有触发器的TRIGGER
  • 但禁止DROP,CREATE,ALTER权限(防误删表)

执行授权语句(替换erp_user和erp_db为你的实际用户名/库名):

-- 创建用户(MySQL 5.7) CREATE USER 'erp_user'@'localhost' IDENTIFIED BY 'StrongPass123!'; -- 授予表级权限 GRANT SELECT, INSERT, UPDATE, DELETE ON erp_db.* TO 'erp_user'@'localhost'; -- 授予存储过程执行权 GRANT EXECUTE ON PROCEDURE erp_db.proc_deduct_stock TO 'erp_user'@'localhost'; GRANT EXECUTE ON PROCEDURE erp_db.proc_create_purchase TO 'erp_user'@'localhost'; -- 授予触发器权限(MySQL 5.7+ 需显式授予) GRANT TRIGGER ON erp_db.* TO 'erp_user'@'localhost'; -- 刷新权限 FLUSH PRIVILEGES;

血泪经验:曾有某公司 DBA 为省事直接GRANT ALL ON erp_db.* TO ...,结果开发误执行DROP TABLE stock_summary,整个库存模块瘫痪 4 小时。精确授权不是麻烦,是给系统加一道保险丝。

3.3 PHP 环境必须关闭magic_quotes_gpc并启用mysqli扩展

虽然已是老配置,但仍有老旧服务器残留。若phpinfo()中显示magic_quotes_gpc = On,会导致所有 POST 参数被自动加反斜杠,例如输入密码a'b变成a\'b,登录永远失败。检查并修改php.ini:

; php.ini magic_quotes_gpc = Off extension=mysqli

验证方法:新建test_db.php,内容为:

<?php $link = mysqli_connect("localhost", "erp_user", "StrongPass123!", "erp_db"); if (!$link) die("连接失败: " . mysqli_connect_error()); echo "MySQL 连接成功,版本:" . mysqli_get_server_info($link); ?>

浏览器访问该文件,应输出版本号而非空白页或错误。


4. 避坑:上线前必须排查的四个高频故障点(附现象、原因、解决)

4.1 现象:登录成功后跳转到空白页,浏览器控制台无 JS 报错,Network 显示index.php返回 200 但 HTML 内容为空

原因:PHPshort_open_tag未开启,导致<?开头的模板代码(如<?php echo $user_name; ?>)被当作纯文本输出,PHP 解析器未执行。
解决:编辑php.ini,将short_open_tag = Off改为On,重启 Web 服务。验证:新建test_short.php,内容<? echo "OK"; ?>,访问应输出 OK。

4.2 现象:点击“新增采购单”,弹窗提示“数据库执行失败”,日志中出现SQLSTATE[HY000]: General error: 1305 PROCEDURE erp_db.proc_create_purchase does not exist

原因:导入 SQL 初始化脚本时,未按顺序执行——必须先执行01_create_tables.sql(建表),再执行02_create_procedures.sql(建存储过程),否则过程依赖的表不存在。
解决:删除当前数据库,重新按文件名序号依次执行:mysql -u erp_user -p erp_db < 01_create_tables.sql→mysql -u erp_user -p erp_db < 02_create_procedures.sql。切勿用 phpMyAdmin “批量导入”功能,它不保证执行顺序。

4.3 现象:销售单审核后,库存扣减成功,但account_voucher表无新记录,account_voucher_line为空

原因:MySQL 未启用event_scheduler,而部分凭证生成逻辑依赖事件调度器(如定时结转成本)。但本系统核心凭证由触发器驱动,此问题实为触发器未生效——常见于sql_mode中包含NO_AUTO_CREATE_USER且用户创建语句不规范,导致触发器创建失败。
解决:登录 MySQL,执行SHOW TRIGGERS LIKE 'tr_after_sale_approved';,若无结果,说明触发器未创建。重新执行03_create_triggers.sql,并确保执行前sql_mode已设为前述严格模式。

4.4 现象:多用户同时提交采购入库单,部分单据状态卡在pending,stock_summary库存增量比实际入库总数少

原因:purchase_in_header表的status字段更新未加WHERE id = ? AND status = 'pending'条件,导致 A 用户更新时把 B 用户刚提交、尚未处理的单据状态也覆盖为completed,B 用户的后续库存更新被跳过。
解决:检查proc_create_purchase存储过程中更新单据状态的 SQL,必须形如:

UPDATE purchase_in_header SET status = 'completed', updated_at = NOW() WHERE id = p_header_id AND status = 'pending'; -- 检查影响行数 IF ROW_COUNT() = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '单据状态异常,可能已被他人处理'; END IF;

5. 进阶技巧:用三张表 + 一个函数,实现“动态库存预警”并邮件通知(非轮询)

真正的业务价值不在“能查库存”,而在“库存快没了我马上知道”。本系统不依赖 Crontab 每分钟扫表,而是用 MySQL 事件 + 自定义函数 + 状态标记,实现低开销预警。

5.1 构建预警规则表:支持按商品分类、安全库存阈值、通知方式灵活配置

CREATE TABLE stock_alert_rule ( id INT PRIMARY KEY AUTO_INCREMENT, category_id INT COMMENT '商品分类ID,0表示全部', min_qty DECIMAL(10,2) NOT NULL COMMENT '安全库存下限', notify_method ENUM('email','sms') DEFAULT 'email', is_active TINYINT(1) DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 示例:所有电子类商品,库存低于50台时邮件提醒 INSERT INTO stock_alert_rule (category_id, min_qty, notify_method) VALUES (101, 50.00, 'email');

5.2 创建预警检查函数:返回需预警的商品ID列表(供事件调用)

DELIMITER $$ CREATE FUNCTION fn_get_low_stock_goods() RETURNS TEXT READS SQL DATA DETERMINISTIC BEGIN DECLARE result TEXT DEFAULT ''; DECLARE done INT DEFAULT FALSE; DECLARE v_goods_id INT; DECLARE v_qty DECIMAL(10,2); DECLARE v_category_id INT; -- 游标遍历所有激活的规则 DECLARE cur_rules CURSOR FOR SELECT s.goods_id, s.qty, r.category_id FROM stock_summary s INNER JOIN goods g ON s.goods_id = g.id INNER JOIN stock_alert_rule r ON (r.category_id = 0 OR g.category_id = r.category_id) WHERE r.is_active = 1 AND s.qty <= r.min_qty; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN cur_rules; read_loop: LOOP FETCH cur_rules INTO v_goods_id, v_qty, v_category_id; IF done THEN LEAVE read_loop; END IF; IF result = '' THEN SET result = CONCAT(v_goods_id); ELSE SET result = CONCAT(result, ',', v_goods_id); END IF; END LOOP; CLOSE cur_rules; RETURN result; END$$ DELIMITER ;

说明:函数返回逗号分隔的goods_id字符串(如"123,456,789"),供后续事件解析。DETERMINISTIC声明确保函数结果仅依赖输入,可被 MySQL 优化器缓存。

5.3 创建每日一次的预警事件:只在业务低峰期运行,避免干扰交易

DELIMITER $$ CREATE EVENT ev_daily_stock_alert ON SCHEDULE EVERY 1 DAY STARTS '2024-01-01 02:00:00' DO BEGIN DECLARE v_goods_list TEXT; DECLARE v_email_body TEXT DEFAULT ''; SET v_goods_list = fn_get_low_stock_goods(); IF v_goods_list != '' THEN -- 构造邮件正文(此处简化,实际调用外部邮件函数) SET v_email_body = CONCAT('【库存预警】以下商品低于安全库存:\n', v_goods_list); -- 关键:写入通知日志表,由独立脚本(非MySQL)读取并发送邮件 INSERT INTO system_notification (type, content, status, created_at) VALUES ('stock_alert', v_email_body, 'pending', NOW()); END IF; END$$ DELIMITER ;

为什么不用 MySQL 直接发邮件?因为SMTP发送涉及网络 I/O,会阻塞事件调度器,导致后续事件堆积。正确做法是:MySQL 只负责“记一笔待办”,由一台专用通知服务器(Python 脚本)每 5 分钟轮询system_notification表,取status = 'pending'的记录,调用企业邮箱 SMTP 发送,成功后更新status = 'sent'。这样数据库永远轻量。

从那以后我每次部署新环境,都会在初始化数据库后,立刻执行SELECT fn_get_low_stock_goods();手动验证函数是否返回预期结果,并用SHOW EVENTS;确认ev_daily_stock_alert状态为ENABLED。这三步花不了两分钟,却能避开 80% 的预警失效问题。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进