☰
SpringBoot+Vue药店销售系统实践:从批次库存到处方登记的核心设计
2026/9/29 10:16:40 网站建设 项目流程

1. 需求画像:药店销售不是普通的商品增删改查

1.1 你要面对的真实场景

我见过太多把药店销售系统做成"换皮商城"的项目,放进几个商品、加个购物车、提交订单就算完工。真拿到药店去用,第一周就会暴露问题:药品批次号去哪了?有效期快到了怎么提醒?那盒药扫码扫出来为什么价格不对?顾客要登记处方信息的时候,界面上连个输入框都没有。

康健药店销售系统这个项目,核心难点从来不是"销售"两个字,而是"药店"两个字带来的行业约束。药品不是普通商品,一件商品你只管SKU、价格、库存三个字段就够了,药品至少要多管四样:批准文号、生产批号、生产日期、有效期。再加上药店经营里绕不开的处方药登记、拆零销售、近效期预警、限购管控,这些业务规则才是这个系统真正要解决的东西。

所以拿到"基于SpringBoot+Vue的康健药店销售系统"这个选题,第一件事不是写代码,而是把药店日常经营的流程撸一遍。一家小型连锁药店或单体药店的典型销售场景是:顾客进店,收银员在POS端输入或扫码找到药品,系统自动带出价格和库存,如果药品属于处方药,需要采集处方信息;结算后打印小票、扣减库存;每天晚上要做日结,统计当天销售额;店长要能看到哪些药快过期、哪些药库存不够。这套流程落到系统里,就是下面这张功能地图。

1.2 角色动线与功能拆分

我习惯先按角色走一遍动线,再定功能模块。康健药店系统里主要有三类使用者:

  • 收银员:核心动线是"找药→加购→处方登记→结算→打印小票"。需要功能:药品快速检索、购物车、结算台、会员识别、处方信息登记、小票打印。
  • 店长/管理员:核心动线是"看经营数据→处理库存问题→管理基础资料"。需要功能:销售报表、库存预警、药品档案管理、供应商管理、采购入库、员工账号管理。
  • 系统维护者:关心的是数据安全和系统可用性。需要功能:操作日志、数据备份、角色权限、系统参数配置。

基于这三条动线,我把功能模块拆成七个:

模块核心功能对应角色
登录与权限JWT登录、角色识别、菜单动态路由所有人
药品档案药品新增、编辑、批准文号/规格/厂家管理管理员
库存管理批次库存、近效期预警、盘点、库存流水管理员、店长
采购管理供应商档案、采购入库单、入库审核管理员
销售收银扫码加购、购物车、处方登记、结算、小票收银员
会员管理会员开卡、积分累计、折扣等级收银员
报表统计日结、销售排行、利润统计、库存报表店长、管理员

这个拆分方式有一个好处:它保证了每个模块都有明确的使用者,不会出现"做了个功能但没人会用"的情况。我第一次做药店系统的时候,一口气加了十几个管理页面,结果收银员每天用的还是只有其中四个,其他页面纯粹是给答辩和演示看的。这次我把功能砍到七个模块,反而每个页面都有人在用,演示的时候逻辑也顺得多。

2. 技术栈分工:SpringBoot与Vue在项目里各自扛什么

2.1 SpringBoot负责的"可靠性"

选SpringBoot做后端,最大理由不是它"火",而是它把Java后端开发里最耗时间的配置工作压缩到了极致。拿这个项目举例:要接入MySQL数据库、要做事务管理、要处理登录鉴权、要接文件上传、要支持定时任务,如果用SSH那套老框架,光XML配置就能写几百行。SpringBoot的自动装配机制把这些东西全部变成了"引入starter+写配置项"两步。

自动装配的原理说穿了并不复杂:SpringBoot在启动时扫描META-INF/spring.factories里的自动配置类,再根据classpath下的依赖和application.yml里的配置项来决定"要不要生效"。比如你引入了spring-boot-starter-data-redis,它检测到RedisTemplate相关类存在,就会自动帮你创建连接工厂、模板对象。你只需要关心业务代码,框架层面的bean组装基本不用碰。

在康健药店系统里,我对SpringBoot的依赖集中在三个地方:

  • 事务管理:销售结算涉及"生成订单+扣库存+记流水"三个写操作,任一步失败整体回滚。SpringBoot里只要在Service方法上加@Transactional,默认的Spring事务管理器就会接管,不需要额外配置。
  • 参数校验:药品档案新增时有十几个字段,如果用if-else手写校验,代码会又臭又长。我用spring-boot-starter-validation配合@Validated注解,在实体类字段上标注@NotBlank、@NotNull、@DecimalMin,全部校验不通过时由全局异常处理器统一返回提示。
  • 定时任务:近效期药品提醒需要每天凌晨跑一次。@EnableScheduling加@Scheduled(cron = "0 0 1 * * ?")就能实现,不需要引入额外的任务调度中间件。

