1. 一个药店的账本困局,藏着整套系统的设计起点
我接过不少“学生课程设计”性质的项目委托,但“药店管理系统(源码+数据库+文档)”是我见过最容易被低估的一个。很多同学拿到题目,第一反应是“不就是进销存吗,简单”,结果真建模的时候才发现,药品根本不是普通商品——它有批号、有有效期、有拆零单位、有处方药与非处方药的业务区分,同一个药品名能对应好几个完全不同的库存条目。如果一个项目里连“批号”这个字段都没出现,那这份源码大概只能算一个“商品管理系统”,离“药店管理系统”还差得很远。
这篇博文我想从一套完整、能跑、能交差、也能讲清楚的药店管理系统出发,把我在设计这套系统时的核心拆解过程、数据库建模逻辑、关键业务代码实现以及部署时的坑都写出来。内容覆盖源码结构、数据库脚本和配套文档的实际用法,适合正在做课程设计的学生、想快速理解进销存系统本质的自学者,以及需要一套可二次开发模板的初级开发者。
老规矩,先交代背景:这套系统的技术栈是Spring Boot + MyBatis + MySQL + Vue(或JSP,看交付版本),前端我习惯用Vue + Element UI,后端提供RESTful接口。你说它有多复杂?谈不上。但一个“药店管理系统”的复杂度和含金量,恰恰不体现在框架炫技上,而是体现在业务边界怎么划、数据怎么建模、哪些逻辑必须靠数据库约束兜底这些“看不见但会被答辩老师一眼看穿”的地方。
下面按我的设计顺序来聊。这样写的好处是:你能顺着我的思路,而不是顺着某个“万能模板”的思路,来理解这套系统为什么长成这样。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 药店管理系统的功能边界:别把进销存做成大杂烩
2.1 先想清楚“药店”和“超市”管理系统的本质区别
我刚拿到这个题目时,第一步不是写代码,而是把药店的业务特征列了一遍。药店和普通便利店最大的区别有三点:
- 药品有有效期和批号,而且这是合规红线。一盒药过期了还在货架上,出了事故不是赔钱的问题,是资质都可能被吊销的问题。所以系统必须支持批号级追踪,而不是简单地维护一个“商品ID + 库存数量”。
- 药品存在“一药多名”和“同名多规格”的现实混乱。同一款阿莫西林,不同厂家、不同规格(0.25g和0.5g)、不同批准文号,实际上是不同商品。系统里不能只用名称做唯一标识,必须建立“药品字典 + 批次库存”的层级。
- 销售环节有管制要求。处方药需要登记、含麻黄碱类复方制剂需要限量销售、拆零药品需要记录批号信息。这些细节看似是“合规负担”,但真正落进系统里,其实只是“几个字段和几条拦截逻辑”的事,但缺了它们,答辩时老师一问就露馅。
所以我在设计需求文档时,把整个系统拆成了五个核心模块:药品信息管理、库存管理(含批号和效期)、采购入库、销售收银、统计报表,外加一个辅助的权限登录和操作日志。这套边界划分既不会让一个课程设计的体量膨胀到失控,也能覆盖药店业务的所有关键节点。
2.2 用一张功能清单锁住系统边界
我在写文档时会把功能模块画成表格,这个习惯来自早期做项目吃过的亏——没写清楚边界就动手,做着做着就开始“加功能”,最后变成了一个四不像。下面是这套系统最终的功能清单,可以作为你设计时的参考:
| 模块 | 核心功能 | 关键设计点 |
|---|---|---|
| 登录与权限 | 用户登录、修改密码、角色区分 | 管理员/店长/收银员三类角色,前端按角色隐藏菜单 |
| 药品信息管理 | 药品字典增删改查、按名称/厂家/批准文号搜索 | 药品唯一标识与批号分离,药品状态启用/停用 |
| 供应商管理 | 供应商信息维护、按供应商查询药品 | 与采购入库联动 |
| 采购入库 | 创建采购单、验收入库、批次生成 | 入库时必须录入生产日期、有效期至、批号、采购价 |
| 库存管理 | 库存查询、库存预警、批次明细、报损出库 | 同一药品多批次分开显示;预警按到期前30/90天两档 |
| 销售收银 | 收银台、处方药拦截、库存扣减、销售小票 | 结账事务保证库存与销售记录原子操作 |
| 统计报表 | 日/月销售报表、利润统计、滞销品排行 | 按药品、分类、时间维度聚合 |
| 系统日志 | 操作日志、登录日志 | 记录关键操作的用户、时间、行为 |
这个功能清单不是我拍脑袋定的,而是对着GSP(药品经营质量管理规范)里对计算机管理系统的基础要求逐条映射过来的。GSP要求药品经营企业建立符合质量管理要求的计算机系统,具备包括供货单位、经营品种、出入库记录、销售记录等基础数据的管理能力,并保证数据可追溯。这套系统虽然只是课程设计,但模型的骨架是按这个思路去靠的。
3. 数据库建模:药品设计的难在“批号”和“数量的多单位换算”
3.1 核心表结构:从ER图到建表SQL
数据库是整个系统的灵魂。我见过太多课程设计把药品表设计成一张“商品表”,字段就是商品ID、名称、价格、库存数量。这种设计在大学作业里也许能过,但一旦要往批号层面走,立刻崩。原因很简单:同一药品不同批次的进价可能不同,有效期可能不同,你不能用一个库存数量字段把两个批次混在一起算。
我这套系统的核心表设计是这样分的:
- sys_user:用户表,字段有id、username、password(BCrypt加密)、real_name、role(区分管理员/收银员)、status。
- drug_info:药品字典表,存储药品的基础信息(药品名称、通用名、规格、厂家、批准文号、剂型、类别[处方/非处方]、零售价、会员价、库存下限、是否拆零)。
- drug_batch:批次库存表,每条记录对应“某个药品在某个批次的库存情况”,字段有id、drug_id、batch_no、supplier_id、production_date、expire_date、quantity、purchase_price、status。
- supplier_info:供应商表。
- purchase_order 和 purchase_order_item:采购入库单主表和明细表。
- sale_order 和 sale_order_item:销售单主表和明细表。销售明细表里直接冗余存了当时的售价、药品名称、批号,因为销售记录是追溯凭证,不能等药品字典被改了之后跟着变。
- inventory_log:库存流水表,每一笔入库、出库、报损、退货都写一条流水,这是追溯和答辩的加分项。
3.2 为什么“批号”不能躺在药品表里
这里详细说一下。你可以把“药品字典”类比成一个“人”的档案库,档案里只记录“他是谁”;而“批次库存”则是“他的每一笔资产明细”,资产明细要跟着资产的实际状态走。如果非要把“批号”作为一个字段塞进药品表,那同一个药品来两批货、两批价格不一样的时候,你的表就不知道该写哪批的价格了。
所以我的设计是:
drug_info里存的是零售价、会员价、库存下限等“与批次无关”的销售层面信息。drug_batch里存的是采购价、批号、生产日期、有效期至、当前批次库存数量等“与批次强相关”的进货层面信息。
两张表通过 drug_id 关联。一个药品当前的总库存,等于它所有批次quantity之和。这样做的好处非常明显:查询“库存还有多少”时,可以按批次分组展示;查询“某个批次还有多少效期”时,也可以快速定位。 而且做采购入库时,新增一行批次记录就行了,不会污染药品主数据。
3.3 数量和单位的双重处理:最小单位与包装单位
药店的另一个坑是单位换算。一盒阿莫西林,整盒销售用“盒”,但拆零时按“粒”卖。库里如果只存一个单位,拆零销售要么没法做,要么做出来一团糟。
我的处理方式是:为药品表增加两个字段,pack_unit(包装单位,如盒)和min_unit(最小单位,如粒),加一个conversion(换算比例,如1盒=24粒)。库存表里统一按最小单位(粒)来存数量。收银台卖出一盒,实际扣减的库存是24粒;如果买5粒,就扣5粒。所有报表里的库存数量,展示时再按需换算回盒/粒。
有人觉得这个设计“过度”,但药店实际上是高频拆零的行业。如果答辩时老师问你“一粒一粒卖怎么处理”,你能直接给出这个方案,就是一个很实际的加分项。
3.4 数据库脚本里的字符集与索引:两个容易翻车的细节
数据库文档里除了表结构,我还会额外给两条建议,因为真按我的脚本跑的时候,90%的报错都出在这两个地方:
第一,字符集统一用utf8mb4,不是utf8。药品名称里有“腚”“痤”这类生僻字,还有少量繁体商品名,utf8在MySQL里是3字节编码,遇到4字节的扩展字符就会报错。建库语句直接用:
sql复制CREATE DATABASE pharmacy_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
第二,drug_batch表一定要建联合索引。因为业务查询几乎总是“按药品+批次”来查的:
sql复制ALTER TABLE drug_batch ADD INDEX idx_drug_expire (drug_id, expire_date);
ALTER TABLE drug_batch ADD INDEX idx_drug_batch (drug_id, batch_no);
不建这个索引,数据量小的时候感觉不到,等演示库里塞了上万条批次记录,按药品查批次明细时,查询会肉眼可见地慢。答辩现场跑出一个3秒的查询,是很尴尬的。
4. 核心业务逻辑:从采购入库到销售出库的实现细节
4.1 采购入库:录入批号,拦截过期药品
采购入库是批号数据的源头。页面上的表单除了选择药品、供应商、数量和采购价,还必须有“生产日期”“有效期至”“批号”三个字段。后端在入库接口里做了两个校验:
- 有效期不能早于当前日期。如果有人录入了一批已经过期的药,直接拒绝。
- 采购价必须大于0。看似废话,但很多人在测试时会填0,如果不拦,后面毛利统计全是错的。
下面是Service层的一段核心逻辑,我用MyBatis的操作方式写出来,尽量保持Java代码的可读性:
java复制@Transactional(rollbackFor = Exception.class)
public PurchaseOrder createPurchase(PurchaseRequest req) {
// 1. 创建主单
PurchaseOrder order = new PurchaseOrder();
order.setOrderNo("PO" + System.currentTimeMillis());
order.setSupplierId(req.getSupplierId());
order.setOperatorId(LoginUtil.getCurrentUserId());
order.setTotalAmount(req.getItems().stream()
.mapToDouble(i -> i.getPurchasePrice() * i.getQuantity())
.sum());
purchaseOrderMapper.insert(order);
// 2. 逐条写入明细和批次
for (PurchaseItemReq item : req.getItems()) {
if (item.getExpireDate().isBefore(LocalDate.now())) {
throw new BusinessException("药品[" + item.getDrugName() + "]已过期,禁止入库");
}
if (item.getPurchasePrice() <= 0) {
throw new BusinessException("采购价必须大于0");
}
// 插入明细
PurchaseOrderItem orderItem = new PurchaseOrderItem();
orderItem.setOrderId(order.getId());
orderItem.setDrugId(item.getDrugId());
orderItem.setBatchNo(item.getBatchNo());
orderItem.setQuantity(item.getQuantity());
orderItem.setPurchasePrice(item.getPurchasePrice());
purchaseOrderItemMapper.insert(orderItem);
// 写入批号库存
DrugBatch batch = new DrugBatch();
batch.setDrugId(item.getDrugId());
batch.setBatchNo(item.getBatchNo());
batch.setSupplierId(req.getSupplierId());
batch.setProductionDate(item.getProductionDate());
batch.setExpireDate(item.getExpireDate());
batch.setQuantity(item.getQuantity());
batch.setPurchasePrice(item.getPurchasePrice());
drugBatchMapper.insert(batch);
// 3. 写库存流水
inventoryLogMapper.insert(new InventoryLog(item.getDrugId(), batch.getId(),
"PURCHASE", item.getQuantity(), "采购入库"));
}
return order;
}
注意两个细节:@Transactional保证主单、明细、批次、流水四步操作同生共死,任何一步抛异常,前面已写入的数据全部回滚,不会出现“单子建了但库存没加”的脏数据;流水表里记录批次ID,让任何一条库存变化都能追溯来源。
4.2 销售收银:扣库存必须带条件,防止超卖和过期销售
收银台看起来是“选药品、算总价、收款”,但里面真正要处理的逻辑是两件事:
第一,扣库存必须按批次先进先出(FIFO)。 我写了一个findFifoBatches方法,按expire_date升序找出该药品的未过期批次,逐批扣减。先到期的批次优先出库,这是药房实务的基本要求,也是GSP的追溯原则。
java复制public void deductStock(SaleOrder order) {
for (SaleItemReq item : order.getItems()) {
int remain = item.getQuantity(); // 注意这里已经换算成最小单位
List<DrugBatch> batches = drugBatchMapper.findValidBatches(item.getDrugId());
if (batches.isEmpty()) {
throw new BusinessException("药品[" + item.getDrugName() + "]库存不足");
}
for (DrugBatch batch : batches) {
if (remain <= 0) break;
int deduct = Math.min(remain, batch.getQuantity());
// 条件更新:只有批号匹配且余量足够才扣减
int rows = drugBatchMapper.deductQuantity(batch.getId(), deduct);
if (rows == 0) {
throw new BusinessException("药品[" + item.getDrugName() + "]库存不足");
}
remain -= deduct;
saleOrderItemMapper.insert(...); // 记录销售明细,含批号
}
if (remain > 0) {
throw new BusinessException("药品[" + item.getDrugName() + "]库存不足");
}
}
}
第二,处方药销售必须拦截。 很多初学者把“处方药”做成前端一个按钮的开关,实际上后台接口必须二次校验。因为前端控制只是UI层面的遮罩,直接调接口就能绕过。我在后端写了一个简单的校验切面:如果drug_info.category == 'RX',则结算请求里必须带上prescription_no(处方编号),没有就抛异常拒绝。这样即使有人绕过页面直接POST,也会被拦在接口层。
4.3 效期预警:一个SQL搞定“30天内过期”和“已过期”
效期预警是药店系统的招牌功能之一。很多人一上来就想着写定时任务,“每天跑批扫描过期药品”,但课程设计阶段完全没必要这么重。一个查询接口就能实现实时预警:
sql复制SELECT d.drug_name, d.specification, b.batch_no,
b.expire_date, b.quantity,
CASE
WHEN b.expire_date < CURDATE() THEN '已过期'
WHEN b.expire_date <= DATE_ADD(CURDATE(), INTERVAL 30 DAY) THEN '30天内到期'
ELSE '90天内到期'
END AS warn_level
FROM drug_batch b
LEFT JOIN drug_info d ON b.drug_id = d.id
WHERE b.status = 1
AND b.expire_date <= DATE_ADD(CURDATE(), INTERVAL 90 DAY)
ORDER BY b.expire_date ASC;
这个SQL为什么不去用存储过程、不用定时任务?因为课程设计阶段,数据量根本不足以支撑“定时任务”的复杂度,而一个带CASE WHEN的查询就能把“已过期、30天内、90天内”三个状态一次算出来。页面加载时调一次,库存预警模块的实时性就足够了。真要做定时提醒,后续可以在Spring Boot里加@Scheduled,逻辑也是一样,只是触发方式变了。
4.4 权限登录:BCrypt加密 + 角色菜单
用户管理模块最容易做成“登录后所有功能都能点”,这在答辩时会被问住:“如果收银员能随便改药品价格,你怎么防范?”所以权限设计我还是认真做了两层:
- 密码存储用BCrypt加密。Spring Security自带的
BCryptPasswordEncoder就用起来很顺手,不要明文存密码,这个问题实在太基础,但又总有人踩。 - 角色分三类:ADMIN、MANAGER、CASHIER。后端在接口上加
@PreAuthorize注解,前端根据角色动态渲染菜单。比如:
java复制@PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")
@PostMapping("/api/drug")
public Result saveDrug(@RequestBody DrugInfo drug) { ... }
收银员角色的token没有管理员权限,即使手动拼接API路径,也会被Spring Security拦截返回403。这种“后端拦截”的权限设计,比单纯隐藏按钮要可靠得多。
5. 这套源码怎么跑:环境、目录结构与常见报错
5.1 拿到源码后的标准操作顺序
我交付的项目包里会带三样东西:源码、数据库脚本(pharmacy.sql)、部署文档(README.md)。很多同学拿到别人的源码第一反应是“直接双击打开”,结果中文乱码、依赖缺失、端口被占,三分钟就放弃了。我先给一个标准操作顺序:
- 装好JDK 8+、Maven 3.6+、MySQL 5.7+,Node.js 14+(前端用Vue时)。
- 用Navicat或命令行执行
pharmacy.sql脚本,确认数据库和表都创建成功。 - 修改后端配置文件
application.yml里的数据库账号密码。这一步90%的人会忘记改,然后报Access denied for user。 - 后端启动:在项目根目录执行
mvn spring-boot:run,看到“Started Application in x seconds”表示成功。 - 前端启动(Vue版本):
cd frontend && npm install && npm run dev,浏览器访问localhost:8080。 - 用管理员账号登录,先建一个供应商,再建药品,再走一遍采购入库。很多人一上来直接就点“销售”,结果没有库存数据,页面空荡荡,还以为系统坏了。
5.2 部署时最容易踩的四个坑
我在调试这套系统时,以及帮大量读者排查问题时,遇到频率最高的场景集中在这四个地方:
坑一:端口冲突。 后端默认8080,前端Vue默认8081,但很多人本机装了别的服务占用了8080,启动直接失败。解决方式很简单,在后端application.yml里改:
yaml复制server:
port: 8088
然后前端的src/utils/request.js里把baseURL对应的后端地址一起改掉。
坑二:MySQL版本语法差异。 脚本如果用了utf8mb4和DATETIME的默认值写法,MySQL 5.6以下可能会报语法错误。课程设计现在基本都用MySQL 8.0了,如果报错,优先确认你的MySQL版本,而不是先去改脚本。
坑三:Maven依赖下载太慢。 国内网络环境下,建议在settings.xml里配阿里云镜像,不配的话,mvn spring-boot:run可能卡在下载依赖上半小时,你会以为程序死了,其实它只是在下包。
坑四:数据库时区问题。 MySQL 8.0默认时区和本地时区不一致,连接串后面最好带上这些参数:
yaml复制url: jdbc:mysql://localhost:3306/pharmacy_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
少了serverTimezone,报错信息会直接指向“The server time zone value”这样的描述,搜一下就知道是时区问题。
5.3 文档的写法:不止是给你交差用的
很多课程设计文档最后都写成了“系统背景-技术介绍-功能实现-总结”的八股文,答辩老师看到想睡觉。我这份项目配套文档会有意识地加入**“关键业务逻辑说明”**部分,专门讲批号库存、效期预警、库存流水这三个设计是“怎么想出来的”,以及“如果扩展要怎么做”。这样做的过程中,自己的思路会越来越清晰。文档不只是给老师看的,也是给自己未来二开留的指南。哪怕是三个月后回来看自己代码,你能靠这份文档十分钟内想起整个项目结构,它才真正算合格。
6. 进阶改造建议:把这套课程设计升级成“能拿得出手的作品”
6.1 增加一个“库存盘点”模块,补上业务闭环
目前这套系统缺了一个在真实药店里非常重要的功能:盘点。盘点的作用是发现账面库存和实物库存的差异,调整库存并记录原因。加这个模块的SQL改造很小,核心就是新增一张stocktake_record表,然后做一个功能,填写药品ID、账面数、实盘数、差异原因,保存后自动生成一条库存调整流水,并同步修改drug_batch里的数量。这个模块能体现你对“实际业务中的对账”有概念,答辩时提一句“库存流水支持追溯盘点差异”,老师会明显注意到。
示例表结构可以参考:
sql复制CREATE TABLE stocktake_record (
id INT PRIMARY KEY AUTO_INCREMENT,
drug_id INT NOT NULL,
batch_id INT NOT NULL,
book_quantity INT NOT NULL,
actual_quantity INT NOT NULL,
diff_quantity INT NOT NULL,
reason VARCHAR(255),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
6.2 预留医保接口和会员积分
真实药店系统一定会对接医保结算,课程设计阶段不需要真接,但可以在drug_info表里预留insurance_type字段,标记“医保甲类/乙类/自费”,在销售表里预留insurance_pay和cash_pay两个金额字段。另外会员积分可以通过member表加两个字段points和level来实现,结算时按比例累加积分,后面还能扩展“积分抵现”。
这些改动都是“加字段+加几个接口”的体量,但对系统完整度是质的提升。答辩时如果你能说出“这里预留了医保字段,方便后续对接”,这比说“系统已经完成了全部功能”要可信得多。
6.3 前端交互上值得补的两个体验点
一个是药品名称的模糊搜索支持拼音首字母。药店收银员敲键盘时间紧张,要是能用“abl”搜出“阿莫西林胶囊”,体验会好很多。实现上可以在drug_info表加一个pinyin_code字段,药品新增时自动生成首字母缩写,查询时用LIKE匹配。另一个是收银台支持快捷键:回车结算、F5挂单、F6取单。这两个小功能代码量不大,但能让你的作品看起来“像个真东西”。
7. 从项目到作品:数据、文档和源码的整理习惯
最后聊聊整个交付物的组织方式。源码、数据库、文档这三样东西,如果只是随手丢在一个压缩包里,页面和接口都起不来,那它只是一堆代码,不是项目。我的整理习惯是一个清晰的目录结构,哪怕别人第一次看,也能按README顺序跑起来:
text复制pharmacy-system/
├── README.md
├── docs/
│ ├── 需求说明.md
│ ├── 数据库设计.md
│ ├── 接口文档.md
│ └── 部署说明.md
├── sql/
│ └── pharmacy.sql
├── backend/
│ ├── pom.xml
│ └── src/main/java/...
└── frontend/
├── package.json
└── src/...
数据库设计文档里,我会配上表关系说明和核心字段的注释。接口文档用Markdown表格列出来,写清楚“请求方式、URL、参数、返回示例”。这样一整套东西交出去,无论是课设答辩、作品集展示,还是给团队里的其他同事接手,别人都不需要来问你“这个表是干什么的”“这个接口要传什么参数”。
我个人经验是:做这种“系统类”项目,最亏的不是技术不够,而是把大量时间花在了写重复的CRUD上,却忽略了业务里那些真正影响数据准确性的细节。一个系统最终能不能打动看的人,往往是看它有没有把药品批号、效期预警、操作日志这些“角落里的事情”做干净。这些地方做到了,代码不需要炫技,也足够扎实。
如果你正在照着这套思路做自己的药店管理系统,建议从数据库设计开始,先把drug_info和drug_batch两张表的关系理清楚。把批号级库存这个模型真弄懂了,后面所有的入库、出库、预警、报表,都是顺理成章的事。第一步走扎实了,这套系统离“完整跑通”也就不远了。
