简介:一份面向Python毕业设计的街区医院管理系统完整项目,内含毕业论文与可运行源码。系统基于Python语言、Django框架和MySQL数据库,采用B/S架构,实现管理员、医生、患者三类角色的注册登录、信息管理与业务处理功能。论文部分从选题背景、需求分析到数据库设计、模块实现、系统测试均有详细阐述,并附有系统流程图、E/R图、数据库表及测试用例,适合计算机相关专业学生参考。压缩包大小约34.66MB,为zip格式,内容涵盖项目源码与论文文档。源码中Django项目结构、模板文件、静态资源及数据库脚本可供直接运行或二次开发,论文文档亦可编辑修改。目前已有54人学习。下载后可获得完整课题方案、可运行代码与论文框架,能帮助理清街区医院管理系统的开发流程,节省选题与搭建时间,也可作为答辩准备和功能扩展的基础。
1. 从手工记账到 Python 街区医院管理系统:别把毕设做成 CRUD
街区医院的日常听起来不复杂:挂号、就诊、开方、收费、发药。但靠 Excel 和纸质处方运转时,各个环节是断开的,挂号窗口不知道今天哪位医生还有号源,药房不知道库存何时见底,月底对账只能逐条人工核对。这个基于 Python 的街区医院管理系统,把患者档案、医生排班、挂号记录、处方明细、收费流水、药品库存全部串进一套 Web 系统,用 Django 做后端,MySQL 存数据,页面由模板直接渲染。对做 Python 毕业设计的同学来说,这是最能体现水平的选题类型:它以完整业务链路覆盖 ORM、状态机、事务、并发等论文里会被追问的技术点;源码包自带论文,读时可以逐章对照代码理解需求分析和数据库设计。适合正在找 python 课程设计、毕业设计参考的本科生,也适合给社区诊所搭建管理原型的开发者。
2. Django 模型设计与 MySQL 表结构:先把数据地基打牢
2.1 选 Django 而不是 Flask 的理由
接到这个题目时,很多人第一反应是找个 Flask 模板改一改。Flask 确实轻,但街区医院管理系统最终要交付的不是一个接口 Demo,而是一套带页面、带权限、带后台管理的完整系统,并且论文里要有系统架构图、功能模块图、数据库表结构说明。Django 的 MTV 架构可以对应论文里的“表现层-业务层-数据层”,自带的 Admin 后台可以免开发直接截图,ORM 迁移机制让数据库结构由模型代码生成,论文的数据库设计章节不会出现“代码和表结构对不上”的情况。
选型时还可以和 FastAPI 对比一下。FastAPI 擅长异步接口和 API 文档,但它的默认形态是返回 JSON,不太适合“医生点按钮、收费员敲键盘”的多页面操作场景。如果非要用 FastAPI,前端还得搭配 Vue 或 React,技术栈会膨胀,答辩时被问到“前后端分离的边界在哪里”很容易露怯。这个项目我一般用 python 3.10 搭配 Django 3.2 LTS,Jinja2 模板渲染页面,依赖安装简单,pip install -r requirements.txt一条命令就能复制出环境。
提示:python 版本和 Django 主版本尽量选稳定组合,不要为了追新用刚发布的大版本,模板语法和第三方库兼容性在毕设答辩期容易出问题。
2.2 核心实体关系:从业务主线拆表
街区医院的业务主线是:患者建档 → 挂号 → 医生就诊 → 开处方 → 收费 → 发药 → 扣库存。沿着这条线,需要先把数据表拆出来:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| patient | 患者档案 | name, gender, id_card, phone, created_at |
| department | 科室 | name, location, description |
| doctor | 医生 | name, title, department_id, max_registration, schedule |
| registration | 挂号记录 | patient_id, doctor_id, register_date, period, status |
| prescription | 处方 | registration_id, diagnosis, is_paid, created_at |
| prescription_item | 处方明细 | prescription_id, drug_id, quantity, amount |
| drug | 药品 | name, spec, price, stock, unit |
| charge_record | 收费记录 | registration_id, amount, pay_method, created_at |
这几张表的关系是:患者和挂号一对多,医生和挂号一对多,挂号与处方一对一,处方与药品多对多并通过处方明细表关联,收费记录与挂号一对一。
为什么处方明细要单独成表?因为一张处方可能开三种药,每种药的单价、数量都不一样。如果只在 prescription 里放一个总金额,收费时就没法追溯“这笔钱是哪几种药组成的”,药房发药时也不知道该拿哪几种药。所以中间的 prescription_item 表是必要的,它的 amount 字段可以在开方时带出,也可以在收费时统一计算。
2.3 模型代码怎么落
先定义基础资料模型,科室和医生属于最稳定的静态数据:
from django.db import models class Department(models.Model): name = models.CharField('科室名称', max_length=50) location = models.CharField('位置', max_length=100) def __str__(self): return self.name class Doctor(models.Model): TITLE_CHOICES = [ ('junior', '住院医师'), ('attending', '主治医师'), ('chief', '主任医师'), ] name = models.CharField('姓名', max_length=30) department = models.ForeignKey( Department, on_delete=models.PROTECT, verbose_name='所属科室', ) title = models.CharField('职称', max_length=20, choices=TITLE_CHOICES, default='junior') max_registration = models.PositiveIntegerField('单时段最大号源数', default=20) schedule = models.CharField('出诊时间', max_length=100, blank=True)on_delete=models.PROTECT表示科室下面还有医生时不允许删除,避免档案级联丢失,这一条在论文“数据完整性设计”里可以单独写一段。max_registration用PositiveIntegerField相当于在数据库层加非负约束,比在视图里手动判断更安全。choices枚举值在 Admin 后台会渲染成下拉框,演示时不用费劲解释取值范围。
然后是挂号记录,这是整个系统里状态最复杂的模型:
class Registration(models.Model): STATUS_CHOICES = [ ('pending', '待就诊'), ('in_progress', '就诊中'), ('finished', '已完成'), ('cancelled', '已取消'), ] patient = models.ForeignKey(Patient, on_delete=models.CASCADE, verbose_name='患者') doctor = models.ForeignKey(Doctor, on_delete=models.PROTECT, verbose_name='医生') register_date = models.DateField('就诊日期') period = models.CharField('时段', max_length=2, choices=[('am', '上午'), ('pm', '下午')]) status = models.CharField('状态', max_length=10, choices=STATUS_CHOICES, default='pending') created_at = models.DateTimeField('创建时间', auto_now_add=True) def __str__(self): return f'{self.patient.name}-{self.doctor.name}-{self.register_date}'auto_now_add=True会在创建时自动写入当前时间,人工不需要传这个值。status字段是系统的状态枢纽,后续的收费模块只允许对“已完成”的挂号生成收费记录,药房发药也只允许对已缴费的处方操作,这样整个流程不会出现“还没看病就收费”的情况。
最后是处方主表。校区医院里一张处方对应一次就诊,所以用OneToOneField而不是普通外键:
class Prescription(models.Model): registration = models.OneToOneField(Registration, on_delete=models.CASCADE, verbose_name='挂号') diagnosis = models.TextField('诊断结果') is_paid = models.BooleanField('是否已缴费', default=False) dispensed_at = models.DateTimeField('发药时间', null=True, blank=True)模型定义完,需要同步到 MySQL:
python manage.py makemigrations hospital python manage.py migrate第一次运行makemigrations会生成一个迁移文件,它只记录表结构的变更,不真正写数据库;migrate才是把变更应用到 MySQL。如果改了模型却忘记生成迁移,直接runserver会报模型和实际表结构不一致的错误。新建开发库后只要跑完这两条命令,表结构和模型就同步了。
3. 挂号、收费、发药三模块实现:把业务链路串起来
3.1 挂号模块:检查号源、生成凭条、维护状态
挂号是系统的第一个入口。前端表单拿到patient_id、doctor_id、period、register_date之后,后端要做的不只是创建一条记录,还要先检查这个医生、这个时段是否还有号源。我先给出一个不带锁的直观写法:
def create_registration(patient_id, doctor_id, period, register_date): doctor = Doctor.objects.get(pk=doctor_id) current_count = Registration.objects.filter( doctor=doctor, period=period, register_date=register_date, ).exclude(status='cancelled').count() if current_count >= doctor.max_registration: raise ValueError('该时段号源已满,请选择其他时段') return Registration.objects.create( patient_id=patient_id, doctor=doctor, period=period, register_date=register_date, )这个函数放在services.py里,视图只负责调用。这样做的直接好处是可以脱离 HTTP 请求写单元测试,论文里的功能测试章节可以直接引用这个函数。exclude(status='cancelled')是关键,已取消的挂号不占号源,如果不排除,系统跑一天后所有时段都会显示满号。register_date由前端传值,医生没有排班的日期在页面下拉框里就不会出现。
视图和路由对应如下:
# views.py from django.shortcuts import render, redirect from django.contrib import messages def registration_create(request): if request.method == 'POST': try: reg = create_registration( patient_id=request.POST['patient_id'], doctor_id=request.POST['doctor_id'], period=request.POST['period'], register_date=request.POST['register_date'], ) messages.success(request, f'挂号成功,凭条号:{reg.id:06d}') return redirect('registration_detail', pk=reg.id) except ValueError as e: messages.error(request, str(e)) return render(request, 'registration_form.html', {'doctors': Doctor.objects.all()}) # urls.py urlpatterns = [ path('registration/new/', views.registration_create, name='registration_create'), path('registration/<int:pk>/', views.registration_detail, name='registration_detail'), ]凭条号直接取主键并格式化成 6 位,比如000012。街道小诊所不需要全局唯一的订单号,用主键最省事;如果业务要求更高,可以用 uuid,但论文里的表结构会因此多出一个字段。挂号状态只能沿固定方向流转:
| 操作 | 原状态 | 新状态 | 操作模块 |
|---|---|---|---|
| 提交挂号 | - | pending | 挂号 |
| 医生叫号 | pending | in_progress | 就诊 |
| 医生完成诊断并开方 | in_progress | finished | 就诊 |
| 患者取消挂号 | pending | cancelled | 挂号 |
所有修改状态的位置都需要校验原状态,pending不能直接跳到finished,这条规则对应的就是论文里的业务流程图。
3.2 收费模块:以处方明细为准,不要手填总价
收费模块最容易出现的实现错误是让收费员手填金额。正确做法是遍历处方明细逐项计算,然后一次性生成收费记录。先补充处方明细模型:
class PrescriptionItem(models.Model): prescription = models.ForeignKey(Prescription, on_delete=models.CASCADE, verbose_name='处方') drug = models.ForeignKey(Drug, on_delete=models.PROTECT, verbose_name='药品') quantity = models.PositiveIntegerField('数量', default=1) amount = models.DecimalField('金额', max_digits=10, decimal_places=2, default=0)计算金额的函数:
def calculate_prescription_amount(prescription_id): items = PrescriptionItem.objects.filter( prescription_id=prescription_id ).select_related('drug') total = 0 for item in items: item.amount = item.drug.price * item.quantity item.save() total += item.amount return totalselect_related('drug')会把关联的药品数据一次性查出来,避免循环里每条明细都发一次 SQL,这就是最常见的 N+1 查询问题。金额字段用DecimalField而不是FloatField,因为浮点数计算 0.1+0.2 会出现尾数误差,金额做累加时差一分钱很难排查。
生成收费记录时要把“创建流水”和“标记已缴费”放进同一个事务:
from django.db import transaction @transaction.atomic def do_charge(registration_id, pay_method): reg = Registration.objects.select_for_update().get(pk=registration_id) if reg.status != 'finished': raise ValueError('患者尚未完成就诊,不能收费') prescription = Prescription.objects.select_for_update().get(registration=reg) if prescription.is_paid: raise ValueError('该处方已经收费,请勿重复操作') total = calculate_prescription_amount(prescription.id) ChargeRecord.objects.create( registration=reg, amount=total, pay_method=pay_method, ) prescription.is_paid = True prescription.save() return totaltransaction.atomic保证创建收费记录和更新处方缴费状态要么都成功,要么都失败。两个select_for_update分别在 InnoDB 层锁住挂号行和处方行,防止两个收费窗口同时操作同一个处方。
3.3 药房发药:库存不足不能发,未缴费更不能发
药房发药的校验逻辑是有顺序的:先确认处方已经缴费,再检查每种药品的库存,最后才扣减。只要有一个条件不满足,整个发药动作就要停下来。
def dispense_drug(prescription_id): prescription = Prescription.objects.select_related('registration').get(pk=prescription_id) if not prescription.is_paid: raise ValueError('处方未缴费,不能发药') items = PrescriptionItem.objects.filter(prescription=prescription).select_related('drug') for item in items: drug = item.drug if drug.stock < item.quantity: raise ValueError(f'{drug.name} 库存不足') drug.stock -= item.quantity drug.save() prescription.dispensed_at = timezone.now() prescription.save()这里用的是“读出来→判断→写回去”的方式,逻辑好理解,但在并发场景下会有丢减问题。两个窗口同时发同一种药,各自读到 stock=5,各自扣 1,最后写回的都是 4,实际却发出去两盒。这个问题的标准解法我放在下一章,配合号源并发一起处理。
4. 权限、并发与事务:答辩时最容易追问的三个技术点
4.1 角色权限:用 Django Group 而不是拍脑袋加字段
街区医院里至少有三种角色:挂号员、医生、收费员,再加上管理员。常见错误是在 User 表加一个role字段,然后代码里到处if user.role == 'doctor'。这样能跑,但论文里“权限设计”一节会显得单薄。Django 自带auth.Group,用分组加装饰器控制页面访问,是更规范的常见做法。
from django.contrib.auth.decorators import login_required, user_passes_test def is_doctor(user): return user.groups.filter(name='doctor').exists() def is_cashier(user): return user.groups.filter(name='cashier').exists() @login_required @user_passes_test(is_doctor) def doctor_prescription(request, registration_id): ...@login_required先拦截未登录用户,@user_passes_test(is_doctor)再校验角色。两个装饰器有顺序要求,反过来的话,未登录用户会先被打回登录页,逻辑上虽然也能跳转,但会暴露一次判断的差异。groups.filter(name='doctor')里的 group 名称需要在 Admin 后台创建,并把用户加入对应分组,代码里不负责自动建组。
4.2 号源并发:count 判断会怎么被穿破
第三章的create_registration有一个并发漏洞:两个请求同时查到current_count = 9,而max_registration = 10,双方都认为还剩一个号,结果都去创建记录,实际挂号变成 11 条。要解决这个问题,最稳妥的办法是引入号源表,把剩余号源变成数据库里的一行数据:
class Schedule(models.Model): doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE, verbose_name='医生') work_date = models.DateField('出诊日期') period = models.CharField('时段', max_length=2, choices=[('am', '上午'), ('pm', '下午')]) remaining = models.PositiveIntegerField('剩余号源', default=20)扣减号源时用条件更新,不用“先查再改”:
from django.db.models import F rows = Schedule.objects.filter( doctor=doctor, work_date=register_date, period=period, remaining__gt=0, ).update(remaining=F('remaining') - 1) if rows == 0: raise ValueError('号源已满')update会在数据库层面把remaining减 1,F('remaining')保证读取和写入是原子操作,不受 Python 端旧值缓存影响。两个请求同时进来时,InnoDB 会锁住这一行,第二条等待第一条提交后再判断remaining__gt=0,不会出现都减成功的场景。这种模式同样适用于药品库存扣减,比drug.stock -= 1少了一个时间窗口。
提示:
filter(...).update(...)返回的是受影响行数,不是对象列表。返回 0 就说明条件不满足,这条逻辑是后续所有“防超卖”判断的核心依据。
4.3 事务:收费和发药必须同时成或同时败
收费成功但扣库存失败,账目和实物就对不上;库存扣了但收费失败,就会出现白拿药。常见做法是把收费和发药放进同一个事务,任一步抛错就整体回滚。
@transaction.atomic def charge_and_dispense(registration_id, pay_method): do_charge(registration_id, pay_method) prescription = Prescription.objects.get(registration_id=registration_id) dispense_drug(prescription.id)do_charge和dispense_drug内部都用了select_for_update,但整个流程处在外层同一个事务里,两个行锁按顺序获取,只要所有事务都保持“先收费、后发药”的访问顺序,就不会形成死锁。如果出现循环等待,多半是某个函数改了顺序。
这个消息可以在 shell 里直接验证回滚是否生效:
python manage.py shell -c " from hospital.services import charge_and_dispense try: charge_and_dispense(1, 'cash') except ValueError as e: print('回滚成功:', e) "可以故意把某个药品的库存改成 0,再执行上述命令。如果ChargeRecord表中没有新增记录,说明transaction.atomic让收费动作跟着回滚了,这个操作答辩时现场做一次,比截图有说服力得多。
5. 源码运行与自动化验证:让毕设当场跑通
5.1 本地启动步骤
拿到源码之后先在项目根目录创建虚拟环境,把依赖装进隔离环境里,避免污染全局 python:
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install -r requirements.txt python manage.py migrate python manage.py createsuperuser python manage.py runservermigrate会按照第二章的模型自动建表,createsuperuser之后才能进入 Admin 后台配置科室、医生、药品。启动后先打开http://127.0.0.1:8000/admin录入基础数据,再访问挂号页面做业务操作,顺序反了会出现医生列表为空。
5.2 造演示数据:用 bulk_create 而不是循环 save
演示时如果只有三五条患者数据,答辩画面会很单薄。用bulk_create一次插入几百条患者档案:
from hospital.models import Patient Patient.objects.bulk_create([ Patient( name=f'测试患者{i}', gender='male', id_card=f'11010119900101{i:04d}', phone=f'1380000{i:04d}', ) for i in range(1, 501) ])bulk_create只在最后批量写一次数据库,比循环里逐个save快一个数量级,500 条数据在本地基本无感。批量插入时要保证表没有外键依赖,Patient 是独立表,可以直接这样造。
5.3 用 TestCase 跑通全链路
依赖 service 函数的设计可以直接写一段端到端测试,覆盖“挂号—完成就诊—收费—发药”的完整链路:
from django.test import TestCase from .services import create_registration, do_charge, dispense_drug class FullFlowTest(TestCase): def test_registration_charge_dispense(self): reg = create_registration( patient_id=self.patient.id, doctor_id=self.doctor.id, period='am', register_date='2025-05-20', ) self.assertEqual(reg.status, 'pending') reg.status = 'finished' reg.save() total = do_charge(reg.id, 'cash') self.assertGreater(total, 0) prescription = reg.prescription dispense_drug(prescription.id) self.assertIsNotNone(prescription.dispensed_at)测试没有走 HTTP 请求,而是直接调用 service 函数,跑得快、定位准。在项目根目录执行python manage.py test看到 OK 之后,回到 Admin 后台刷新 ChargeRecord 列表,刚才那条收费记录会出现在第一行,演示时这个动作很直观。
本文还有配套的精品资源,点击获取