这些选型都指向同一个判断:一个小型药店的销售系统,稳定性和开发效率最重要。杀鸡不用牛刀,真的不需要为了"技术亮点"去引入微服务、消息队列这些东西。

2.2 Vue负责的"交互效率"

前端选Vue,核心原因是收银台场景对交互响应速度要求极高。顾客排队结账的时候,收银员每点一下页面等两秒,那是会砸招牌的。Vue的响应式数据绑定让"改数据就改界面"变成了开发直觉,不需要像jQuery那样手动操作DOM,代码维护成本低得多。

项目用Vue 2配合Vue Router和Vuex(如果你是新项目,也可以直接上Vue 3 + Pinia,写法更简洁)。三个页面组件完全可以复用同一套逻辑;后端返回的药品列表我封装了公共药品选择器,在采购入库、销售收银、库存盘点三个地方用同一个组件,只是传不同的props。

这里有一个容易被忽略的实用点:Vue Router的导航守卫配合动态路由,是实现角色权限控制的优雅方案。我在登录成功后根据后端返回的角色编码动态注册路由表,收银员账号只挂载收银台、会员、销售查询三个路由,管理员账号才挂载全部路由。配合router.beforeEach里对token的校验,前端页面这一层的权限就控住了。当然,后端每个接口还是必须做权限校验,前端控制只是体验优化,不能作为唯一防线。

响应式性能方面,有一个实操心得:药品列表数据量稍微大一点(几千条),如果一次性渲染成表格,收银台输入药品名称搜索时会出现明显卡顿。我的处理方式是给搜索框做防抖,配合后端接口的分页查询,前端只维护当前页码的数据。结账物品列表用key标记每一条记录,避免Vue为了diff而反复渲染,实测在4核8G的开发机上,连续快速加购10种药品没有可感知的卡顿。

2.3 为什么坚持前后端分离

做这个项目之前我也犹豫过:要不要直接用SpringBoot模板引擎(Thymeleaf)在一个工程里搞定?毕竟药店销售系统的页面不算多,传统单体方式部署起来更省事。最终坚持前后端分离,理由有三个:

第一,收银台要响应快,局部刷新比整页刷新体验好太多。卖出一盒药,购物车、库存显示、当日流水都要同步变化,前后端分离后这些只是Vue的局部状态变更。

第二,后续扩展移动端会轻松得多。药店老板大概率会要求"能在平板上收银""能在手机上看看报表",前后端分离后,后端接口原样复用,只需要新写一个移动端前端工程,不用碰Java代码。

第三,团队协作和调试体验更清晰。后端只返回JSON,前端只负责渲染,两端各有一套错误日志体系。联调时出问题,通过浏览器Network面板和Postman一对比,很快就能定位到底是接口的问题还是页面渲染的问题。

3. 表结构与核心规则:药品批次、效期和处方约束

3.1 五张核心表的设计脉络

康健药店系统的数据库表一共有13张,真正决定业务形态的是下面这五张。设计的时候我反复确认过,去掉哪一张都会让药店业务转不起来。

用户表(sys_user):字段上除了基本的账号、密码、姓名、手机号,一定要加一个role_code字段来区分收银员和管理员。密码别存明文,用BCrypt加密,Spring Security的BCryptPasswordEncoder一行代码搞定。

药品表(med_drug):核心字段有药品编码(条码)、通用名、商品名、规格、剂型、生产厂家、批准文号、单位、销售价、会员价。注意区分"药品编码"和"条码",一个药品可以有多个条码(比如同一盒药在不同门店贴了不同标签),但药品编码在系统里是唯一的。

批次库存表(med_batch_stock):这张表是整个系统的灵魂。包含药品编码、批次号、生产日期、有效期、入库数量、当前库存量、锁定库存量。为什么要单独建表而不是在药品表上加一个库存字段?因为同一种药品可能进了两批货,一批效期到明年,一批效期下个月就到期,两者价格可能一样,但销售时绝不能混着扣。批次表就是为了支持"先进先出、近效期优先"的扣减逻辑。

