药品存销数据库设计:从需求分析到存储过程的完整落地
简介这是一份面向数据库课程设计或初学者的完整项目文档围绕药品存销信息管理系统覆盖需求分析、E-R图绘制、关系模型转换、MySQL建库建表及SQL实现全流程。文档以药品、员工、客户和出入库信息为核心实体梳理多对多关系转换、表字段约束、主外键关联并给出各表不少于8条数据的数据录入要求以及按药品编号查询、员工经手情况、客户购买清单、库存动态等典型查询场景。资源共1个docx文件压缩包824KB单文档便于直接阅读与复用。除概念结构设计、逻辑结构设计和物理表结构外还涵盖视图、存储过程、触发器等进阶应用可帮助读者掌握数据库设计规范与MySQL实操技巧用于课程作业或毕业设计参考。文档在CSDN已有8267人学习下载是药品管理类数据库设计中较为完整的样例。1. 药品存销信息管理系统数据库需求分析没做透建表越快返工越多药品存销信息管理系统数据库是绝大多数药店、中小型医药流通企业信息系统的地基把药品基本信息、供应商、采购入库、库存台账、销售出库这一路数据按规矩收进数据表再用视图和存储过程把高频查询和关键事务封装好。不少做这个题目的人建表很快后面反复返工——因为没在需求分析阶段想清楚业务规则。这篇按完整落地顺序来需求分析、数据库设计、数据表、视图、存储过程最后排掉常见权限、性能和流水对账的坑。适合正在做课程设计、毕设以及准备在中小型药店管理系统里落地的开发者。2. 需求分析把采购、库存、销售三条业务线拆成数据字典2.1 业务边界与角色先画流程图再谈表做药品存销管理系统最容易犯的错是上来就想“建哪几张表”。表是结果业务流才是源头。我一般会先用一张流程图把三步走画清楚采购入库、库存管理、销售出库外加一个贯穿始终的效期管理。具体来说业务流程大概是采购员根据缺货情况生成采购单供应商送货后由库管验收验收合格进入库存日常销售时销售员开销售单系统扣减库存每天或每月做盘点与月结。这里至少有四类角色采购员、库管员、销售员、系统管理员每个角色对应一组操作每组操作背后就是一组待落库数据。把流程和角色定下来之后再看数据流。采购环节至少产生供应商信息和采购单据主表明细库存环节产生药品信息、库存台账、库存流水销售环节产生客户信息和销售单据主表明细。头歌需求分析仿真实验里常用的做法就是把这些流程画成用例图和数据流图再逐个环节检查每个角色要做什么、输入什么、输出什么。这套思路对真实项目同样适用我后续做的几个小系统都靠它减少了漏表。需要注意的是药品存销和其他进销存系统的差异在批号和有效期上。同一药品可能有多批库存不同批次价格和有效期不同销售出库时要按先进先出或效期优先原则扣减。这条规则如果没在需求分析阶段写清楚后面做视图、存储过程时就会非常别扭。2.2 数据字典把“系统要什么”落成字段级定义业务梳理完成后进入数据字典阶段。数据字典不是表结构而是把业务语言翻译成数据字段清单。我会先列出核心数据项再定义主键、外键、业务唯一键和常见约束。药品主数据里必须包含药品编号、名称、规格、生产厂家、批准文号、剂型、计量单位。药品编号是业务主键可能来自药监部门的批准文号也可能是企业内部编码工具生成存在表里用 VARCHAR(50)并单独建立唯一索引。名称和规格建议分开存不要合并成一个字符串否则报表统计时要解析规格就很痛苦。供应商数据项要有编号、名称、联系人、电话、地址。有些小系统把供应商和采购单合成一张表看起来省事但一个供应商多次供货后供应商信息会在每张采购单里重复数据冗余和误维护都会来。库存台账是这套系统的核心字段至少要覆盖药品编号、批次号、当前数量、到期日、货位编码、最后更新时间。批次号在采购验收时由采购员手工录入或扫码自动生成它和药品编号一起决定了库存记录的唯一性。销售单则包含单号、销售日期、客户名称或编号、药品编号、批次号、数量、单价、金额。字段时间用 DATE 类型即可金额用 DECIMAL(10,2)不用 FLOAT哪怕小数点只有两位浮点累计误差会让报表对不平。我会同时列一个简单的 CRUD 矩阵每张表谁创建、谁读取、谁更新、谁冻结。例如库存台账只允许库管员写入销售员只能读取销售单只能由销售员创建库管员可以核读出库状态。CRUD 矩阵落在需求文档里后续设计数据库权限和视图权限时有依据。实际项目中这个矩阵还能反推是否需要软删除比如药品停售时是 DELETE 还是给 status 置 0两种做法直接决定后台要不要保留该药品历史记录。2.3 需求分析仿真实验的练习价值用题目训练建模手感很多人觉得需求分析是虚的不如直接写 SQL。我早期也是这个想法直到被分配一个类似药品存销的题目反复改了三次表结构才明白需求不拆透后面全在返工。如果没机会接触真实项目像头歌实践教学平台这类需求分析仿真实验值得快速过一遍——它把用例分析、活动图、数据流图的步骤拆成一个个小题目按步骤提交结果。带着练习时的建模思路回到项目里数据字典会顺手很多。仿真实验还会训练状态建模。比如采购单从草稿到审核、入库、作废的状态流转在数据库里就是一个 status 字段加一组约束。初级设计经常漏掉“作废”状态导致退货场景没法表达。把这个状态图在需求分析阶段画出来后面建表时就会顺手加上 CHECK 约束或枚举避免应用层写入脏状态。需要注意一点仿真实验的答案不是唯一正确答案。网上有人分享头歌需求分析仿真实验答案但它只能帮你验证思路不建议照抄进项目文档。真实项目的需求分析最终要以你手里的业务流程和单据为准。3. 数据库设计从 ER 模型到能落地的建表 SQL3.1 实体识别与关系映射药品、库存、供应商、销售单怎么连需求分析把业务边界框住后下一步是概念结构设计用 ER 模型把实体、属性和关系画出来。这里给一个常见的 ER 划分也是我做过几个存销项目后觉得最稳的一组实体药品主数据 medicine、供应商 supplier、采购单主表 purchase_order、采购单明细 purchase_order_item、库存台账 inventory、库存流水 inventory_log、销售单主表 sale_order、销售单明细 sale_order_item。关系上供应商和采购单是 1:N采购单和采购明细是 1:N药品和采购明细是 1:N药品和库存台账是 1:N一个药品多个批次销售单和销售明细是 1:N。库存流水是核心活动记录它和药品、采购单、销售单都有间接关联通过 ref_no 记录业务单据号而不是滥用外键拆成多条冗余路径。客户实体要单独讨论。很多小系统不建客户表直接用客户名称字符串挂销售单主表上做课程设计时也可以建 customer 表更规范。但真实药店散客极多、频繁新增单独管理反而不如在销售单里存客户姓名和手机号。取舍在于是否存在批量散客之外的稳定客户关系这个我一般按“是否要做会员积分、赊销额度”来判断。3.2 规范化与反规范化取舍批次、效期、保留快照逻辑结构设计要把 ER 模型转成满足范式要求的表结构。这个系统的核心关系至少要做到第三范式3NF消除传递依赖。例如库存台账不能冗余药品名称因为药品名称属于主数据名称变了库存台账不需要跟着变但批次号必须存在因为同一药品批次之间在数量、有效期上相互独立。纯粹 3NF 在药品存销里会碰到一个实际问题业务历史一致性。假设药品复方感冒颗粒改了规格你不能去修改历史销售单里的名称否则报表数据全变了。常见做法是主数据表保持 3NF业务单据表里冗余关键字段做快照比如销售明细同时存 md_id 和 name_at_sale。这样即使药品主数据被修改历史销售记录仍然可追溯。库存批次这里也要做反规范化。库存台账直接存储 batch_no 和 expiry_date不单独拆一张批次表因为批次是依附药品存在的简单属性拆表后每次查询都要多一次 join。但批次需要保证唯一性所以库存台账对 md_idbatch_no 建唯一索引。另外采购明细里应该同时记录采购时的采购价、批号、到期日避免入库时再去主数据里翻。3.3 建表 SQL核心表、约束与初始化顺序逻辑结构定好后开始物理设计默认使用 MySQL 8.0引擎 InnoDB字符集 utf8mb4。主键统一用自增 int业务编号用唯一索引约束。先写药品和供应商这类父表再写单据表避免外键引用不存在的表。CREATE DATABASE IF NOT EXISTS drug_store_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE drug_store_db; -- 药品主数据表 CREATE TABLE medicine ( md_id INT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(50) NOT NULL COMMENT 药品业务编码, name VARCHAR(100) NOT NULL COMMENT 药品名称, spec VARCHAR(50) NULL COMMENT 规格如 10mg*12片, manufacturer VARCHAR(100) NULL COMMENT 生产厂家, approval_no VARCHAR(50) NULL COMMENT 批准文号, dosage_form VARCHAR(20) NULL COMMENT 剂型片剂/胶囊/颗粒, unit VARCHAR(10) NOT NULL DEFAULT 盒 COMMENT 计量单位, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_medicine_code (code) ) ENGINEInnoDB COMMENT药品主数据; -- 供应商表 CREATE TABLE supplier ( sup_id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, contact_person VARCHAR(50), phone VARCHAR(30), address VARCHAR(150), UNIQUE KEY uk_supplier_name (name) ) ENGINEInnoDB COMMENT供应商; -- 采购单主表 CREATE TABLE purchase_order ( po_id INT AUTO_INCREMENT PRIMARY KEY, po_no VARCHAR(30) NOT NULL, sup_id INT NOT NULL, order_date DATE NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1审核 2入库 3作废, operator VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_po_no (po_no), KEY idx_purchase_order_sup (sup_id), CONSTRAINT fk_po_supplier FOREIGN KEY (sup_id) REFERENCES supplier (sup_id) ) ENGINEInnoDB COMMENT采购单主表; -- 库存台账 CREATE TABLE inventory ( inv_id INT AUTO_INCREMENT PRIMARY KEY, md_id INT NOT NULL, batch_no VARCHAR(50) NOT NULL, qty DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 当前库存数量, expiry_date DATE NULL, location VARCHAR(30) NULL COMMENT 货位编号, last_updated DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_inventory_md_batch (md_id, batch_no), CONSTRAINT fk_inventory_md FOREIGN KEY (md_id) REFERENCES medicine (md_id) ) ENGINEInnoDB COMMENT库存台账;参数说明很重要DECIMAL(10,2) 库存数量允许小数适用于按克、按毫升计量的药品如果只按盒、板计量建议改成 INT否则前端下单按整数数量校验时会出错。UNIQUE KEY uk_inventory_md_batch 是防止同一药品批次出现两条台账记录的关键。外键在开发期保留能挡掉大部分脏数据上生产后如果压测发现写入慢再考虑去掉外键由应用层保证引用完整性这是很多一线 MySQL 团队的做法。初始化基础数据要按父表到子表顺序插入INSERT INTO medicine(code, name, spec, manufacturer, approval_no, unit) VALUES (P001, 复方感冒颗粒, 10g*10袋, 某制药有限公司, 国药准字Z101, 盒), (P002, 布洛芬缓释胶囊, 0.3g*20粒, 某药业股份, 国药准字H202, 盒); INSERT INTO supplier(name, contact_person, phone) VALUES (九州通医药批发, 王经理, 13800000000);如果建表后想重来注意删除顺序先删子表 sale_order_item、purchase_order_item、inventory_log再删库存台账、采购单、药品主数据。顺序反了会触发外键约束错误这也是新手最常见的第一道坎。4. 视图与存储过程把查询和事务封装进数据库4.1 视图设计库存余量、销售月报与权限隔离视图就是把一条查询语句存成数据库对象。在药品存销系统里最常用的视图有两个库存余量视图和销售汇总视图。库存余量视图把 inventory 和 medicine 关联让下游直接读药品名、规格、批次、当前数量不用每次 join。CREATE OR REPLACE VIEW v_inventory_stock AS SELECT m.code, m.name, m.spec, i.batch_no, i.qty, i.expiry_date, i.location FROM inventory i JOIN medicine m ON i.md_id m.md_id WHERE i.qty 0;注意WHERE i.qty 0 会把零库存滤掉如果想保留历史批次便于盘点就不要加这个过滤条件。视图建好后权限控制也简单只给销售员 SELECT 这个视图不给 SELECT inventory 表就避免了销售员看到库存成本等敏感字段。销售汇总视图用于按月统计销售情况。CREATE OR REPLACE VIEW v_sales_monthly AS SELECT DATE_FORMAT(s.sale_date, %Y-%m) AS sale_month, m.code, m.name, SUM(si.qty) AS total_qty, SUM(si.amount) AS total_amount FROM sale_order s JOIN sale_order_item si ON s.so_id si.so_id JOIN medicine m ON si.md_id m.md_id GROUP BY sale_month, m.code, m.name;视图能不能加快查询速度MySQL 里默认视图只是查询的封装不缓存数据执行时还是跑底层 SQL性能取决于表结构和索引。Oracle 的物化视图、SQL Server 的索引视图才真正存储数据。很多项目里真正加速靠的是给 sale_order.sale_date、sale_order_item.md_id 加索引视图只是把复杂 join 藏起来让报表层代码更干净。4.2 存储过程入库、出库、月结三个核心事务存储过程的价值在于把多步骤业务操作、事务控制和错误回滚收进数据库避免每个应用端各写一套逻辑也避免应用端漏掉事务提交。入库存储过程处理采购到货后写库存台账和库存流水的动作。DELIMITER // CREATE PROCEDURE sp_purchase_inbound( IN p_po_no VARCHAR(30), IN p_md_code VARCHAR(50), IN p_batch_no VARCHAR(50), IN p_qty DECIMAL(10,2), IN p_expiry_date DATE, IN p_location VARCHAR(30), IN p_operator VARCHAR(50) ) BEGIN DECLARE v_md_id INT; DECLARE v_po_id INT; START TRANSACTION; SELECT md_id INTO v_md_id FROM medicine WHERE code p_md_code FOR UPDATE; IF v_md_id IS NULL THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 药品编码不存在; END IF; SELECT po_id INTO v_po_id FROM purchase_order WHERE po_no p_po_no; IF v_po_id IS NULL THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 采购单不存在; END IF; INSERT INTO inventory (md_id, batch_no, qty, expiry_date, location, last_updated) VALUES (v_md_id, p_batch_no, p_qty, p_expiry_date, p_location, NOW()) ON DUPLICATE KEY UPDATE qty qty VALUES(qty), expiry_date VALUES(expiry_date); INSERT INTO inventory_log (md_id, batch_no, change_type, change_qty, ref_no, create_time, operator) VALUES (v_md_id, p_batch_no, in, p_qty, p_po_no, NOW(), p_operator); UPDATE purchase_order SET status 2 WHERE po_id v_po_id; COMMIT; END// DELIMITER ;这段代码的关键点SELECT ... FOR UPDATE 给药品行加锁防止两个并发入库同时初始化同一批次INSERT ... ON DUPLICATE KEY UPDATE 利用 md_idbatch_no 唯一键实现有则累加、无则插入。参数里 p_qty 的 DECIMAL(10,2) 要和 inventory.qty 类型一致否则传字符串时隐式转换可能导致索引失效。月结存储过程是另外一类典型需求生成月度报表并把结果写进统计表相当于手动做一次性物化视图。DELIMITER // CREATE PROCEDURE sp_monthly_settlement(IN p_month DATE) BEGIN DECLARE v_start_date DATE; DECLARE v_end_date DATE; SET v_start_date DATE_FORMAT(p_month, %Y-%m-01); SET v_end_date DATE_ADD(v_start_date, INTERVAL 1 MONTH); INSERT INTO monthly_report(stat_month, md_id, total_qty, total_amount) SELECT DATE_FORMAT(sale_date, %Y-%m) AS stat_month, md_id, SUM(qty), SUM(amount) FROM sale_order_item WHERE sale_date v_start_date AND sale_date v_end_date GROUP BY md_id ON DUPLICATE KEY UPDATE total_qty VALUES(total_qty), total_amount VALUES(total_amount); END// DELIMITER ;这里用 v_start_date sale_date v_end_date 的半开区间而不是 BETWEEN能避开最后一天的 00:00:00 时间边界问题。ON DUPLICATE KEY UPDATE 让同一个月重复跑月结不会产生重复行只会覆盖更新适合月底多次跑批。4.3 出库逻辑与存储过程的边界控制销售出库比入库复杂因为要按效期优先从多个批次里扣减。我会用一个游标逐批次检查扣完一个批次再扣下一个并且把每次扣减量写进库存流水。核心逻辑用一段示例体现DELIMITER // CREATE PROCEDURE sp_sale_outbound( IN p_so_no VARCHAR(30), IN p_md_code VARCHAR(50), IN p_qty DECIMAL(10,2), IN p_operator VARCHAR(50) ) BEGIN DECLARE v_md_id INT; DECLARE v_total_qty DECIMAL(10,2); DECLARE v_inv_id INT; DECLARE v_batch_no VARCHAR(50); DECLARE v_batch_qty DECIMAL(10,2); DECLARE v_remaining DECIMAL(10,2); DECLARE done INT DEFAULT 0; DECLARE cur_batches CURSOR FOR SELECT inv_id, batch_no, qty FROM inventory WHERE md_id v_md_id AND qty 0 ORDER BY expiry_date IS NULL ASC, expiry_date ASC, inv_id ASC FOR UPDATE; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done 1; START TRANSACTION; SELECT md_id INTO v_md_id FROM medicine WHERE code p_md_code FOR UPDATE; SELECT COALESCE(SUM(qty), 0) INTO v_total_qty FROM inventory WHERE md_id v_md_id; IF v_total_qty p_qty THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 库存不足; END IF; SET v_remaining p_qty; OPEN cur_batches; batch_loop: LOOP FETCH cur_batches INTO v_inv_id, v_batch_no, v_batch_qty; IF done THEN LEAVE batch_loop; END IF; SET deduct LEAST(v_batch_qty, v_remaining); UPDATE inventory SET qty qty - deduct WHERE inv_id v_inv_id; INSERT INTO inventory_log (md_id, batch_no, change_type, change_qty, ref_no, create_time, operator) VALUES (v_md_id, v_batch_no, out, deduct, p_so_no, NOW(), p_operator); SET v_remaining v_remaining - deduct; IF v_remaining 0 THEN LEAVE batch_loop; END IF; END LOOP; CLOSE cur_batches; INSERT INTO sale_order(so_no, sale_date, status, operator) VALUES (p_so_no, CURDATE(), 1, p_operator); COMMIT; END// DELIMITER ;ORDER BY expiry_date IS NULL ASC, expiry_date ASC 的意思是把没有到期日的批次放到最后其他按到期日从近到远排实现效期优先出库。游标逐批次更新会比一条 UPDATE 多几次数据库交互但胜在逻辑清晰并且每一批扣减都能准确写流水对账时不会出现数量差额。5. 常见问题与排查视图权限、存储过程性能、流水对账5.1 创建视图权限不足grant 与 DEFINER 陷阱现象连接账号有 SELECT 权限执行 CREATE VIEW 时报CREATE VIEW command denied to user。原因MySQL 里创建视图除了 SELECT 权限还需要 CREATE VIEW 权限。另一个更隐蔽的问题是创建视图时可以指定 DEFINER如果写成别的账号后续查询该账号无底层表权限时会报access denied让你以为是视图本身坏了。解决把创建视图需要的权限一次性授予。GRANT SELECT, CREATE VIEW ON drug_store_db.* TO app_user%; FLUSH PRIVILEGES;对已有视图检查 DEFINER 是否正确SELECT TABLE_NAME, DEFINER FROM information_schema.VIEWS WHERE TABLE_SCHEMA drug_store_db;检查结果显示的 DEFINER 账号必须存在且有底层表查询权限。如果 DEFINER 指向一个已删除的旧账号即使当前连接用户有权限查询视图时也会报权限错误。5.2 存储过程执行慢统计信息过期与执行计划偏移现象同一个存储过程在数据量少的测试库上秒出迁移到生产库几百万行数据后突然要几十秒单独把存储过程里的 SQL 拎出来执行却很快。原因MySQL 优化器依赖表统计信息生成执行计划生产库数据分布变化后统计信息没更新优化器可能选择错误索引。参数嗅探则会让优化器针对第一次传入的参数值制定计划后续遇到不同值分布时计划不合理。解决先更新统计信息再看执行计划。ANALYZE TABLE medicine, sale_order_item, sale_order; EXPLAIN SELECT ... FROM sale_order WHERE status 1 ORDER BY sale_date DESC;如果 EXPLAIN 显示没走预期索引可以在 SQL 里显式 FORCE INDEX或者把存储过程中的复杂搜索条件改成动态 SQL。我在项目里见过不少团队疯狂加索引却效果不明显根因其实是统计信息旧了所以排查顺序一定是 ANALYZE 优先于加索引。5.3 库存流水对不上事务边界和日志记录缺失现象月末对账时库存台账的 qty 和库存流水 SUM 结果不一致或者出现负数库存。原因三种情况最常见。一是并发扣减没锁行两个会话同时读到同一库存数二是业务操作拆成多步中途失败没回滚三是有人直接改 inventory 表而不是写流水日志缺失导致差异无法解释。解决把扣库存和写流水放进同一个事务并用 SELECT ... FOR UPDATE 锁行所有库存变动必须走统一入口比如存储过程或同一事务内的两条 SQL对账时用一条 SQL 先找出差异行。SELECT i.md_id, i.batch_no, i.qty AS table_qty, (SELECT COALESCE(SUM( CASE WHEN l.change_type in THEN l.change_qty WHEN l.change_type out THEN -l.change_qty END), 0) FROM inventory_log l WHERE l.md_id i.md_id AND l.batch_no i.batch_no) AS log_qty FROM inventory i HAVING table_qty log_qty;如果返回结果不为空说明台账或流水至少一边出了问题。优先排查是否有绕过存储过程的直接 UPDATE以及是否有人手工删过流水记录。5.4 视图嵌套过深与 41 视图混淆现象有人为了复用查询把多个视图叠在一起外层视图再做 join 和 where结果查询慢到无法用还有人把软件架构里的 41 视图模型和数据库视图混为话题以为两者要一一对应建设。原因MySQL 视图默认不缓存结果嵌套越多SQL 展开越庞大优化器生成执行计划的时间线和 join 顺序越不可控。41 视图是逻辑视图、开发视图、进程视图、物理视图加场景模型的架构工具和数据库里的视图对象是两套体系不需要刻意对应。解决数据库视图最多套两层再往上直接写独立的查询 SQL 或拆成临时表。遇到慢查询先 EXPLAIN 看底层表索引不要靠视图再包一层来裁剪字段。软件架构的 41 视图归入设计文档数据库里只放必要视图。6. 上线前必做的三类校验脚本与一点复盘心得我每次把一个药品存销数据库交付出去之前会跑三类脚本这三类脚本帮我挡掉了大部分低级问题也适合你验收自己的建库成果。第一类检查孤儿外键。把所有子表 LEFT JOIN 父表找出 NULL 行比如销售单主表左连接客户表后找不到客户。理论上数据库外键能挡住一部分但有些人因为开发期性能问题去掉了外键就得靠这类脚本兜底。也可以在 information_schema 里遍历表把每张子表跑一遍。第二类效期预警。药品存销比普通进销存多一条生命线90 天内到期的批次要列出来交给运营处理。SELECT m.code, m.name, i.batch_no, i.expiry_date, DATEDIFF(i.expiry_date, CURDATE()) AS days_left FROM inventory i JOIN medicine m ON i.md_id m.md_id WHERE i.qty 0 AND i.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) ORDER BY i.expiry_date;第三类台账与流水一致性直接用 5.3 里的对账 SQL 跑一遍。我几乎每次跑这三类脚本都能发现至少一两处问题多数出在漏写流水、批号大小写不一致或者导入历史数据时过期批次没处理。复盘自己做过的几个存销项目最大的收获不是 SQL 技巧而是先梳理需求再设计表结构的顺序。需求分析没做透就建表后面每次改动都要迁移数据成本翻倍。视图和存储过程在中小团队里非常实用但要控制数量别把数据库变成只有你能看懂的流水线。现在我的习惯是每张表都有创建人、更新人和时间戳每次对账发现问题先查流水再查代码最后才怀疑数据库。这套习惯帮我少加了很多夜班也希望帮到你。本文还有配套的精品资源点击获取