ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Flask的医院设备管理系统设计与实现

基于Flask的医院设备管理系统设计与实现 1. 开头引入医院设备科的工作说白了就是跟一堆“大件”打交道心电监护仪、呼吸机、超声、CT、输液泵、麻醉机。小到几百块的血压计大到上千万的大型影像设备每一台都关系到临床诊疗能不能正常开展。前几年我在一家区域医院信息科帮忙搭内部系统时最头疼的就是设备台账全靠Excel几张表来回传科室报修靠电话维修记录七零八落年底一盘点发现账上写着“在用”的机器实际早就在库房吃灰了。后来我用手边的Python和Flask花了两周多的时间做了一个医院医疗仪器设备管理系统就围绕台账、维修、保养、报废、统计这几条主线才算把这摊事理顺了。这篇东西主要写给三类人看一是医院信息科或设备科里想自己动手做内部工具的工程师二是正在做毕业设计、需要参考Web开发完整流程的Python学习者三是想了解Flask项目从数据库设计到部署细节的同行。我会把当时的设计思路、表结构、核心模块的实现方式以及踩过的坑都摊开讲一遍。系统本身不复杂Flask实现这类内网管理工具绰绰有余但里面关于状态流转、权限、文件上传、并发处理这些细节如果前期没想清楚后面返工很痛苦。先说结论Flask做这种中小规模的内部管理系统最大的优势是轻、快、可控一个项目一个虚拟环境装好依赖就能跑。下面我按设计拆解、数据库建模、模块实现、排坑实录这几个部分展开。2. 项目整体设计与技术选型思路1.1 为什么选Flask而不是FastAPI热词里有人拿Flask和FastAPI比我自己两个都用过简单说下感受。FastAPI的异步性能和自动生成OpenAPI文档确实好适合要对外提供高并发API接口、前后端彻底分离的场景。但医院设备管理这种系统是典型的内网、低并发、表单密集、页面服务端渲染为主的业务系统一天可能就几十个人用核心诉求不是吞吐量是开发效率、维护简单、和传统运维习惯匹配。Flask在这里有几个实打实的优势模板渲染一套搞定不需要额外搭Vue或React前端工程。Jinja2模板直接把设备列表、表单、详情页面渲染出来设备科的同事用浏览器就能操作不用我维护两套代码。生态成熟SQLAlchemy、Flask-Login、Flask-WTF这些扩展都很稳定网上资料多遇到问题搜一下就有答案。部署简单Gunicorn加Nginx就能跑哪怕用内置的dev server做内网演示也没问题。请求生命周期清晰适合快速迭代。我前期先用SQLite把业务逻辑跑通后期再切MySQL只需要改一行数据库URL和安装驱动。当然如果后面要做的不是这种“部门级工具”而是全院级的平台要对接几十个外部系统、接口频繁被第三方调用那确实应该认真考虑FastAPI。工具选型说到底看场景不是看谁新。1.2 功能模块划分与项目结构做这个系统之前我先列了设备科日常要处理的几类事情然后倒推出功能模块设备台账建档、变更、查询、导出这是所有流程的基础状态信息必须唯一且可信。出入库管理新设备入库、科室领用、退库、调拨每一步都要留痕。维修管理科室报修、工程师接单、维修记录登记、费用登记。保养管理定期保养计划比如呼吸机每季度保养一次系统要能提醒。报废与处置设备达到使用年限或维修价值不高时申请报废审批后变更状态。统计报表按科室、按设备类型统计维修次数、维修费用、设备分布情况。系统管理用户、科室、角色权限、操作日志。按照Flask的蓝图和工厂模式项目目录可以这样组织hospital_equipment/ ├── run.py # 启动入口 ├── config.py # 配置文件SECRET_KEY、数据库URL等 ├── requirements.txt ├── app/ │ ├── __init__.py # 应用工厂初始化扩展和蓝图 │ ├── models/ # SQLAlchemy 模型 │ │ ├── device.py │ │ ├── user.py │ │ ├── repair.py │ │ └── maintain.py │ ├── views/ # 蓝图 │ │ ├── auth.py │ │ ├── device.py │ │ ├── repair.py │ │ └── report.py │ ├── templates/ # Jinja2模板 │ ├── static/ # 静态文件、上传文件 │ ├── utils/ # 公共函数如分页、导出、二维码生成 │ └── extensions.py # db、login_manager等扩展实例蓝图按业务模块拆分而不是把所有路由堆在一个文件里。这样每个人改自己负责的部分互不影响也方便后期把某几个模块单独拎出来做接口。1.3 合理的方案取舍先SQLite后MySQL我第一版直接用的SQLite因为开发机上不需要装数据库服务文件型数据库对单机开发太友好了。但医院内网最终部署我建议切到MySQL原因后面排坑部分会细说主要是并发写入锁的问题。SQLite在几十个并发用户同时登记维修记录时会出现database is locked虽然概率不高但在设备科月底集中录入时容易触发。数据库URL的切换就是一个配置项的事# config.py import os BASE_DIR os.path.abspath(os.path.dirname(__file__)) class Config: SECRET_KEY os.environ.get(SECRET_KEY) or change-this-in-production SQLALCHEMY_TRACK_MODIFICATIONS False # 开发默认使用 SQLite部署时通过环境变量切换到 MySQL SQLALCHEMY_DATABASE_URI os.environ.get(DATABASE_URL) or \ sqlite:/// os.path.join(BASE_DIR, equipment.db)切换时只要设置DATABASE_URLmysqlpymysql://root:password127.0.0.1/hospital_eq?charsetutf8mb4然后重新建表即可。3. 核心功能拆解与数据库建模2.1 设备台账字段设计与表关系设备台账是整个系统的核心数据源字段设计直接决定后面统计报表好不好做。我整理设备科原有的Excel表格后最终定的核心字段如下字段说明是否必填备注device_no设备编号是全院唯一如SB-2024-0001name设备名称是比如“全自动生化分析仪”category设备分类是按《医疗器械分类目录》或院内自定义model规格型号否用于维修配件识别manufacturer生产厂家否联系售后用supplier供应商否采购来源department_id所在科室是外键关联科室表location存放位置否比如“住院楼3层设备间”purchase_date购置日期否用于计算折旧和使用年限price购置金额否统计科室设备总值warranty_end保修截止日期否提醒保修期内找厂家status设备状态是默认“在用”具体状态见后文qrcode二维码内容否冗余字段存生成后的文件名created_at建档时间是自动填充updated_at更新时间是自动更新科室表很简单id、科室名称、负责人、联系电话。用户表里加一个department_id这样普通用户登录后默认只能看本科室的设备管理员可以看全部。这种数据权限在查询时用条件拼接实现不复杂但必须有因为设备科和临床科室看到的数据范围完全不同。维修记录和保养记录各自建表关联device_id。维修记录包含报修人、故障描述、维修工程师、处理结果、维修日期、费用、更换配件。保养记录包含保养类型预防性维护PM、计量检定、巡检、计划日期、实际完成日期、执行人、结论。这里有个容易忽略的点一台设备的维修和保养是多次的如果只设计一个状态的“维修中”没法回溯历史。所以设备状态只表示当前瞬间的“位置”历史过程全部靠子表记录。2.2 设备状态机设计与流转约束设备状态不能想改就改否则台账很快就乱了。我给状态机做了明确约束状态含义可流转到的状态触发条件在用临床正常使用维修中、闲置、报废报修后转维修中科室退回后转闲置维修中正在维修或待维修在用、报废、闲置维修完成验收通过后转在用闲置未使用但可用在用、报废科室领用转在用申请报废报废已报废无审批完成状态流转代码必须集中在服务层比如DeviceService.change_status()不能在每个路由里随手改status字段不然查台账时会同时出现“同一个设备维修中又在科室领用记录里被领走”这种逻辑矛盾。我当时还加了操作日志表每次状态变更、资料修改都记录操作人、操作时间、变更前后的值。这个日志在年底审计时非常有用也是临床科室和设备科扯皮的时候最有力的依据。2.3 角色权限设计系统分了四种角色管理员设备科主任/系统管理员全部权限包括用户管理和角色分配。科室操作员临床科室护士/设备管理员查看本科室设备发起报修确认维修完成。维修工程师设备科工程师/第三方维修查看分配给自己的工单填写维修过程、费用、更换配件。访客只读只能看公开统计和部分设备信息。实现上我用Flask-Login管理登录态用login_required保证必须登录用自定义装饰器role_required(admin)做角色校验。这类内网系统不需要做特别复杂的RBAC权限模型一个用户一个角色就够两套装饰器加数据过滤条件完全覆盖需求。4. 关键模块实现与实操细节3.1 登录认证与安全会话实现登录模块用的Flask-Login密码加密存的是werkzeug.security.generate_password_hash生成的哈希坚决不允许明文。初始化方式如下# app/extensions.py from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager db SQLAlchemy() login_manager LoginManager() login_manager.login_view auth.login login_manager.login_message 请先登录用户模型里必须实现is_authenticated、is_active、get_id这些Flask-Login要求的属性。最省事的办法是继承UserMixinclass User(UserMixin, db.Model): __tablename__ users id db.Column(db.Integer, primary_keyTrue) username db.Column(db.String(32), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) role db.Column(db.String(16), defaultstaff) department_id db.Column(db.Integer, db.ForeignKey(departments.id))登录成功之后为了让模板里能判断当前用户角色可以注入一个全局模板变量app.context_processor def inject_user(): from flask_login import current_user return dict(current_usercurrent_user)另外提个容易被忽视的点登录页面的表单要做CSRF保护Flask-WTF默认开启如果用了纯手写表单也要记得在模板里渲染{{ form.hidden_tag() }}或手动生成csrf_token否则容易被跨站请求伪造攻击。内网系统不代表没有风险尤其设备管理系统里涉及维修费用、报废审批这些重要操作。3.2 设备台账的增删改查与表单校验设备新增时表单校验要做两层浏览器端用HTML5的required、min、max做基础校验服务器端必须用Flask-WTF的Form类再做一次强校验因为前端校验可以被绕过脏数据一旦进了台账后面所有统计都错。服务器端校验示例from flask_wtf import FlaskForm from wtforms import StringField, FloatField, DateField, SelectField from wtforms.validators import DataRequired, Length, Optional, NumberRange class DeviceForm(FlaskForm): name StringField(设备名称, validators[DataRequired(), Length(max50)]) device_no StringField(设备编号, validators[DataRequired(), Length(max32)]) price FloatField(设备金额, validators[Optional(), NumberRange(min0)]) purchase_date DateField(购置日期, validators[Optional()]) department_id SelectField(科室, coerceint, validators[DataRequired()]) status SelectField(状态, choices[ (in_use, 在用), (idle, 闲置), (maintaining, 维修中), (scrapped, 报废) ])设备编号唯一性要单独查重不能只依靠表单校验。我在新增和编辑两个入口都加了“查重再提交”的逻辑编辑时排除自身iddef validate_device_no(device_no, exclude_idNone): query Device.query.filter_by(device_nodevice_no) if exclude_id: query query.filter(Device.id ! exclude_id) return query.first() is None不然俩人同时录入后提交的会把先提交的编号覆盖掉。列表页的分页和模糊查询可以合并到一个函数里。筛选条件用filter链式拼接核心代码大概是这样def query_devices(page, per_page20, keyword, department_idNone, statusNone): query Device.query if keyword: like f%{keyword}% query query.filter(db.or_( Device.name.like(like), Device.device_no.like(like), Device.model.like(like) )) if department_id: query query.filter(Device.department_id department_id) if status: query query.filter(Device.status status) return query.order_by(Device.created_at.desc()).paginate( pagepage, per_pageper_page, error_outFalse)这样无论有没有条件都会走同一个分页对象模板里调用pagination.items拿当前页数据pagination.iter_pages()渲染页码。3.3 维修保养工单流转过程维修流程我是这么设计的科室操作员在设备详情页点“报修”填写故障现象、联系人工单状态为“待受理”。设备科管理员在维修工单列表看到新工单指派给某个工程师状态变为“维修中”。工程师收到任务去现场处理在系统里填写处理过程、更换配件、维修费用状态改为“待验收”。科室操作员确认设备能正常使用后点击“验收通过”状态变为“已完成”同时设备状态自动改为“在用”。如果维修费用超过设备残值管理员可以直接走报废审批流程。每一步的状态变更我都记录在repair_logs表里。工单核心表字段包括device_id、repair_no、report_user_id、fault_desc、assignee_id、status、cost、result_desc、reported_at、finished_at。这里有个小技巧工单编号可以做成RP-20240101-001这种格式用日期加当日序号方便纸质打印和电话沟通时说编号。保养模块我加了一个“待办提醒”视图计算下一次计划保养日期如果距今天小于30天就在首页仪表盘红字提醒。实现方式就是在列表页里做一次日期运算不用后台定时任务简单直接。3.4 统计报表与可视化展示统计报表我用的ECharts后端只提供JSON数据接口前端用Ajax拉数据再渲染图表。这种方式比后端生成图片灵活鼠标悬停能看到具体数值也方便导出。比如按科室统计设备数量app.route(/report/device_count_by_dept) login_required def device_count_by_dept(): rows db.session.query( Department.name, db.func.count(Device.id) ).join(Device).group_by(Department.id).all() return jsonify({ x: [r[0] for r in rows], y: [r[1] for r in rows] })页面端再初始化ECharts柱状图数据源指向这个接口。仪表盘我放了四个数字卡片设备总数、在用数量、维修中数量、本月维修费用再加两个图表科室设备分布、近六个月维修费用趋势设备科的人每天上班扫一眼就有数。3.5 设备二维码标签生成这是整个系统里同事反馈“最实在”的功能。我用qrcode库为每台设备生成一个二维码图片内容是一个URLhttp://192.168.1.50:8000/device/{id}二维码贴在设备外壳上手机扫码直接看到这台设备的全部档案和维修历史。工程师去临床科室处理故障时不用打电话回来问设备编号和型号扫一眼就全有了。生成二维码的代码很短import qrcode import os def generate_device_qrcode(device_id): url fhttp://192.168.1.50:8000/device/{device_id} img qrcode.make(url) filename fqrcode_{device_id}.png img.save(os.path.join(current_app.config[UPLOAD_FOLDER], qrcode, filename)) return filename注意内网访问地址不要写死最好配到config里不然设备科换网段后二维码全部失效重新贴一遍标签要累死人。3.6 附件上传与文件管理维修记录和台账都支持上传附件比如故障照片、验收单扫描件、保修单照片。上传处理有几个关键点扩展名白名单只允许jpg、png、pdf、docx杜绝上传可执行文件。文件名重写用uuid.uuid4().hex生成随机文件名保留原始文件名存到数据库字段里防止中文文件名和路径安全问题。大小限制MAX_CONTENT_LENGTH设置16MB超过直接拒绝。ALLOWED_EXTENSIONS {png, jpg, jpeg, pdf, docx} def allowed_file(filename): return . in filename and filename.rsplit(., 1)[1].lower() in ALLOWED_EXTENSIONS def save_upload(file_storage, sub_dirrepair): ext file_storage.filename.rsplit(., 1)[1].lower() new_name uuid.uuid4().hex . ext save_dir os.path.join(current_app.config[UPLOAD_FOLDER], sub_dir) os.makedirs(save_dir, exist_okTrue) file_storage.save(os.path.join(save_dir, new_name)) return os.path.join(sub_dir, new_name)静态文件在开发模式下没问题生产环境要把上传目录和static目录都交给Nginx托管Flask只负责业务逻辑不然一个文件下载请求也会占住一个工作进程。3.7 环境部署要点开发环境部署步骤给新手参考# 1. 安装 Python 3.8 python --version # 2. 创建虚拟环境 python -m venv venv source venv/bin/activate # 3. 安装依赖 pip install flask flask-sqlalchemy flask-login flask-wtf pymysql qrcode pillow openpyxl # 4. 初始化数据库Flask shell 或独立脚本 flask shell from app import create_app, db app create_app() with app.app_context(): db.create_all() # 5. 启动 python run.py依赖的版本在requirements.txt里锁住尤其Flask这种框架2.x和3.x之间API有差异。我部署时遇到过同一个项目在两个环境行为不一致的问题原因就是requirements.txt没锁版本最终定位到是Flask-SQLAlchemy从2.5升到3.0后查询API变化导致的。5. 实操过程中遇到的坑与排查记录4.1 中文乱码与字符集问题这大概是中文Web系统最常见的问题我在两个地方踩过一是数据库连接如果用MySQL连接串一定要带charsetutf8mb4否则存emoji表情或特殊字符时直接报错中文倒是能存但排序可能有问题。SQLite本身是UTF-8一般不涉及这个问题。二是Flask的jsonify返回中文时默认会转成\uXXXX这种Unicode转义不是真正的乱码但前端调试时看着难受。可以在app配置里设置app.config[JSON_AS_ASCII] False这样接口返回的中文就是明文了。另外代码文件头一定要加# -*- coding: utf-8 -*-Python 3默认源码就是UTF-8主要是防止在Windows上用普通记事本编辑后文件变成GBK编码导致SyntaxError。4.2 SQLite并发锁与切换MySQL的教训第一版用SQLite跑了一个多月平时十几个人用没问题。到了月底设备科集中录入验收单三个人同时点保存时系统突然报database is locked。SQLite是文件级锁写操作会把整个库锁住其他写请求只能等timeout默认的5秒超时就报错。我的处理分两步第一步把SQLite的busy_timeout调大能缓解但治标不治本第二步直接切MySQL因为医院内网本来就有MySQL服务pymysql驱动装好数据表迁移用脚本导一遍就完事。切完再没出现过锁问题。这里建议一开始就规划好生产数据库开发用SQLite没问题但上线前一定要切走不要等到用户量上来再折腾。4.3 分页排序与筛选条件失忆列表页常见的坑是用户在第3页筛选了一个条件点翻页后发现条件丢了又回到全量列表。原因是翻页链接里没有带上当前的筛选参数。解决方法是模板里生成页码URL时追加当前查询参数# 模板里 a href{{ url_for(device.list, pagep, keywordrequest.args.get(keyword, ), department_idrequest.args.get(department_id, )) }}{{ p }}/a更省事的办法是把筛选条件放到URL query里翻页时直接用request.args.get重新读取。我一开始用的是GET表单提交筛选天然能把参数带在URL里问题只是翻页时要把这些参数再拼回去。4.4 上传文件安全与路径遍历上传模块最怕的是文件名里带../../这种路径遍历字符串。虽然我用了uuid重命名但在允许用户下载原始文件名时还是要用secure_filename处理一下展示名防止构造下载路径时出幺蛾子。另外上传目录权限要设置为Web用户可写、其他用户只读。医院内网虽然相对封闭但系统里存的是设备资产数据被人恶意传个PHP脚本上来后续麻烦很大。4.5 维修工时区与日期类型陷阱Flask-WTF的DateField返回的是datetime.date而不是字符串直接塞进MySQL的DATE类型没问题但如果你在查询里用比较时就得多想一步。比如查“近三个月报修记录”如果直接拿datetime.datetime.now()和数据库里的date字段比类型不匹配会报错。统一做法是与日期字段比较时先取当天零点from datetime import datetime, timedelta start_date datetime.now().date() - timedelta(days90) rows RepairOrder.query.filter(RepairOrder.reported_at start_date).all()reported_at这个字段我用的是DateTime和date比较时会隐式按当天零点处理。为了省心整个项目里统计口径都按“自然日”算不做时区转换内网系统这是最省事的方案。4.6 生产环境静态文件404与部署细节Flask开发服务器能正常显示图片和CSS一换到Gunicorn后样式全丢多半是静态文件路由没配好。正确做法是Nginx直接代理静态目录server { listen 80; server_name hospital.internal; location /static/ { alias /opt/hospital_equipment/app/static/; expires 7d; } location /uploads/ { alias /opt/hospital_equipment/uploads/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }用Gunicorn启动Flask时建议多开几个workergunicorn -w 4 -b 127.0.0.1:8000 run:app-w 4可以根据服务器CPU核心数调整。要注意的是每个worker都会占用一定内存医院内网那台旧服务器如果只有2G内存4个worker可能会把内存打满这种情况下2个worker反而更稳。4.7 表单重复提交与按钮双击维修费用登记页用户点“提交”后由于网络卡顿又点了两次结果生成两条一样的维修记录。这种情况在后端加个唯一键约束最有效repair_no生成时带上用户ID和日期时间相同秒内提交两次时第二回就违反唯一约束抛出异常提示用户。前端也做个防御提交后立即禁用按钮并用JS改变文案为“正在提交...”双保险。4.8 忘记密码与管理员自救内网系统没有邮件服务密码忘记后管理员可以从命令行重置。我写了一个flask CLI命令app.cli.command(reset-password) click.argument(username) click.argument(new_password) def reset_password(username, new_password): user User.query.filter_by(usernameusername).first() if user: user.password_hash generate_password_hash(new_password) db.session.commit() click.echo(f密码已重置{username})这样即使管理员自己的密码也忘了只要ssh上服务器就能重置不至于推到重来。6. 写在后面的一点点扩展想法这个系统做到后面我个人的体会是所谓“管理系统的复杂度”大多数不在代码量而在业务规则是否清晰。设备状态流转、权限边界、工单状态机这三个东西想清楚了Flask的技术实现反而是体力活。被设备科同事追着改需求是常态比如“加一个计量检定超期提醒”、“维修费用要能按季度汇总导出Excel”这些需求都不难难的是当初的数据库设计没有把扩展性卡死。后续如果还要往上加东西我建议优先考虑两个方向一是对接院内已有的LIS或HIS系统把设备科的数据和临床系统打通让设备故障时自动弹出对应的在用科室和最近一次保养记录二是给维修工单加一个消息通知功能在钉钉或企业微信群里发一条“XX设备待验收”的提醒减少人工打电话确认的环节。二维码那个功能还可以延伸做扫码盘点每年年底盘点时用手机逐台扫二维码更新“实存位置”和台账自动比对出盘盈盘亏清单。设备管理这件事系统能做的只是把流程固化真正让数据“活”起来的还是把每个环节都坚持录进系统里的人。
返回列表