销售订单表(sales_order):主表记录单号、收银员、会员ID、实付金额、优惠金额、处方状态、订单时间。子表sales_order_item记录每一行药品的编码、名称、批次号、单价、数量。

会员表(member):记录卡号、姓名、手机号、积分、等级、开卡时间。药店会员的核心价值是积分换购和折扣价,我在会员价、积分规则上做了冗余字段,避免每次结算都实时计算。

3.2 批次库存与"近效期优先"扣减逻辑

药品库存管理的特殊性在于:同一药品不同批次,过期时间不一样,不能按一个总数来管。我见过有人直接在med_drug表里放一个stock字段,销售时stock减1,三个月后整个系统的库存全乱了,因为根本没有办法知道剩下的是哪个批次的药。

正确的做法是销售出库时按批次先进先出。结算时前端提交的是药品编码,后端Service收到后按"有效期升序"找到所有该药品有库存的批次,然后从最早的批次开始扣减。用一个简化的Java代码说明:

@Transactional public void deductStock(String drugCode, int quantity) { List<MedBatchStock> batchList = batchStockMapper .findAvailableBatchesByDrugCode(drugCode); // 按有效期升序 int remain = quantity; for (MedBatchStock batch : batchList) { if (batch.getStock() >= remain) { batch.setStock(batch.getStock() - remain); batchStockMapper.updateById(batch); remain = 0; break; } else { remain -= batch.getStock(); batch.setStock(0); batchStockMapper.updateById(batch); } } if (remain > 0) { throw new RuntimeException("可用库存不足"); } }

这个方法必须放在事务里执行,不然扣了一半突然报错,库存数据就悬空了。另外,每次入库时如果发现同批次号已经存在,应该做累加而不是新建记录,否则库存流水不好追溯。我踩过的坑是入库时没做批次去重,结果两次采购同一个批号的药品,系统里出现两条记录,盘点的时候怎么都对不上账。

近效期预警我是通过定时任务实现的:每天凌晨扫描批次库存表,当有效期距离当前日期小于90天时,把该批次药品写入预警表,前端在库存页和首页轮播展示。预警阈值可以做成系统参数,不同药店要求不一样,有的药店60天就开始催着处理了。

3.3 处方药登记带来的字段设计

药店系统的业务约束还体现在处方药销售上。按经营规范,销售处方药需要登记处方信息、药师审核,并留存记录。所以销售订单表上必须有prescription_status字段,取值包括"无需处方、待登记、已登记、已审核"。

前端收银界面的交互逻辑是这样的:当购物车里的药品被标记为处方药时,结算前必须弹出处方登记表单,填写处方编号、开具机构、医师姓名、患者姓名、身份证号、药师审核人,这些信息保存到gsp_record表。缺了任何一个必填项,后端直接拒绝下单。最开始我把处方登记做成了可选项,药店里没人愿意填,后来改成强制校验,才真正符合经营规范。

购买限量也是一样。某些药品(比如含特殊成分的感冒药)单人单次限购数量,系统需要在结算接口里做数量校验。实现上我在药品表里加一个max_per_order字段,默认NULL表示不限,checkout时逐行校验购买数量是否超限。这类规则很细,但恰恰是药店和普通商城最大的区别。

4. 销售主链路:从加购到收银流水的事务与并发

4.1 收银台前端的操作流

收银台的页面设计直接决定了收银员能不能高效干活。我在康健药店系统的收银台页面上划分了三个区域:左侧是药品搜索和结果列表,右侧是当前购物车,底部是结算操作区。

最常用的操作路径是:扫条码或输入药品名称拼音首字母→搜索结果点击选中→药品进购物车→继续扫下一盒→最后点"结算"。为了让这个路径尽量短,我把搜索框设计成聚焦状态,扫枪扫完自动触发搜索,无需切换鼠标;药品条目上的"加购"按钮做得足够大,确保手指点按不会误触。

购物车组件内部维护一个数组,每个元素包含药品编码、名称、规格、单价、数量、金额、是否处方药、处方信息。Vue的computed属性实时计算合计金额、优惠金额、应收金额,并同步显示会员折扣信息。前端只在点击结算时调后端接口,中间过程完全不请求网络,保证流畅度。

处方登记弹窗是处理特殊药品的关键交互。我设置了两种触发方式:一种是当购物车里加入处方药时自动弹出提示;另一种是点结算时,如果还有处方药未登记,弹窗列出待登记药品清单,逐项填写。表单里内置了省市区和医院名称的常用数据字典,方便快速输入。

4.2 后端事务边界与防超卖并发

销售主链路最终落到后端的一个方法:checkout。它做的事情包括:校验药品状态、计算金额、扣减批次库存、生成销售订单主表和子表、更新会员积分、写入日结流水。整个过程必须在一个事务里,任何一步异常都全部回滚。

这里有个很容易被忽略的问题:多人同时收银时如何防止超卖。收银员A和收银员B同时卖同一盒药,如果两个请求都读到库存为1,各自扣1,最终库存就变成-1了。最简单的解决方案是给批次库存表加version字段做乐观锁:

UPDATE med_batch_stock SET stock = stock - #{quantity}, version = version + 1 WHERE id = #{id} AND stock >= #{quantity} AND version = #{version}

如果更新影响行数为0,说明库存已被其他事务抢走或版本不对,直接抛出"库存不足或数据已变更"提示。实际压测下来,这个方案在药店这种低并发场景完全够用,不需要引入Redis分布式锁和Redission那套重型方案。如果以后要支撑几十家门店同时收银,再考虑把库存扣减放到Redis里做原子操作也不迟。

事务边界上还有一个细节:扣减库存和生成订单应该是同一个事务,但小票打印和短信通知必须放在事务提交后。我把打印任务通过Spring的事件机制发布,事务提交后由监听器异步处理。原因很简单:事务提交前打印,如果后续回滚了,小票已经打出来了,顾客手里拿着小票但系统里没订单,这个账就对不上了。

4.3 日结报表与流水对账

每天晚上打烊之后,店长需要一个数据汇总:今天卖了多少单、收了多少钱、现金和移动支付各占多少、会员消费占比、哪些药品卖得最多。我在系统里做了一个日结任务,可以手动触发也可以定时执行。

日结的算法是:当天00:00到23:59:59之间所有状态为"已支付"的订单,按支付方式分组汇总金额,同时统计订单数和客单价。为了让日结报表可追溯,我在sales_order表里冗余了一个order_date字段并建立索引,避免日结查询去字符串匹配create_time导致索引失效。

报表模块我采用了"先有汇总表、再有查询页面"的做法。每天晚上定时任务把当天的汇总数据写入daily_report表,前台查询只碰这张汇总表。好处是报表页面打开极快,哪怕积累了三年的数据,查询也只扫365条记录。很多项目报表卡成PPT,就是因为在查询时实时SUM大表。

5. 前后端联调:跨域、精度和文件上传的三个坑

5.1 跨域配置与登录态共享

前后端分离开发时,前端跑在localhost:8080,后端跑在localhost:9090,浏览器默认会拦截跨域请求。解决办法很直接:在后端加一个WebMvcConfigurer配置类,允许跨域访问。

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

注意allowedOriginPatterns不要用allowedOrigins("*"),后者在allowCredentials(true)时会冲突。这个问题卡了我半天,报错信息提示"Cannot allow credentials for wildcard origin",换成Pattern写法就好了。

登录态用的是JWT方案。用户登录成功后后端签发一个有效期为24小时的token,前端存在localStorage里,每次请求通过Axios拦截器自动放到Authorization头里。后端用一个HandlerInterceptor统一校验token,没有token或token过期直接返回401。Vue Router的beforeEach里同时存储了token和用户信息,如果发现token不存在就跳转登录页。

5.2 金额精度和时间格式串台

药店系统的金额计算容不得半点误差。最开始我在前端用JavaScript的浮点数直接算金额,结果"0.1+0.2"这种经典问题在购物车累计里就出现了。前端展示可以四舍五入,但传给后端的金额必须精确。

我的约定是:前端只负责展示金额,所有金额计算都在后端用BigDecimal完成。购物车提交的是药品编码和数量,后端查单价、计算总价、扣减金额,前端展示结果。像是会员折扣、满减优惠这类逻辑,前端不参与计算,防止两边算法不一致。

JSON序列化方面,Java的BigDecimal如果直接转前端数字,可能会出现精度丢失。我在全局Jackson配置里做了序列化定制:

@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.serializerByType(BigDecimal.class, new ToStringSerializer()); }; }

