网上药店商城进销存系统实战:Vue3+FastAPI实现批号效期与GSP合规管理
2026/9/15 7:27:17 网站建设 项目流程

去年接了一个医药客户的定制项目,需求方一开始就给了七个字:"网上药店商城进销存管理系统"。我当时第一反应是,这不就是个带库存的电商系统嘛,拿个现成的电商框架改一改就行了。等真正开始做业务梳理,我才发现自己想得太简单了:药品不是普通商品,从采购入库那一天起,批号、效期、GSP合规记录就天天跟在流程后面,处方药还得考虑在线问诊和审方,这些都决定了这个系统的数据模型跟普通商城完全不是一个设计思路。这套项目最终采用Vue3做前端、Python做后端,前台商城和后台进销存共用一套核心库存数据,前后端加起来跑了大半年,日常维护基本稳定,我把整个设计和落地过程中的经验整理出来,给后面打算做同类系统的人一个参考。

这套系统做成什么样了呢?往大了说,它是一个B2C网上药店,用户可以浏览药品、加购物车、下单支付;往实际业务看,它又是一套完整的进销存后台,采购、入库、出库、盘点、效期预警、销售统计全部串联。前台每成交一单,后台库存实时变化;后台每进一批药,前台可售数量同步更新。适合想从零搭建"商城+进销存一体化"系统的开发者参考,也适合医药行业信息化人员拿来做业务蓝图。

1. 药品生意为什么要把商城和进销存绑在一起

1.1 网上药店比普通电商多出来的三座大山

普通电商做一套商城,核心就三件事:商品、订单、库存。库存直接用SKU数量来管,卖一个减一个,非常简单。但网上药店不一样,给业务建模时你马上会撞上三座大山。

第一座是批号管理。药品和衣服、手机最大的区别是它有批号,同一款药、同一个SKU,可能同时存在五个不同批号的货,每一批又对应不同的供货商、不同的生产日期、不同的质检报告。消费者看到的只是一个商品链接,但后台发货时必须知道出的是哪一批,否则一旦出现质量问题召回,你根本查不到流向。

第二座是效期管理。药品有严格的效期,临近效期的药不能正常卖,甚至有些药过了效期必须报废。这个"效期"不是一个备注字段,而是一条硬性的业务规则。销售时原则上要效期先到先出(FEFO),仓库里的药不能像普通商品那样随便发。系统必须能算出哪些药临近效期、哪些已经过期,并在前台商城里对临期药品做拦截或提示。

我对客户的一句原话印象特别深:"普通商品卖不掉是压资金,药品卖不掉是压资金加压合规风险。"这句话后来直接决定了我给这个系统加了多少跟效期、批号相关的功能模块。

第三座是GSP合规。药品经营质量管理规范要求企业记录每一批药品的来源、验收、储存、销售去向,形成完整的追溯链条。普通商城的"订单明细表"在这里远远不够,你得有采购记录、验收记录、上架记录、销售记录、退换货记录,并且每条记录都要能关联到批号。这一块做好了系统是加分项,做不好以后合规检查就是灾难。

1.2 我对项目模块的理解与边界划分

搞清楚了业务约束,模块划分其实就顺理成章了。这套系统我拆成两大端四个域:

商城端面向消费者,包含用户认证、药品展示、购物车、下单支付、订单查询几个模块。这里面的难点不是页面多炫,而是所有展示给用户的库存和价格,都必须来源于后台进销存的真实数据,不能像普通商城那样在商品表里存一个"假库存"随便改。

后台端面向药房员工和管理者,包含商品管理、采购管理、入库管理、出库/销售管理、库存管理、效期预警、盘点管理、报表统计、会员管理。一件药品从供应商报价开始,到采购单审核、到货验收、入库、上架商城、下单出库、最后形成销售统计,整条链路在两个端之间来回流转。

