开年的时候,我接了一个医药仓库自动化管理系统的活。药品不像普通快消品,批次、效期、温湿度、供应商资质,任何一个字段出错都可能引发合规问题。当时团队里对后端用Flask还是Django争论了两天,最后我拍板用Flask + Vue做前后端分离,在PyCharm里从零写起。这篇文章把整个项目从需求拆解、技术选型、数据库设计、接口实现到前端联调部署的过程完整拆一遍,顺便把我踩过的坑和筛选后的解决方案都记录下来,供做医药信息化、仓库管理系统或者毕业设计的同学参考。
这个系统能做什么?核心是:药品信息管理、批次效期管理、入库出库自动化流程、库存预警、操作日志审计。Web端基于Vue,后端提供RESTful API,支持PC浏览器直接使用。开发环境是PyCharm + Python 3.9 + Flask + MySQL,前端用Vue 2 + Element UI。如果你们项目团队更熟悉Django,也可以照着这篇文章的设计思路,把视图和模型换成Django风格,业务逻辑完全通用。
1. 项目整体设计与技术选型思路
1.1 医药仓库的特殊性,决定了系统必须做什么
普通仓库管理系统管好“数量”就行了,医药仓库不行。药品是严格受控商品,管理和流通有明确的合规要求,所以系统设计的第一优先级不是炫技,而是把医药行业的业务规则内化到系统里。
我在做需求调研时,和仓库管理员聊了一个下午,整理出几个必须支持的能力:
- 批次管理:同一药品、不同批次,入库时间、生产日期、有效期都不同,不能混为一谈。
- 效期预警:药品临近有效期要自动提醒,通常默认按“失效前30天”预警,也可以自己配置。
- 双人复核:某些高风险药品入库、出库需要双人签字确认,系统要多一步审批或记录。
- 温湿度记录:冷库、阴凉库的温湿度数据要定时记录并展示,哪怕只是半自动录入,也要有留痕。
- 先进先出:出库时默认推荐效期最早的批次,防止药品积压过期。
这些需求直接决定了数据表设计。药品主表不能只存“库存数量”,必须关联批次表,每个批次有独立的库存量和效期。
1.2 Flask和Django,我为什么选了Flask而不是Django
这个项目标题里同时出现了Flask和Django,其实刚好是对国内开发者最常见的“框架二选一”纠结。我实际开发时选的是Flask,理由如下:
- Flask轻量灵活,蓝图层级清晰,适合做前后端完全分离的纯API后端。
- Django自带Admin、ORM、认证、后台管理,适合“全栈一体”的开发模式,但如果我们用Vue做前端,Django的模板和Admin反而用不上,带着两套东西一起跑有点重。
- Flask + SQLAlchemy + Flask-RESTful这套组合,写RESTful API非常顺手,中间件、装饰器都很直观。
如果你所在团队的开发习惯是“一个框架搞定所有”,或者需要快速搭出带后台管理界面的产品原型,Django的Admin能省很多事。但做前后端分离的自动化管理系统,我仍然推荐Flask:路由自由、请求生命周期清晰、调试方便,出问题时能直接定位到视图函数。
还有一个小考量:Flask的扩展生态很成熟。Flask-SQLAlchemy管数据库,Flask-Migrate管表结构迁移,Flask-JWT-Extended管认证,Flask-CORS管跨域,全部组合起来也就几行配置。
1.3 前端三件套:Vue + Element UI + Axios
前端技术栈我选了Vue 2 + Element UI,这套组合在桌面端后台管理系统里相当成熟。Vue 2虽然已经不是最新版本,但生态稳定,Element UI组件库的表格、表单、弹窗、日期选择器直接满足仓库管理页面需求,能省大量UI开发时间。
模块划分上,用vue-router做路由,Vuex做全局状态管理(存登录token、用户信息),Axios统一封装HTTP请求。库存看板用ECharts画几张趋势图和饼图,展示库存周转、效期分布。
为什么不选Vue 3 + Element Plus?如果从零开始新项目,其实推荐Vue 3。但项目当时沿用了一部分已有代码基座,所以用了Vue 2。如果你现在做新系统,直接上Vue 3 + Element Plus + Pinia完全可以,接口逻辑不受影响。
1.4 开发环境准备与PyCharm配置
开发工具是PyCharm Professional版,好处是自带数据库工具、HTTP客户端和远程部署功能,省去来回切换。
环境配置按下面的步骤走:
- 安装Python 3.9,勾选“Add Python to PATH”。
- 在PyCharm里新建项目,选择Virtualenv作为虚拟环境。
- 安装依赖:
pip install flask flask-sqlalchemy flask-migrate flask-cors flask-jwt-extended pymysql - 安装前端依赖:
vue create frontend # 选择Vue 2 cd frontend npm install element-ui axios echarts vuex vue-router - 数据库在本地或服务器装MySQL,创建数据库:
CREATE DATABASE pharma_wh DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
这里特别提醒:数据库选编码一定要用utf8mb4,别用utf8。医药信息里有生僻字、特殊符号(比如®、™),utf8mb4才能完整存下来。后面我会专门讲中文乱码问题。
2. 核心功能模块设计与数据库建模
2.1 模块拆解:从“记录库存”到“自动化管理”
我把系统拆成六个子模块,每个模块对应一组API和前端页面:
| 模块 | 核心功能 | 数据库表 |
|---|---|---|
| 药品管理 | 维护药品基础信息、分类、厂家 | drug, drug_category |
| 批次管理 | 维护每批药品的生产日期、有效期、批号 | drug_batch |
| 库存管理 | 当前库存、货位、预警数量 | inventory, shelf |
| 入库管理 | 采购入库、调拨入库,自动增加批次库存 | inbound_order, inbound_detail |
| 出库管理 | 销售出库、报损出库,支持先进先出推荐 | outbound_order, outbound_detail |
| 系统管理 | 用户、角色、权限、操作日志 | user, role, operation_log |
“自动化”主要体现在几个地方:入库后库存自动更新、出库时自动扣减对应批次库存、效期到期自动预警、库存低于阈值自动生成采购建议。这些逻辑全部在后端通过事务实现,前端只负责展示和交互。
2.2 关键表结构设计:药品、批次、库存
数据库设计是这个系统的地基,我直接说几个核心表的字段设计。
药品表(drug)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| drug_code | varchar(20) | 药品编码,唯一 |
| drug_name | varchar(100) | 药品通用名 |
| spec | varchar(50) | 规格,比如“0.5g*24粒” |
| unit | varchar(10) | 单位(盒/瓶/袋) |
| manufacturer | varchar(100) | 生产厂家 |
| approval_number | varchar(50) | 批准文号 |
| category_id | int | 分类外键 |
| min_stock | int | 最低库存阈值 |
| status | tinyint | 启用/停用 |
批次表(drug_batch)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| drug_id | int | 关联药品 |
| batch_no | varchar(50) | 生产批号 |
| produce_date | date | 生产日期 |
| expire_date | date | 有效期至 |
| quantity | int | 当前批次可用库存 |
| supplier_id | int | 供应商外键 |
库存表(inventory)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| drug_id | int | 药品外键 |
| batch_id | int | 批次外键 |
| shelf_code | varchar(20) | 货位编码 |
| quantity | int | 货位库存 |
这里的核心设计思路是:把“药品”和“批次”分离。同一药品可以有多批库存,每批独立记录效期和数量。出库时可以通过SQL查询expire_date升序排列,实现先进先出。
2.3 自动化预警与审批流设计
预警是医药仓库的刚需。系统每天定时扫描(或者在库存查询接口中实时计算),返回三类数据:
- 效期预警:
expire_date离当前日期小于30天(或者自定义天数)的批次。 - 库存预警:当前总库存小于
min_stock的药品。 - 过期药品:
expire_date小于当天日期的批次,这类药品不允许出库,只允许报损。
审批流用状态机实现。入库单和出库单都有状态字段:pending(待审核)、approved(已审核)、rejected(拒绝)、done(已完成)。审批人通过后,系统才真正变更库存,保证“账实一致”。
3. 后端API设计与实现
3.1 Flask项目结构与初始化
Flask项目不能把一堆路由写在一个文件里,我按模块拆成蓝图(Blueprint)。目录结构如下:
pharma_wh_backend/ ├── app/ │ ├── __init__.py # 应用工厂 │ ├── extensions.py # SQLAlchemy、JWT等扩展实例 │ ├── models/ # 数据库模型 │ ├── apis/ # 蓝图路由 │ │ ├── auth.py │ │ ├── drug.py │ │ ├── inbound.py │ │ ├── outbound.py │ │ ├── inventory.py │ │ └── system.py │ ├── utils/ # 公共工具 │ └── config.py # 配置 ├── migrations/ # Flask-Migrate迁移文件 └── run.py # 启动入口应用工厂主要是为了“配置可插拔”。开发、测试、生产环境用不同的配置文件,启动时不加载多余的测试数据。
3.2 认证与权限实现:JWT + 装饰器
医药系统必须严格区分角色。我设计了三种角色:管理员、仓管员、审核员。管理员管药品和用户,仓管员做入出库单,审核员负责审批。
认证方式采用JWT。登录接口获取token,前端每次请求在Authorization头里带上token。
核心代码示例(简化版):
from flask_jwt_extended import create_access_token, jwt_required, get_jwt_identity @app.route('/api/auth/login', methods=['POST']) def login(): data = request.get_json() user = User.query.filter_by(username=data['username']).first() if user and user.check_password(data['password']): access_token = create_access_token(identity=user.id, additional_claims={'role': user.role}) return {'code': 0, 'data': {'token': access_token, 'role': user.role}} return {'code': 1, 'msg': '用户名或密码错误'}, 401权限校验用自定义装饰器:
from functools import wraps from flask_jwt_extended import get_jwt def role_required(*roles): def wrapper(fn): @wraps(fn) @jwt_required() def decorator(*args, **kwargs): claims = get_jwt() if claims.get('role') not in roles: return {'code': 1, 'msg': '无权限操作'}, 403 return fn(*args, **kwargs) return decorator return wrapper注意:JWT的
additional_claims里不要放敏感信息,角色这种可以放。过期时间建议设置成半天,并允许前端在token过期时静默刷新。
3.3 入库接口的核心逻辑:事务保证数据一致性
入库操作不是简单地给库存加数量,还涉及批次创建、流水记录、状态变更。我会用数据库事务保证要么全部成功,要么全部回滚。
入库单创建接口的业务流程:
- 校验当前用户权限(仓管员)。
- 验证药品ID、数量、批次信息是否合法。
- 在事务中执行:
- 新增
inbound_order记录,状态为pending。 - 新增
inbound_detail记录,关联药品、批次、数量。 - 创建或更新
drug_batch记录。
- 新增
- 审核通过后,调用“确认入库”接口,更新批次库存和总库存。
“确认入库”的核心代码:
from flask_sqlalchemy import session as db_session def confirm_inbound(order_id): order = InboundOrder.query.get(order_id) if order.status != 'approved': raise ValueError('当前订单不可入库') for detail in order.details: batch = DrugBatch.query.filter_by( drug_id=detail.drug_id, batch_no=detail.batch_no ).first() if not batch: batch = DrugBatch( drug_id=detail.drug_id, batch_no=detail.batch_no, produce_date=detail.produce_date, expire_date=detail.expire_date ) db_session.add(batch) db_session.flush() # 立即获取自增ID batch.quantity += detail.quantity inventory = Inventory.query.filter_by( drug_id=detail.drug_id, batch_id=batch.id, shelf_code=detail.shelf_code ).first() if inventory: inventory.quantity += detail.quantity else: inventory = Inventory( drug_id=detail.drug_id, batch_id=batch.id, shelf_code=detail.shelf_code, quantity=detail.quantity ) db_session.add(inventory) order.status = 'done' db_session.commit()实现过程中,我遇到了一个典型问题——多次查询数据库导致性能很差。批量入库时如果有几百条明细,循环里逐条query会慢。优化方案是先把所有批次号查出来,用Python字典做映射,或者用SQL的update...where in批量更新。
3.4 出库接口:先进先出与库存扣减
出库时系统自动推荐效期最近的批次,仓管员也可以手动指定批次。后端实现逻辑:
def auto_find_batches(drug_id, need_quantity): # 查询当前药品所有批次,按效期升序、数量降序排列 batches = DrugBatch.query.filter( DrugBatch.drug_id == drug_id, DrugBatch.expire_date >= date.today(), DrugBatch.quantity > 0 ).order_by(DrugBatch.expire_date.asc(), DrugBatch.quantity.desc()).all() result = [] remaining = need_quantity for b in batches: if remaining <= 0: break take = min(b.quantity, remaining) result.append({'batch_id': b.id, 'quantity': take}) remaining -= take if remaining > 0: raise ValueError('可用库存不足') return result然后就是扣减库存的循环。扣减时一定要放在事务里,同时用行级锁避免并发出库导致库存变负数。
from sqlalchemy import func batch = DrugBatch.query.filter_by(id=batch_id).with_for_update().first() if batch.quantity < take_quantity: raise ValueError('批次库存不足') batch.quantity -= take_quantitywith_for_update()是对数据库行加锁,防止两个请求同时读到同一个库存量,各自扣减后把负数写回去。药品出库必须严格禁止负数库存,哪怕系统崩溃也不能出现。
4. 前端Vue页面实现与联调
4.1 前端项目结构与路由配置
前端目录我按功能模块划分:
frontend/src/ ├── api/ # 请求接口封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由表 ├── store/ # Vuex状态 ├── views/ │ ├── login.vue │ ├── dashboard.vue # 数据看板 │ ├── drug/list.vue │ ├── inbound/list.vue │ ├── outbound/list.vue │ ├── inventory/list.vue │ └── warning/list.vue路由懒加载采用按需导入,减少首屏体积:
const router = new VueRouter({ routes: [ { path: '/login', component: () => import('../views/login.vue') }, { path: '/dashboard', component: () => import('../views/dashboard.vue') }, // ... ] })4.2 封装axios拦截器:统一处理token和异常
axios不能直接用,我做了统一封装。请求拦截器在每次请求前添加token,响应拦截器统一处理HTTP错误码和业务错误码。
import axios from 'axios' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use(response => { const res = response.data if (res.code !== 0) { if (res.code === 401) { // 跳转登录页 } // 弹出错误提示 return Promise.reject(new Error(res.msg)) } return res.data }, error => { // HTTP错误处理 return Promise.reject(error) })注意:如果后端接口返回字段有
code、data、msg这种封装,前端拦截器统一拆包,那么页面里就不需要每个请求都重复判断code,会让代码干净很多。
4.3 库存看板页面:数据可视化的价值
仓库管理最需要“一眼看清全局”。我在首页做了一个数据看板,展示五类信息:
- 今日入库、出库数量
- 库存总数(总盒数)
- 效期预警列表(最近30天到期)
- 库存周转率柱状图(最近12个月出库量)
- 各分类库存占比饼图
这些图表用ECharts实现。用Vue + ECharts的常见写法:
import * as echarts from 'echarts' mounted() { this.chart = echarts.init(this.$refs.chartDiv) this.loadData() }图表的数据来源是后端聚合接口,比如:
@app.route('/api/report/inventory-trend') def inventory_trend(): # 统计最近12个月每个月入库、出库的总量 rows = db.session.execute( ''' SELECT DATE_FORMAT(create_time, '%Y-%m') as month, SUM(CASE WHEN type='inbound' THEN quantity ELSE 0 END) as in_qty, SUM(CASE WHEN type='outbound' THEN quantity ELSE 0 END) as out_qty FROM inventory_flow WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) GROUP BY month ORDER BY month ''' ).fetchall() return {'code': 0, 'data': {'months': [r[0] for r in rows], 'in': [r[1] for r in rows], 'out': [r[2] for r in rows]}}4.4 前后端联调经验:跨域与代理
本地开发时Flask运行在5000端口,Vue在8080端口,存在跨域问题。我没有在Flask里手动配置CORS麻烦的东西,而是用Vue CLI的devServer代理:
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } } } }这样前端请求/api/drug/list时,devServer会把请求转发到http://localhost:5000/api/drug/list,浏览器端没有跨域问题,后端也不需要处理CORS。生产环境部署时用Nginx统一反向代理,这个问题自然不存在。
联调时经常遇到接口字段对不上的问题。我的经验是:前后端一定要提前约定好接口文档,哪怕简陋一点,也要把所有字段名、类型、嵌套结构写清楚。否则前端拿list后端给rows,前端取expire_date后端返回expireDate,这种问题排查起来非常浪费时间。规范是全部用小写蛇形命名贯穿前后端,禁止前端私自转驼峰。
5. 系统部署与打包上线经验
5.1 本地运行配置
后端正涉及迁移,我直接用Flask-Migrate管理表结构:
flask db init flask db migrate -m "create pharma tables" flask db upgrade前端打包:
npm run build构建产物在frontend/dist目录下,里面有index.html和static目录。
5.2 使用Nginx + Gunicorn部署Flask
开发环境直接运行flask run没问题,但生产环境这样跑性能不行。我用Gunicorn做WSGI服务器,Nginx做反向代理和静态文件服务。
Gunicorn启动命令:
gunicorn -w 4 -b 127.0.0.1:5000 run:appNginx配置示例:
server { listen 80; server_name your-domain.com; location /api/ { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { root /home/admin/frontend/dist; index index.html; try_files $uri $uri/ /index.html; } }注意try_files指令,如果前端用history路由模式,必须加这一行,否则刷新页面时会404。
5.3 生产环境遇到的三个典型问题
问题1:静态资源404
前端打包后,图片、CSS、JS路径默认是绝对路径/static/xxx。如果你的部署路径不是域名根目录,要配置publicPath,比如:
// vue.config.js module.exports = { publicPath: '/' }问题2:接口偶尔超时
部分报表接口涉及跨表聚合,数据量大时在1秒内响应有压力。优化方案是给关键查询加索引,比如drug_batch(drug_id, expire_date)、inventory_flow(create_time)。另外,Gunicorn超时时间也调长一点:
gunicorn -w 4 --timeout 60 -b 127.0.0.1:5000 run:app问题3:数据库连接池耗尽
Flask-SQLAlchemy默认的连接池参数在生产环境不够用,会出现OperationalError: too many connections。我在config里加:
SQLALCHEMY_ENGINE_OPTIONS = { 'pool_size': 10, 'pool_recycle': 3600, 'pool_pre_ping': True }pool_pre_ping会在每次从连接池取连接前检查连接是否有效,避免MySQL的空闲连接被服务端断开后报错。
5.4 数据备份与安全建议
医药数据不能丢,忘记备份是不可原谅的。我写了一个定时任务脚本,每天凌晨2点用mysqldump备份并保留最近7天:
0 2 * * * mysqldump -u pharma_admin -p'pass' pharma_wh > /backup/pharma_wh_$(date +%Y%m%d).sql备份文件同步到异地或者对象存储。防止服务器磁盘故障导致全部数据丢失。
另外安全上需要注意:
- 用户密码必须用哈希存储,我用Werkzeug自带的
generate_password_hash。 - JWT密钥不要硬编码在代码里,用环境变量注入。
- 管理后台的删除操作不要做物理删除,改为软删除,保留数据痕迹。
6. 踩坑记录与问题排查实录
6.1 效期计算:时区与日期的坑
药品效期按自然日算,比如有效期至“2025-03-01”,意味着3月1日当天还可以用,3月2日就是过期。最开始我直接用Python的datetime.now()获取当前时间,然后和expire_date比较。结果有一段时间发现“提前一天预警”的现象。
排查后发现是MySQL的CURDATE()和Python的date.today()用的时区不一致,服务器时区是UTC,导致日期偏移。解决办法是统一在代码里使用datetime.date.today(),并且数据库连接串加参数?useUnicode=true&characterEncoding=utf8mb4,同时保证服务器时区设成Asia/Shanghai。
6.2 并发出库导致库存变负数
上线后遇到过两次出库成功后库存变负数,是个严重的bug。原因是两个仓管员同时提交了同一个药品的出库单,两个请求同时读到批次剩余数量都是10,各自扣5,写完都变5,实际应该剩余0。后来加上了with_for_update()行锁,问题解决。
用Flask-SQLAlchemy时要注意,开启锁必须在事务内,并且锁住的查询要和更新操作在同一事务里。如果用了db_session.commit(),锁会释放,必须等整个操作完成后再提交。
6.3 中文乱码与数据库编码
系统刚装好时,药品名称含“阿莫西林胶囊”没问题,但一保存特殊字符“®”就报错或者变成乱码。原因就是MySQL数据库和表的字符集默认是latin1。解决方法是建库时指定utf8mb4,同时Flask连接串里写清楚:
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://user:password@localhost/pharma_wh?charset=utf8mb4'还有个容易忽略的点:mysql字符集的排序规则。utf8mb4_unicode_ci适合通用场景,如果要对敏感数据做精确匹配,建议用utf8mb4_bin。医药仓库的业务查询主要是模糊搜索药品名和批号,用unicode_ci完全没问题。
6.4 前端日期显示偏移
ECharts图表里日期显示正常,但表格里的日期比数据库里少一天。这是JSON序列化的时间对象带了UTC时区,导致前端解析时减了一小时。解决办法是后端所有日期字段统一返回字符串格式YYYY-MM-DD,不要返回Date对象。批次表的produce_date和expire_date都直接转成字符串,前端就当字符串处理,不搞new Date()。
6.5 常见问题速查表
| 问题 | 现象 | 解决方法 |
|---|---|---|
| 登录提示非法token | 前端token丢失或过期 | 检查axios拦截器是否把token正确放入header,检查JWT过期时间 |
| 接口返回500 | Flask日志出现KeyError | 多半是前端传参名称不对,用Postman测接口定位 |
| 新增药品时报表单加载不出来 | 分类接口跨域 | 本地用devServer代理,生产用Nginx统一转发 |
| 出库找不到批次 | 查询时过滤了过期批次 | 检查expire_date >= today的过滤条件 |
| 库存汇总重复计数 | JOIN了详情表和库存表 | 检查SQL聚合时有没有用DISTINCT或子查询 |
最后再分享两个实际操作中的经验
第一,医药仓库系统的核心不是页面漂不漂亮,而是数据准确性和审计轨迹。哪怕是用Flask这种轻量框架,也要从一开始就设计好操作日志表,谁在什么时间做了什么事,包括改了什么字段、从哪里改成了什么,都必须记录。后续如果要做合规审计,这些日志就是系统的命根子。
第二,千万别把业务逻辑全塞到视图函数里。我后来重构了一下,把所有库存变动抽成了独立的Service层,视图函数只负责接受参数、调用服务、返回结果。这样不仅测试好写,后续加新功能也不容易把接口搞乱。比如出库服务、入库服务、盘点服务都放在app/services/下,每个服务里再严格管理事务边界。
如果你们也要做类似的系统,建议第一步不是写代码,而是先画清楚业务流程图。把入库、出库、调拨、盘点、报损这些操作的数据流走一遍,再开始建表。这套系统做完之后,我感觉Flask + Vue的组合在中小型医药仓库场景里,完全够用,而且维护成本不高。Django虽然大而全,但对这种纯API后端来说,Flexible加分不够,反而是轻量带约束的Flask,能让你把注意力集中在业务逻辑本身。