一个开药店的朋友找我帮忙,说他店里还在用Excel管库存,效期药品快到期的没人提醒,处方药销售记录也不够规范,每次应付检查都满头大汗。我一开始觉得就是个普通进销存,但真正把需求聊透以后才发现,药店管理的核心痛点根本不在"卖药"这个动作,而在"药品流转"这条完整链路——从采购入库、批次库存、效期预警,到收银开单和处方登记,任何一环掉链子,都会直接影响药店能不能合规经营。
这篇文章要聊的这套基于Spring Boot + Vue的药店管理系统,就是围绕这条链路设计的前后端分离项目。后端用Spring Boot提供RESTful接口,前端用Vue配合Element UI搭建管理后台,源码、数据库脚本和项目文档都齐全,适合正在做课程设计、毕设,或者想给中小型药店做信息化改造的同学直接参考。下面我会把这个项目的需求拆解、数据库建模、库存扣减的并发处理、收银开单的交互设计这些核心细节全部展开,实际调试中踩过的坑也会一并写出来。
1. 药店管理项目从哪来:先搞清楚药店每天在忙什么
1.1 一天下来,药店要处理哪些事
药店不是简单的卖药。一家社区药店,每天开门之后要做的事情大致有这么几类。
第一类是采购和验收。店长要根据库存情况和销售节奏,向供应商下采购单,货到了以后要验数量、验批号、验效期,然后录入系统。这个环节如果靠手写单据,后期对账能对到怀疑人生。
第二类是销售。顾客买药,店员开单收款。如果是处方药,按规定必须凭处方销售,还要登记处方编号、患者信息、用药剂量这些内容。这里有个外行人容易忽略的点:处方药销售记录不是你想记就记、想不记就不记的,监管检查的时候必须能随时调出来。
第三类是库存管理。药品有有效期,不能像普通商品那样一直放着。效期管理做不好,过期药还摆在货架上,这是药店经营的红线问题。另外还要每天关注哪些品种库存偏少、哪些品种积压,避免资金压在滞销品种上。
第四类是会员和营销。很多药店有会员积分、满减活动,会员购药记录要能查,积分要能算,不然顾客问一句"我卡里多少分"店员都答不上来。
第五类是报表。日报、月报、毛利统计、供应商对账,月底都要做得出来。
把这五类事拆开看,就是一个典型的"进销存 + 合规管理"系统。功能边界定清楚以后,后面所有设计都围绕这几条主线展开,就不会跑偏。
1.2 为什么不能继续用Excel
说实话,很多小型药店至今还在用Excel管药,勉强能用,但越用越难受。库存账和实物账对不上是最常见的现象,因为每个人录入习惯不一样,有人填"阿莫西林胶囊",有人填"阿莫灵",同一个药品在表格里出现好几个称呼,月末汇总的时候根本分不清到底哪个才是同一个品种。
更麻烦的是效期管理。Excel里虽然有一列"有效期",但没人会每天翻一遍表格看哪批药快过期了。我接触过一家社区药店,出过整盒过期药被顾客买到的情况,最后只能赔礼道歉加退款,还差点被投诉到监管机构。这不是Excel本身的问题,而是流程上缺少一个主动提醒的机制——系统必须能在药品到期前自动告诉你,而不是靠人肉死记。
另外,处方药登记在Excel里很容易漏项。手工登记很难保证每次都不漏。这套系统上线之后做的第一件事,就是把处方药销售设成强制登记,不填入处方编号就没有办法完成开单,从机制上堵住漏洞。
1.3 最终敲定的功能清单
根据上面这些日常场景,我把系统功能拆成了七个模块:
| 模块 | 核心功能 | 解决什么痛点 |
|---|---|---|
| 系统管理 | 用户管理、角色管理、菜单权限 | 店员/店长/管理员分级操作 |
| 药品管理 | 药品档案、分类、厂家、批准文号 | 统一药品主数据,避免同药多名 |
| 库存管理 | 入库、出库、盘点、效期预警 | 账实一致,过期药提前拦截 |
| 采购管理 | 供应商维护、采购单、验收入库 | 采购流程留痕,方便对账 |
| 销售管理 | 收银开单、退货、处方登记 | 日常收银与合规记录 |
| 会员管理 | 会员档案、积分、等级折扣 | 留存回头客,会员精准营销 |
| 统计报表 | 销售日报/月报、毛利分析、库存报表 | 经营数据一目了然 |
这个清单看起来多,但实现上并不复杂。核心就是药品主数据、库存流水、销售流水这三块,其他模块都是在它们之上做增删改查和统计。
2. 技术选型分析:Spring Boot + Vue 这对组合为什么够用
2.1 后端选 Spring Boot 的三个理由
对于这类管理信息系统,Spring Boot几乎是个不需要犹豫的选择。原因很直接。
第一,它把Spring MVC时代大量繁琐的配置自动化了。内嵌Tomcat、自动配置、起步依赖,写一个Controller就能跑起来,开发效率比传统SSH框架高太多。以前配一个Spring要用XML写满几页纸,现在一个注解就解决。
第二,生态成熟。药店管理系统本质是CRUD加少量业务逻辑,MyBatis-Plus可以把单表操作做到极致简洁,分页、条件构造器都是开箱即用。不需要写大量XML映射文件,实体类加几个注解就能自动生成基本的增删改查。
第三,招人容易、学习资料多。不管是接手项目的开发者还是后续维护的人,Spring Boot的熟悉度在各种技术栈里都是最高的。出了问题随便一搜就是解决方案,不会卡住。
2.2 前端选 Vue 的理由
前端用Vue加Element UI,是管理后台项目里最稳妥的组合。Vue的组件化开发让页面复用变得简单,比如药品选择器、库存表格、分页组件,写一次到处用。Element UI提供了成熟的后台组件库,表格、表单、弹窗、消息提示都有现成方案,不需要自己从头写样式和交互。
这套系统选的是Vue 2.6配合Element UI。现在Vue 3加Element Plus也完全成熟了,如果是从零新建项目,我更建议直接用Vue 3。当初保留Vue 2版本主要是历史原因——项目搭建时Vue 3生态还在过渡期,而系统本身的功能并不依赖Vue 3的新特性,升级动力不足,就一直沿用了。
2.3 配套组件选型一览
| 技术组件 | 选用版本 | 说明 |
|---|---|---|
| Spring Boot | 2.7.x | 稳定、资料多、兼容性好 |
| MySQL | 8.0 | 主流关系型数据库 |
| MyBatis-Plus | 3.5.x | 单表CRUD、分页插件 |
| Redis | 5.0+ | 缓存登录Token、热点数据 |
| JWT | 0.9.x | 前后端分离的认证方案 |
| Vue | 2.6.x | 渐进式前端框架 |
| Element UI | 2.15.x | 后台管理组件库 |
| Nginx | 1.20+ | 静态资源托管与反向代理 |
这套组合对服务器要求很低,一个2核4G的云主机跑起来毫无压力,也符合中小药店的预算。别一上来就搞微服务、Redis集群、消息队列,这套系统在这个量级的负载下根本用不上,徒增运维成本。
3. 数据库设计:围绕"药品-库存-销售"的三条主线
3.1 药品档案和批次库存为什么要分开存
这是整个数据库设计里最重要的一个决定。
一开始我也习惯把所有字段塞进一张药品表:药品ID、名称、规格、厂家、数量、有效期。看起来简单,但很快就发现问题了——同一个药品,这次进的是2025年3月到期的一批,下次进的是2025年9月到期的一批。如果只存一个数量和一个有效期,入库时库存数量虽然可以累加,但效期到底按哪一计算?
所以必须拆表。药品主表存固定不变的档案信息,批次库存表存每次入库的批次信息:
CREATE TABLE drug_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_code VARCHAR(32) NOT NULL COMMENT '药品编码', drug_name VARCHAR(128) NOT NULL COMMENT '药品名称', spec VARCHAR(64) COMMENT '规格', unit VARCHAR(16) COMMENT '单位', manufacturer VARCHAR(128) COMMENT '生产厂家', category_id BIGINT COMMENT '分类ID', approval_number VARCHAR(64) COMMENT '批准文号', prescription_type TINYINT COMMENT '1处方药 0OTC', retail_price DECIMAL(10,2) COMMENT '零售价', purchase_price DECIMAL(10,2) COMMENT '进货价', status TINYINT DEFAULT 1 COMMENT '状态 1上架 0停用' ); CREATE TABLE drug_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, drug_id BIGINT NOT NULL, batch_no VARCHAR(64) COMMENT '批号', expire_date DATE COMMENT '有效期', quantity INT DEFAULT 0 COMMENT '库存数量', purchase_price DECIMAL(10,2) COMMENT '该批次进货价', supplier_id BIGINT COMMENT '供应商ID', sort_order INT COMMENT '出库优先级,值越小越先出库' );批号、有效期跟着库存批次走,这样采购入库时新增一条批次记录,销售出库时优先扣先到期的批次,效期管理就落到了数据层面。药店如果要拿GSP认证,批次记录都要有据可查,这个设计的规范程度直接影响合规审计能不能通过。
3.2 销售主表与明细表:为什么必须拆
卖药这个动作要记录的信息分两层:这笔订单的整体信息,和订单里每一件药品的信息。
主表存订单整体,明细表存每个药品。订单号、会员ID、收银员、总金额、实付金额、支付方式、销售时间放主表;药品ID、批次ID、数量、单价、金额放明细表。
CREATE TABLE sale_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '订单号', member_id BIGINT COMMENT '会员ID', user_id BIGINT NOT NULL COMMENT '收银员ID', total_amount DECIMAL(10,2) COMMENT '总金额', discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额', pay_amount DECIMAL(10,2) COMMENT '实付金额', pay_type TINYINT COMMENT '1现金 2微信 3支付宝 4医保', sale_time DATETIME COMMENT '销售时间', require_prescription TINYINT DEFAULT 0 COMMENT '是否处方药订单', prescription_no VARCHAR(64) COMMENT '处方编号', status TINYINT DEFAULT 1 COMMENT '1正常 2已退货' ); CREATE TABLE sale_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, drug_id BIGINT NOT NULL, stock_id BIGINT NOT NULL COMMENT '批次库存ID', quantity INT NOT NULL, price DECIMAL(10,2) COMMENT '成交单价', amount DECIMAL(10,2) COMMENT '行金额' );拆开的好处首先是退货方便。顾客退一份药,只需要在明细表里找到对应行做负向冲销,主表状态改成已退货,不用动其他数据。另外报表统计也简单:按主表统计营业额,按明细表统计药品销售排行,两边互不干扰。
3.3 效期预警的字段设计与查询写法
效期预警依赖的是drug_stock表里的expire_date字段。核心查询就一个思路:查出现在到90天以内到期的所有库存批次。
SELECT s.drug_id, d.drug_name, s.batch_no, s.expire_date, s.quantity FROM drug_stock s LEFT JOIN drug_info d ON s.drug_id = d.id WHERE s.expire_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) AND s.quantity > 0 ORDER BY s.expire_date ASC;这里有个写SQL的细节要特别注意:很多人会写成DATEDIFF(s.expire_date, CURDATE()) <= 90,这种写法在expire_date字段上没法走索引,药品批次一多,全表扫描会越来越慢。改成BETWEEN这种范围查询,索引就能正常工作。别小看这个优化,库存批次上万条之后,查询速度的差别是非常明显的。
预警提醒放在登录后的首页工作台。每天第一次登录系统,自动拉取90天内到期的批次列表,30天内到期的用红色标记。这套机制上线之后,药店再也没出现过药快过期还没发现的窘境。
4. 后端核心模块实现:从登录到扣库存的完整链路
4.1 JWT登录鉴权:前后端分离下的轻量方案
药店系统用户不多,登录认证不需要复杂的OAuth流程,JWT就够用。
用户登录成功后,后端生成一个带过期时间的Token返回给前端,前端存起来,每次请求在请求头带上Authorization字段。后端用一个拦截器校验Token,通过就放行,不通过就返回401。
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } // 解析Token,失败时由全局异常处理器统一返回 Claims claims = JwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", claims.get("userId")); request.setAttribute("roleId", claims.get("roleId")); return true; } }Token里只放userId和角色信息,不要放大段用户对象,避免Token体积膨胀。过期时间一般设8小时,药店营业时间基本都能覆盖,店员中午换班重新登录也不会频繁。
这里补一句实战经验:JWT最大的坑是过期失效。店员可能8小时后还在收银,Token过期突然被踢出去,开了一半的单子就丢了,很影响情绪。所以前端要加一个响应拦截器,收到401时不要直接把用户踢回登录页,而是先弹一个"重新登录"的对话框,保留当前页面的开单数据,用户重新认证后可以继续操作。
4.2 收银下单的并发扣库存:为什么不能先查后改
收银开单是整套系统并发压力最集中的地方。两个收银员同时卖同一个药,如果代码写成"先查库存,够的话再扣减",那么两个请求可能同时查到库存还剩3盒,然后都认为自己可以卖出2盒,最后库存被扣成负数。这就是典型的超卖问题。
解决思路不复杂,用一条原子更新的SQL,把检查和扣减合并成一个操作:
@Transactional(rollbackFor = Exception.class) public void createOrder(SaleOrderDTO dto) { // 1. 插入订单主表记录 // 2. 逐行扣减库存,使用条件更新保证不超卖 for (SaleOrderItemDTO item : dto.getItems()) { int updated = stockMapper.reduceStock(item.getStockId(), item.getQuantity()); if (updated == 0) { throw new RuntimeException("库存不足或批次已被占用"); } // 3. 写入订单明细 } }对应的Mapper方法:
@Update("UPDATE drug_stock SET quantity = quantity - #{num} " + "WHERE id = #{stockId} AND quantity >= #{num}") int reduceStock(@Param("stockId") Long stockId, @Param("num") Integer num);关键就在quantity >= #{num}这个条件,数据库会先判断当前数量是否足够,够才执行扣减。UPDATE的行锁保证了同一时刻只有一个请求能修改这条批次记录。执行结果返回0就说明扣减失败,整个订单回滚,前端提示库存不足。
这个方案比乐观锁少一次版本号的复查逻辑,比悲观锁少了SELECT FOR UPDATE的锁等待,在药店这种并发量级下是最优解。
这里还有一个细节:销售下单时要按drug_stock表的sort_order字段排序,优先扣先到期的批次。这样效期管理不只是"能查",还能落地执行——系统自动先卖快过期的药,而不是每次都拿最新批次的药卖。这是很多管理系统的通病:预警做出来了,但业务动作没有闭环。
4.3 采购入库与批号处理:入库动作比想象中麻烦
采购入库涉及两块数据:采购单本身的状态变更、库存批次的新增。我把流程拆成三步:下单、验收、入库。
下单阶段只创建采购单,不影响库存。验收阶段核对实物数量和批号,如果供应商送来的批号和采购单上填的不一致,以实际到货为准修改批号。入库阶段才真正插入drug_stock记录,同时把采购单状态改成已入库。
这样拆的好处是数据链路清晰,每一步都有操作日志可查。监管检查时可以调出采购单、验收记录、入库批次三条数据,形成完整追溯链。我给这一步加了校验:入库数量大于验收数量时直接拦截,防止人为录错造成账实不符。
4.4 报表统计的SQL:别在一开始就过度优化
药店系统的报表无外乎销售日报、月报、毛利统计、品类销售占比。这类统计在数据量不大的阶段,一条GROUP BY就解决了:
SELECT DATE_FORMAT(sale_time, '%Y-%m-%d') AS sale_date, COUNT(*) AS order_count, SUM(pay_amount) AS total_pay, SUM(item_profit) AS gross_profit FROM sale_order WHERE sale_time >= #{startDate} GROUP BY DATE_FORMAT(sale_time, '%Y-%m-%d') ORDER BY sale_date DESC;毛利这里要特别说明。药品的进货成本每个批次可能不一样,毛利不能简单用"零售价减最新进货价"去算。我是在销售明细里冗余存了每一行当时的进货价,下单时从对应库存批次里带出来。这个冗余字段虽然违反三范式,但胜在查询快、历史准确——进货价后面涨了,之前的销售毛利不该跟着变。
数据量超过几十万行以后,可以考虑按月分表或者定时汇总表,但单体药店跑三五年都到不了那个量级。过早引入大数据组件和复杂的聚合方案,只会让项目可维护性变差。先把业务跑通,再谈优化。
5. 前端开单体验与页面结构:把复杂逻辑藏在操作后面
5.1 页面整体布局与路由结构
前端采用Element UI经典的左右布局:左侧菜单导航,右侧内容区域。菜单按功能模块划分成系统管理、药品管理、库存管理、采购管理、销售管理、会员管理、统计报表七组,每个菜单项对应一个Vue路由。
登录后默认进入工作台,展示效期预警、库存预警、今日销售三个核心卡片,让店长一打开系统就能看到最需要关注的信息。这三个卡片是高频需求,放在首页就是让关键信息第一眼可见。
有个设计值得说一下:销售收银页我单独做了全屏路由,不跟普通页面共用内容区域的固定头部。原因是收银员一天十几个小时盯着这个页面,操作密度最高,页面越简洁越好——只保留一个扫码输入框、一个商品列单区、一个结算区,减少视觉干扰。
5.2 收银开单页的交互设计:扫码枪是怎么配合Vue的
扫码枪在系统里就是一个USB输入设备,本质上它是在扫描完成后模拟键盘一次性输入一串字符加一个回车。所以前端不需要任何特殊硬件对接,只要有一个输入框拿着焦点,把扫码内容当成普通的搜索关键字处理就行。
<el-input ref="scanInput" v-model="scanCode" placeholder="扫码或输入药品名称/编码" @keyup.enter.native="handleScan" /> methods: { handleScan() { getDrugByCode(this.scanCode).then(res => { if (res.data.stockQuantity < 1) { this.$message.error('该批次已售罄'); this.scanCode = ''; this.$refs.scanInput.focus(); return; } this.cartItems.push({ drugId: res.data.id, drugName: res.data.drugName, spec: res.data.spec, stockId: res.data.stockId, price: res.data.retailPrice, quantity: 1 }); this.scanCode = ''; this.$refs.scanInput.focus(); }); } }这里两个细节值得注意。第一,每次扫码处理后必须清空输入框并重新聚焦,因为扫码枪的机制就是"输入加回车",不重新聚焦的话下一单就失效了。第二,每扫一个药品自动加一条购物车记录,不需要手动点"添加"按钮,这是收银效率的关键。实测下来,熟练的收银员用这套流程完成一笔三五种药品的订单,十秒以内能搞定。
购物车列表里,每行显示药品名、规格、批号、单价、数量、小计,数量默认是1,双击可以修改。修改数量时前端直接计算行小计和总金额,不需要请求后端,提交订单时再把整个购物车数据一次性发给后端。
5.3 表单校验与关键操作保护
药品新增和编辑的表单,校验是最多的。批准文号必填、零售价和进货价不能为负、药品编码不能重复,这些都要在前后端各校验一遍。前端做的目的是让用户不用提交才发现错误,后端做的是防止有人绕过前端直接调接口。
重点之一是处方药订单的强制登记。前端在提交订单前判断购物车里是否存在处方药,存在则弹出处方登记弹窗,必须填写处方编号和开方信息才能完成提交。这个强制逻辑不只做在前端,后端createOrder接口里也加了同样的判断,防止绕过前端直接调接口。
退款操作也加了二次确认。弹出框里显示订单金额、购买人、药品明细,确认后才执行退货。退货同时恢复对应批次库存,并把订单状态改成已退货。这个逻辑说不上多难,但很容易漏——只改订单状态忘了加库存,账面就会对不上。我见过不少项目在这个环节出错,退货的库存恢复一定要和状态变更放在同一个事务里。
6. 权限控制与数据安全:药店场景里容易忽视的两个点
6.1 RBAC角色的简单落地
这套系统用户少,权限模型用最基础的RBAC就够:用户属于角色,角色拥有菜单权限。数据库设计三张核心表:用户表、角色表、用户角色关联表。菜单权限我采用后端返回菜单列表,前端根据返回结果动态生成侧边栏菜单的方式。
有朋友问我为什么不把权限做到按钮级。我的看法是:在药店这个场景里,按钮级权限实际意义不大。店员能看到所有菜单但进不去某个页面,意义不大;真正需要控制的动作比如删除药品、退款、修改价格,在后端接口里做权限校验就够了。菜单隐藏主要是让界面干净,安全边界终究应该落在后端。
后端权限校验我用的是自定义注解加拦截器的方式。在需要限制的接口上加一个@RequirePermission注解,标注需要的权限码,拦截器里解析用户角色,再去查角色权限列表做比对。这样权限逻辑集中管理,Controller里的业务代码保持干净。
6.2 密码加密与敏感信息保护
密码存储必须用加密算法。我用的是BCrypt,而不是MD5或SHA。理由很直接:MD5和SHA是摘要算法,计算速度快,撞库也快,彩虹表一砸就穿。BCrypt是慢哈希,内部自带盐值,即使数据库泄露,想还原密码的代价也高得多。
不引入全套Spring Security的情况下,单独用spring-security-crypto依赖里的BCryptPasswordEncoder即可,加密和校验都是一行代码的事。这个度的把握在于:为一个小型管理系统引入全套Spring Security,配置成本高、收益不大;只引入需要的加密工具类,刚刚好。
日志里不能打明文密码。我写登录接口的初期踩过这个坑,为了调试方便在日志里打印了登录参数,结果密码也一起打出来了,后来检查代码才发现,赶紧改成只打印"登录成功或失败"这种不包含请求体的日志。这个习惯建议从一开始就养成。
6.3 操作日志:至少覆盖这三类
不是所有操作都要记日志,但至少三类必须记:药品入库、删除或修改药品档案、退货退款。这三类操作直接关系库存数据和财务数据,出了问题要能追踪到"谁在什么时间做了什么"。
我用一个简单的AOP切面处理:自定义一个@Log注解,标在需要记录日志的Controller方法上,切面里把当前用户、请求参数、执行结果统一写入日志表。参数序列化时做字段过滤,排除密码和Token这类敏感字段,只保留指定的几个字段。这样做的好处是业务代码零侵入,日志逻辑统一收敛。
7. 部署上线与维护:一台云主机怎么把系统跑起来
7.1 前后端分离的部署方案
药店服务器预算有限,一台云主机就够。前端打包成静态文件交给Nginx,后端打成一个jar包用systemd守护运行,MySQL装在同一个台机器上。
部署目录结构:前端打包后放在 /var/www/pharmacy,后端jar放在 /opt/pharmacy。Nginx配置同时做两件事:托管前端静态资源、反向代理后端API:
server { listen 80; server_name pharmacy.example.com; root /var/www/pharmacy; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端配置里把context-path设为/api,前端请求统一走/api前缀,Nginx转发的规则和前端环境变量里的baseURL能对上。前后端同域部署之后,生产环境的跨域问题完全不存在了。
7.2 实际开发中踩过的两个典型坑
跨域问题是前后端分离项目逃不掉的第一关。开发环境下我在Vue的vue.config.js里配置了devServer代理,把/api开头的请求转发到本机8080端口,避免前端直连后端接口产生跨域。生产环境用上面的Nginx方案,整个系统只有80端口一个入口,天然不存在跨域问题。
时区问题也踩过一次。系统上线后,店员反馈日报里的销售日期少了一天。排查下来是MySQL连接串里没有指定serverTimezone,数据库默认用了服务器的UTC时区,而服务器部署时区是北京时间,凌晨0点到8点的订单被归档到了前一天。解决办法是把连接串改成带serverTimezone的格式,问题立刻消失。这类问题在开发环境往往不出现,因为本机的MySQL和Java默认时区一致,一到服务器就露馅。
7.3 关于金额精度和库存同步的提醒
金额字段必须用DECIMAL,不能用Double。这个教训我在测试阶段就发现了——用Double计算0.1加0.2得到的是0.30000000000000004,虽然界面显示时四舍五入看不出来,但累计到几千笔订单,报表里就会差出几毛钱。后端所有金额计算用BigDecimal,数据库用DECIMAL(10,2),前端展示用toFixed(2),三层统一,精度问题就不会出现。
库存显示不同步的问题也值得一说。前端药品列表里的库存数字是页面加载时查的,收银员开完一单后切到库存页面,看到的还是旧数字,因为这个数字是静态展示,不会自己刷新。后来我在关键页面加了activated生命周期钩子,每次切回页面重新拉取一次库存数据。这个改动很轻,但对使用体验的提升非常明显——不会再出现"明明卖了一盒,库存页面还显示原数量"的困惑。
后续这套系统如果要往更深的方向走,大概会是这样几个方向:对接医保接口,这是药店信息化绕不开的需求点;电子处方流转,需要跟医院的处方系统打通;多门店连锁时,批次库存表加一个门店ID字段,再引入总仓调拨流程。当初数据库设计时留好扩展位,这些改动基本就是在现有骨架上添加功能而已。
最后分享一点个人体会。这套系统上线后,店长跟我说了一句话让我印象很深:以前月底盘点要两天,现在两个小时就能做完,更重要的是效期提醒这件事终于被系统接住了,再也不用靠人肉去翻表格。做管理系统的成就感其实就来自这种真实的效率提升。如果你正准备做类似的项目,我的建议是先把需求边界和数据库表结构想清楚,表结构稳了,后面写代码会顺很多。尤其是药品这种效期和批次概念非常强的领域,批次表的设计质量直接决定整个系统的上限。希望这篇记录能让你少走几步弯路。