这样接口返回的BigDecimal全部转成字符串,前端展示时不会有精度问题,提交时也能保证完整精度。

时间字段也有一个经典坑:LocalDateTime序列化后默认格式带"T",比如2024-05-20T14:30:00,前端如果不处理直接显示会很难看。建议在application.yml里统一配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai

5.3 上传处方照片与XSS过滤的配合

药店系统有个上传场景:顾客的纸质处方单需要拍照留存,同时Excel导入药品档案也是高频操作。文件上传和全局过滤器配合时,最大的坑是过滤了内容,却把文件流也处理坏了。

我最初写了一个全局XSS过滤器,作用是把请求里的特殊字符转义,防止恶意脚本注入。结果发现上传文件时报错,原因是过滤器拦截了multipart/form-data请求,尝试读取body里的内容并做字符串处理,直接破坏了文件流的解析。

解决办法是让过滤器放行上传类接口:

@Override public boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); return path.startsWith("/api/upload") || path.startsWith("/api/import"); }

上传的文件在控制层单独做文件类型校验和大小限制,处方照片限制为jpg/png/pdf,单个文件不超过5MB。文件名存储时用UUID重命名,避免中文文件名带来编码问题。Excel导入时先用POI解析,对每个单元格做类型判断,药品编码列必须是字符串,数量列必须是正整数,解析错误直接跳过该行并把错误信息返回给前端。

