资讯动态

SpringBoot+Vue药店销售系统实践:从批次库存到处方登记的核心设计

发布时间:2026/9/28 7:31:21 来源:尧图企业网站定制
1. 需求画像药店销售不是普通的商品增删改查1.1 你要面对的真实场景我见过太多把药店销售系统做成换皮商城的项目放进几个商品、加个购物车、提交订单就算完工。真拿到药店去用第一周就会暴露问题药品批次号去哪了有效期快到了怎么提醒那盒药扫码扫出来为什么价格不对顾客要登记处方信息的时候界面上连个输入框都没有。康健药店销售系统这个项目核心难点从来不是销售两个字而是药店两个字带来的行业约束。药品不是普通商品一件商品你只管SKU、价格、库存三个字段就够了药品至少要多管四样批准文号、生产批号、生产日期、有效期。再加上药店经营里绕不开的处方药登记、拆零销售、近效期预警、限购管控这些业务规则才是这个系统真正要解决的东西。所以拿到基于SpringBootVue的康健药店销售系统这个选题第一件事不是写代码而是把药店日常经营的流程撸一遍。一家小型连锁药店或单体药店的典型销售场景是顾客进店收银员在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) { ListMedBatchStock 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.10.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/Shanghai5.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.activeprod]前端打完后把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_USERroot DB_PASS****** DB_NAMEkangjian_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保证了收银交互的流畅度。只要先把药店行业的业务规则吃透这个系统就不仅仅是一个毕业设计而是真正能拿到店里跑起来的工具。最后再分享一个小技巧收银台界面一定不要做成一堆表单的样式它是高频操作界面越像收银机越好用。我第一版做出来像后台管理系统的表单页面收银员实测后吐槽找不到按钮在哪里后来改成横条式商品列表加右侧购物车才真正被店里接受。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