我特别强调一个边界原则:商城端的任何操作,都不允许直接修改库存主数据。商城端通过接口读库存、锁定库存、请求扣减库存,但真正的数量变更只发生在后端服务里,并且每一步变更都要求写一条流水。这样做的好处是,就算商城前端出了Bug导致多下单,后台数据仍是可信的,排查问题时有据可查。

1.3 技术选型:Vue3 + Python这套组合的取舍

为什么前端选Vue3?原因很朴素:这个项目需要大量中后台页面,Vue3的Composition API更适合把复杂业务逻辑拆成一个个可复用的组合式函数;TypeScript支持也成熟,商品、订单、库存这些有明确结构的数据,类型定义清晰后能少一大半低级错误。加上Element Plus在后台管理界面的组件覆盖度高,省去了大量造轮子的时间。

后端选Python,我在这套系统里用了FastAPI而不是Django或者Flask,这个选择后面单独讲。但整体来说,Python在药店这种业务规则复杂、报表需求频繁变化的项目里,开发效率是真高。

组合方案适合场景我的判断
Vue3 + FastAPI前后端分离、异步接口多、追求开发效率本次选用
Vue3 + Django需要现成的Admin后台、用户权限体系也可以,但Django自带Admin基本没法直接用在这个领域
Vue3 + Flask接口极少、只想快速跑通页面业务深入后会很累,Flask得自己装太多东西

这套组合唯一的代价是前后端两种语言并存,团队需要同时具备前端生态和Python生态的知识。但就"网上药店商城进销存管理系统"这类业务而言,开发速度和小步迭代的价值远大于这个代价。

2. Vue3端在落地中的几个关键决策

2.1 用Composition API组织业务逻辑,而不是把代码堆在setup里

很多Vue3教程会把所有逻辑都塞进一个setup函数里,我刚开始也是这么干的,等到商品列表页写了十几个reactive数组、五六个函数的时候,页面已经乱到没法维护了。后来我改成按业务领域拆Hook:useProductList管商品列表和搜索,useCart管购物车,useStockAlert管库存预警,useOrderSubmit管下单流程。

拆完之后,商品管理页的setup变得非常薄:

const { list, total, params, loading, fetchList, handleDelete } = useProductList() const { updateSkuStatus } = useProductSku()

每个Hook内部自己管状态、自己调接口、自己暴露方法,页面只做组合和模板绑定。这样做的好处是,采购入库单和销售出库单都涉及"选择药品"这个交互,但我只需要在不同的页面里复用useProductSelector这个Hook,药品搜索、分页、选中逻辑完全一致,不用改第二遍。

这里我想强调:Composition API的价值不是让你写更少的代码,而是让你把相同业务的代码聚在一起。选项式API时代,data和methods隔着十万八千里,想改一个业务逻辑要来回滚动屏幕;组合式函数直接把这段业务相关的状态、计算属性、方法放一起,这才是它真正的意义。

2.2 computed计算属性在库存预警和订单金额计算中的实际用法

Vue3里computed是出镜率最高的API之一,但很多人只拿它做简单的加法。在这个项目里,我用计算属性处理了两类特别典型的场景。

第一类是订单金额的实时计算。购物车里的商品可能调整数量、可能勾选不同药房的配送费规则、还有满减活动,这些叠加起来如果每次都在模板里写表达式,模板会膨胀到没法看。我把它收敛成一个computed:

const cartTotal = computed(() => { const goodsAmount = cartItems.value.reduce( (sum, item) => sum + item.price * item.qty, 0 ) const discount = calcPromotion(goodsAmount) const deliveryFee = goodsAmount >= freeShippingThreshold ? 0 : 8 return { goodsAmount, discount, deliveryFee, payable: goodsAmount - discount + deliveryFee } })

第二类是库存预警。后台首页的待办卡片上要显示"临期药品"和"低库存药品"两个列表,如果每次进入页面都重新请求接口,既慢又浪费后端资源。我选择在前端把当前页面的库存数据拉回来之后,用computed做客户端实时过滤,配合5分钟一次的刷新,体验非常顺滑。