6. 打包部署与后续演进:从本地开发到服务器运行

6.1 前后端打包与Docker部署

项目做完,下一步就是部署。我用了Docker Compose一次性拉起前端Nginx、后端Java应用和MySQL三个容器。后端打成jar包,Dockerfile写到一半我突然意识到:这又是一个值得记录的坑。

SpringBoot项目打包时,配置文件里的环境差异要处理好。我用application.yml存放公共配置,再用application-prod.yml覆盖生产环境的数据库地址和日志路径。打包命令是:

mvn clean package -DskipTests

后端Dockerfile:

FROM openjdk:8-jre-alpine COPY target/kangjian-pharmacy.jar /app/app.jar WORKDIR /app EXPOSE 9090 ENTRYPOINT ["java", "-jar", "app.jar", "--spring.profiles.active=prod"]

前端打完后,把dist目录挂载到Nginx容器里,同时配置反向代理,把所有/api开头的请求转发到后端容器:

location /api/ { proxy_pass http://backend:9090; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

这里有一个必须处理的细节:前端路由如果用了history模式,Nginx还要配置try_files,否则用户直接访问/expired路径会被Nginx返回404。正确的写法是:

location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }

6.2 数据库备份与定时任务

药店系统最怕丢数据。每天产生的销售记录、库存流水都是重要的经营凭证,数据库备份不能省。我在生产服务器上写了一个简单的cron脚本,每天晚上3点用mysqldump全量备份,同时保留最近14天的备份文件:

#!/bin/bash BACKUP_DIR=/data/backup/mysql DB_USER=root DB_PASS=****** DB_NAME=kangjian_pharmacy DATE=$(date +%Y%m%d) mysqldump -u$DB_USER -p$DB_PASS $DB_NAME | gzip > $BACKUP_DIR/db_$DATE.sql.gz find $BACKUP_DIR -name "*.sql.gz" -mtime +14 -delete

系统内的定时任务,比如近效期预警和日结汇总,也要注意执行时间错峰。我把日结放在23:59执行,近效期预警放在01:00执行,尽量避开营业高峰期的数据库读写。

6.3 后续扩展的思考方向

康健药店销售系统做完之后,要扩展其实有很清晰的方向。一个是库存进销存一体化:现在系统里采购入库和销售出库是分开的,缺一张采购退货单;另外药店经营还需要GSP相关的电子记录,包括温湿度记录、养护记录、员工培训记录,这些都是行业刚需。另一个是移动端适配:老板希望在手机上看到实时营业数据,用Vue重做一个移动端H5界面并不难,后端接口基本可以原样复用。还有一个是对接电子支付和医保接口,小店可能用不到,但连锁药店早晚要接医保的电子处方流转。

如果让我重新做一次这个项目,我会在第一版就引入一个进销存层面的统一流水表,把采购入库、销售出库、退货、盘盈盘亏全部记录在一张表里,后续做库存成本核算、毛利分析都会方便很多。现在表结构是分开设计的,再要合并就得写数据迁移脚本,工作量不小。

回到项目标题本身,SpringBoot和Vue的组合在这个场景里确实是最稳的选择:SpringBoot保证了业务事务的可靠性,Vue保证了收银交互的流畅度。只要先把药店行业的业务规则吃透,这个系统就不仅仅是一个"毕业设计",而是真正能拿到店里跑起来的工具。最后再分享一个小技巧:收银台界面一定不要做成一堆表单的样式,它是高频操作界面,越像"收银机"越好用。我第一版做出来像后台管理系统的表单页面,收银员实测后吐槽"找不到按钮在哪里",后来改成横条式商品列表加右侧购物车,才真正被店里接受。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询