不过要提醒一件我在项目里踩过的小事:computed默认是只读的,如果商品数量增减逻辑里想用cartTotal反推每个商品的最新行小计,那是方向错了。行小计应该由每个商品自己的计算属性负责,购物车合计永远只做加法汇总。这个边界想清楚,计算层就不会越搅越乱。

2.3 动态路由和按钮级权限:后台菜单不是写死的

网上药店进销存后台的账号分好几类角色:店长、采购、仓管、收银、财务。不同角色登录后看到的菜单和按钮完全不一样。最开始我图省事,在前端维护了一个静态路由表,每个路由的meta里写死roles,登录后再用v-if控制整个菜单栏。

结果第一个客户现场就出了幺蛾子:采购经理居然能看到销售出库单的"作废"按钮。原因是前端只能控制显示,不能控制路由是否存在——技术较强的员工直接在地址栏敲路径,还是能进入权限之外的页面。

后面我改成了真正的前端动态路由方案:登录后拿到当前用户的权限标识,用router.addRoute()按角色动态挂载路由,同时注册全局前置守卫:

router.beforeEach((to, from, next) => { if (!userStore.token) return next('/login') if (userStore.routesLoaded) return next() // 根据用户权限生成动态路由并addRoute buildDynamicRoutes(userStore.permissions) userStore.routesLoaded = true next({ ...to, replace: true }) })

按钮级权限我在项目里用了一个自定义指令v-permission,没有权限的元素直接被移除。实测下来这套组合能挡掉八成以上的越权操作,剩下的两层靠后端接口鉴权兜底。

2.4 Element Plus表单联动校验和tabs样式修改的小笔记

药品管理的表单校验比普通商品复杂。比如采购入库单录入时,选择了一个SKU后,批号是必填的,但普通商品SKU不需要批号;选择了"处方药"分类后,必须勾选"需要审方"选项,否则不能上架。Element Plus的规则是声明式的,遇到这种"根据其他字段决定当前字段是否必填"的场景,我在validator里拿到整个表单的值再动态判断:

const rules = { batchNo: [{ validator: (rule, value, callback) => { if (form.skuType === 'drug' && !value) { callback(new Error('药品必填批号')) } else { callback() } }, trigger: 'blur' }] }

另外提一个样式上的小坑。后台首页用了若干tabs切换"当日订单"、"待补货清单"、"临期药品",客户觉得默认的Tabs样式太普通,要求把激活标签改成圆角胶囊。Element Plus的样式是带scoped的,直接写在组件里不生效,必须用:deep()改动子组件内部类名:

:deep(.el-tabs__item.is-active) { background: var(--el-color-primary); color: #fff; border-radius: 16px; }

热搜词里有"vue3修改tabs标签页样式",估计大家也遇到这个问题,这里把答案写清楚:记住一条规则,在scoped样式范围内改第三方组件内部结构,一律用:deep()把选择器渗透进去,配合!important处理优先级冲突。

3. Python后端的接口设计与库存数据模型

3.1 为什么选择FastAPI而不是Django或Flask

网上药店这个业务对接口的诉求有几个特点:接口多、字段校验严格、并发集中在热销药品的扣库存操作、后续肯定要接小程序和App。基于这些,我在Python后端里选择了FastAPI。

FastAPI的几个特性在这个项目中是实打实节省了时间的。首先,Pydantic的模型校验让我不用在每个接口里手写参数判断——比如SKU编码格式、批号的日期格式、采购价的非负校验,都只要在Schema上声明约束就行,前端传错数据直接返回422。

其次,FastAPI原生支持异步,热销药品的浏览接口从同步改成async之后,并发能力提升明显,同时使用协程处理那些需要同时查库存、查价格、查促销的聚合接口非常自然。

最后是自动生成的OpenAPI文档。跟客户对接联调的时候,我把FastAPI自动生成的/docs页面直接发给对方,他们自己就能看接口契约,省了大量口头沟通成本。

3.2 库存表的数据结构:批号是药品进销存的命根子

先看商品侧:spu表存药品通用信息,sku表存具体的规格包装,比如"阿莫西林胶囊 0.25g*24粒/盒"是一个SKU。跟普通商城一样,这两张表负责前台展示。

关键在库存侧。我没有像普通电商那样在sku表里放一个stock_qty当总数,而是设计了四级:sku绑定多个stock_batch(库存批次表),每个批次记录批号、生产日期、效期、入库数量、剩余数量;stock_batch的每次数量变化都写进stock_flow(库存流水表);可售库存总数由批次汇总计算得出,不落冗余字段。

表名核心字段作用
spu药品名称、批准文号、OTC类型、通用名商品主数据
skuspu_id、规格、单位、条码、零售价具体销售单元
stock_batchsku_id、batch_no、生产日期、效期、qty_remaining批次化库存
stock_flowbatch_id、change_type、change_qty、关联单号库存变动流水
purchase_order供应商、采购总金额、状态采购单主表
sale_order用户、订单金额、状态销售订单主表

批号一旦绑定到库存批次,后续所有采购、销售、盘点、调拨都围绕batch_id展开。前台用户选中一个SKU后,系统查询该SKU下所有剩余数量大于0且未过效期的批次,把剩余数量加总成可售库存。这个设计虽然查询时多一步汇总,但换来了批号维度的完全可控,我认为非常值得。

3.3 一个入库接口里,事务和行锁是怎么配合的

库存不是把数字加一减一那么简单。拿采购入库接口举例,前端提交的是一张入库单明细,里面包含SKU、批号、数量、生产日期、有效期。后端要做的事包括:校验采购单状态、校验药品信息、写入库存批次、写库存流水、更新采购单状态,每一步都不能出错。

我强烈建议库存变动接口统一走"事务+行锁+流水"三件套。事务保证要么全部成功要么全部回滚;行锁防止两个并发入库请求同时修改同一个库存批次;流水保证解锁后可以追溯。

@router.post("/purchase/inbound") async def inbound(payload: InboundPayload, session: AsyncSession = Depends(get_db)): async with session.begin(): # 锁定采购单,防止重复入库 po = (await session.execute( select(PurchaseOrder).where(PurchaseOrder.id == payload.po_id) .with_for_update() )).scalar_one() if po.status != "APPROVED": raise HTTPException(400, "采购单不在可入库状态") for item in payload.items: batch = (await session.execute( select(StockBatch) .where(StockBatch.sku_id == item.sku_id, StockBatch.batch_no == item.batch_no) .with_for_update() )).scalar_one_or_none() if batch is None: batch = StockBatch(sku_id=item.sku_id, batch_no=item.batch_no, production_date=item.production_date, expiry_date=item.expiry_date, qty_remaining=item.qty) session.add(batch) else: batch.qty_remaining += item.qty session.add(StockFlow(batch_id=batch.id, change_type="INBOUND", change_qty=item.qty, ref_no=po.order_no)) po.status = "INBOUNDED"

这里最容易被忽略的是with_for_update()。刚开始我以为事务能解决并发问题,结果测试时开两个进程同时提交同一张采购单的入库,两张单都成功了,库存加了两次。加了行锁之后,第二个事务只能等第一个事务提交,再读到采购单状态已变成"INBOUNDED",直接拒绝入库。这个锁是库存系统的命门,不管入库还是出库,凡涉及数量变更,查询涉及的对象都要考虑加锁。

4. 进销存核心流程:从采购到销售,钱货移动怎么管住

4.1 采购入库:状态流比想象中复杂

采购流程我一开始做了三步:建单、审核、入库。后来客户说不行,药品采购还有"到货验收"环节——到货了仓库要清点数量、核对批号、检查外包装是否完好,验收不合格的拒收部分还得单独记录。所以采购单状态最终增加到了六个:草稿、待审核、已审核、待验收、已完成、已作废。

每个状态之间的流转不是自由的。已作废的单子不能重新激活;待验收状态下可以录入实际到货数量和拒收原因;验收完成才能触发入库。在系统里,我通过状态机和接口粒度把规则控制得很死:

  • 只有"已审核"的采购单允许填写验收记录;
  • 只有"待验收"的采购单允许调用入库接口;
  • 校验状态的同时,后端会比对采购明细和实际入库数量,超收数量超过5%直接拒绝入库。

4.2 销售出库:预占、扣减、取消释放

商城下单跟后台出库是同一个业务流程的两端。用户在前台提交订单后,我不会立刻扣减库存总数,而是先"预占"库存——把当前SKU下可用的批次数量锁定。预占期间其他用户看到的可售库存已经减掉了这部分,避免超卖。用户支付成功后,预占变成正式扣减;如果订单超时未支付或用户取消,预占释放。

订单状态库存操作说明
待支付预占库存锁定批次,可售数减
已支付扣减预占,生成出库单库存从锁定变已出
已取消释放预占可售数恢复
售后完成原路退回对应批次校验效期后重新入可售

这块业务里最容易翻车的是:一个订单包含多个SKU,每个SKU涉及多个批次,预占时要按效期先到先出原则优先锁定早过期的那批。我用一个简单的循环处理:

remaining = item.qty for batch in available_batches: # 按过期时间升序 if remaining <= 0: break lock_qty = min(remaining, batch.qty_remaining) create_stock_lock(order_item_id, batch.id, lock_qty) remaining -= lock_qty if remaining > 0: raise HTTPException(400, "库存不足")

这个逻辑保证了同一批次药品不会在整个订单中间被"拆没",也让售后回退时能确定是回退到哪个批号。

4.3 效期预警和滞销分析:药店老板每天睁开眼想看的数据

系统上线后,药房老板每天先看两个报表:效期预警和滞销排行。效期预警的SQL思路很直接——找出所有剩余数量大于0、且计算出的效期剩余天数小于预警阈值的批次:

SELECT sku.name, stock_batch.batch_no, stock_batch.qty_remaining, DATEDIFF(stock_batch.expiry_date, CURDATE()) AS remain_days FROM stock_batch JOIN sku ON sku.id = stock_batch.sku_id WHERE stock_batch.qty_remaining > 0 AND stock_batch.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) ORDER BY remain_days ASC;

为什么这个报表重要?因为药品不像衣服,衣服过季还能清仓,药过了效期就是纯损失,还得承担销毁成本。系统里我还加了一个开关:低于30天效期的药品,前台商品详情页直接显示"临期药品"标签;低于15天的,禁止正常销售。

滞销分析则用库存流转流水来统计——一张SKU如果连续三个月没有出库流水,或者库存周转天数为负数,后台首页会出现"滞销预警卡片",提醒运营尽快做买赠活动或者退回供应商。

5. 部署到服务器时遇到的那些实际问题

5.1 nginx配置Vue3项目的正确姿势

前端项目打包后是纯静态文件,我用nginx做静态服务器和反向代理。第一个坑是路由模式。Vue3项目如果用了createWebHistory(),刷新某个子路径时nginx会按路径去找真实文件,找不到就404,必须配置try_files把请求都转发到index.html:

server { listen 80; server_name your-domain.com; root /var/www/pharmacy-admin/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态资源缓存 location /assets/ { expires 30d; add_header Cache-Control "public, immutable"; } }

gzip压缩也别忘了开,Vue3打包出来的vendor文件经常超过1MB,不压缩首屏加载能慢3秒以上。另外,客户用Edge浏览器时偶尔反馈"系统里按钮点了没反应"之类玄学问题,多数是浏览器缓存顽固,assets目录启用上面这段永久缓存后,配合打包文件名hash,基本能解决。

5.2 后端进程、Python环境与数据库的日常维护

Python后端的部署我用了这么一套:服务器上建独立的虚拟环境,用pip install -r requirements.txt安装依赖,用uvicorn作为ASGI服务器跑FastAPI,最后用systemd托管进程,保证服务器重启后服务自动拉起:

[Unit] Description=pharmacy-fastapi After=network.target [Service] User=www-data WorkingDirectory=/opt/pharmacy-backend ExecStart=/opt/pharmacy-backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 4 Restart=always [Install] WantedBy=multi-user.target

这里有一个项目初期踩过的坑:venv没建,直接把依赖装进系统Python里,后来系统升级Python版本把依赖搞坏了,后台全挂。现在我的原则是"一个项目一个虚拟环境",不管什么语言,隔离是第一原则。数据库每天凌晨自动备份,备份文件保留最近30天,药品业务的数据千万别省这个钱。

5.3 一次线上重复出库事故:让我补了一个联合唯一索引

项目上线第二周,客服反馈说一张订单被仓库发了两遍货。我排查后发现是出库接口高并发下出了漏洞:两个仓库管理员同时处理同一订单的出库操作,后端没有对订单明细做锁校验,结果同一条销售明细生成了两条出库记录,库存也扣了两次。

修复方案分两层。第一层在业务代码里,出库前先查询订单状态,只有"已支付"且"未出库"的订单才能出库,查出后立即with_for_update()锁住订单行。第二层在数据库层面,给stock_flow表加了联合唯一索引(ref_no, ref_item_id, change_type),确保同一条业务单据的同一个明细,同一种变动类型只能有一条流水。

加完这个索引后,就算代码层万一漏了判断,数据库也会在第二次写入时抛唯一约束错误,不至于让数据静默错下去。这套"代码防业务错、数据库防数据错"的双保险,我现在做任何进销存项目都会沿用。

6. 系统跑起来之后,还可以往这几个方向扩展

6.1 GSP合规留痕和处方药流程对接

目前的系统已经记录了出入库流水,但严格来说GSP合规还要求更多留痕内容,比如供应商资质证照、药品检验报告、冷链运输温度记录。这些数据建议扩展成"资质档案"模块,跟采购单和库存批次做关联,效期快到期时同步提醒资质续期。处方药这块如果要做B2C,必须对接有资质的互联网医院进行在线问诊和电子处方开方,处方笺本身也要电子留档。这些是政策层面的硬要求,技术实现上主要是跟第三方平台对接接口,建议尽早预留回调接口的数据表。

6.2 扫码、批号标签和PDA手边作业

仓库操作员如果对着电脑录出入库单,效率低还容易录错。我在迭代计划里加入了扫码方案:采购验收时用扫码枪扫描包装上的药品条码,系统自动填充SKU和批号;出库分拣时扫订单条码,自动跳过无库存批次。有条件的仓库直接上PDA,Vue3的移动端适配加上WebSocket实时同步,仓库员工不用坐在电脑前也能收发货,出入库效率至少提升一倍。

6.3 自动补货建议与多门店调拨

进销存做到后面,最有价值的不是记账功能,而是数据洞察。把每个SKU的历史销售数据、当前库存、采购在途、效期天数全部跑一遍,就能生成"建议采购量"——效期紧的先打折清,销量高的自动提醒补货。多门店场景下,还需要设计"库存调拨单",把总仓的货调给缺货门店,同时记录收货方、发货方两个维度的库存流水,这个跟采购入库是一个套路,数据模型已经支持。

最后再分享一点我个人的体会。这套系统前后端加起来上万行代码,真正难的地方从来不是哪个表格或者接口,而是数据模型跟业务规则的映射。药品的批号、效期、GSP记录,开发时觉得多此一举,等真遇到临期药压仓、批号追溯查不到的时候,你才会发现当初多花的那两天设计时间有多值钱。做进销存系统,把进、销、存、流水的底子打好,商城界面做得朴素一点都不丢人。后面不管是接处方平台、上PDA、做自动补货,底层数据不变,扩展起来都很顺。

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

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

立即咨询