简介:这是一份基于Python Django框架开发的医院挂号诊疗系统毕业设计源码案例,适合计算机相关专业学生用于毕业设计、课程设计或项目初期演示,也适合希望学习Django Web开发的小白进阶。项目已获导师认可并通过答辩评审,代码经过测试运行成功,功能完整,具备实际参考价值。资源包共2000个文件,大小仅5.57MB,其中以1633个JavaScript文件、249个HTML文件、53个CSS文件等前端资源为主,辅以少量Python后端文件以及JSON、XML、TXT、MD等多种格式的配置说明与文档,整体体量紧凑,便于快速查阅。目前已有54人浏览/学习,说明其内容受到初步认可,适合直接参考使用。下载后可获得完整项目源码与详细设计文档,既能直接用于毕设答辩演示,也可在此基础上二次开发,扩展挂号、诊疗相关功能,在动手实践中提升Django全栈开发能力。
1. 一个 Django 毕业设计最容易翻车的地方,从来不是 Django
拿到“医院挂号诊疗系统”这类题目,大多数人的第一反应是先把用户登录、医生列表、挂号按钮做出来,跑通页面就以为大功告成。但真正拿去答辩或者演示时,被问住的问题往往很一致:同一位医生上午的号被两个人同时挂到怎么办?患者挂了号又取消,号源要不要还回去?医生排班和挂号记录之间怎么对账?这些问题都不能靠前端弹窗解决,必须回到数据库和业务层的设计上。
这篇文章从 Django 的实际开发路径出发,把一套可以拿去交作业、也可以作为真实小诊所内部工具的挂号系统拆开讲。你会看到从模型设计、号源锁定、视图事务处理到 Admin 后台配置的完整落地方案,每一段都有可以直接抄的代码和参数说明。选题本身不追求“系统演示有多炫”,而是围绕 Django 开发中最核心的模型关系、事务控制、查询优化和后台定制这几件事,把挂号业务彻底跑通。
适合两类人看:一类是正在做毕业设计、需要完整源码思路和文档支撑的学生;另一类是已经写过几个 Django 项目、但还没处理过“高并发预约类业务”的开发者。前者能从里拿到可直接复现的代码骨架,后者可以从号源锁定的细节里看到自己和生产级实现的差距。
2. 挂号诊疗系统的数据建模:从 ER 图到 Django Models 的落地方法
2.1 先想清楚业务边界,再写 Model
做医院挂号系统,最忌讳的是上来就建十几个表。以最常见的门诊挂号场景为例,真正必需的核心业务对象只有四个:科室、医生、排班、号源/挂号记录。患者信息可以并入 Django 自带的 User 模型,通过扩展字段保存姓名、身份证号和手机号,不需要单独建一张患者表去维护登录关系,否则后面做认证要写大量冗余代码。
围绕这四个对象,关系是这样的:
- 科室对医生是一对多:一个科室有多个医生,每个医生只属于一个科室。
- 医生对排班是一对多:一个医生可以有多个时段的出诊安排。
- 排班对号源是一对多:一个排班产生多个号(比如上午 30 个号)。
- 挂号记录关联排班和患者:一次挂号对应一个排班下的一个号位。
这就是典型的“排班产生号池,挂号消耗号池”结构。把号池做成独立的 Model,而不是在挂号记录里简单存一个医生 ID,是这套系统能否处理“退号、换号、号源统计”的关键。你会发现后面写视图时,90% 的查询和更新都在围绕这个号池模型转。
2.2 核心 Model 定义与关键字段参数
下面这份代码是可以直接放进models.py用的核心结构,去掉了教科书里常见的冗余字段,保留真正会影响业务逻辑的部分。
from django.db import models from django.contrib.auth.models import User from django.core.validators import MinValueValidator, MaxValueValidator class Department(models.Model): """科室""" name = models.CharField('科室名称', max_length=50, unique=True) intro = models.TextField('科室简介', blank=True) def __str__(self): return self.name class Meta: db_table = 'hospital_department' verbose_name = '科室' verbose_name_plural = verbose_name class Doctor(models.Model): """医生,与 User 一对一扩展登录账号""" user = models.OneToOneField(User, on_delete=models.CASCADE, verbose_name='登录账号') department = models.ForeignKey(Department, on_delete=models.PROTECT, verbose_name='所属科室') title = models.CharField('职称', max_length=20, choices=( ('resident', '住院医师'), ('attending', '主治医师'), ('chief', '主任医师') ), default='attending') phone = models.CharField('联系电话', max_length=11, blank=True) def __str__(self): return f'{self.department.name}-{self.title}-{self.user.last_name or self.user.username}' class Meta: db_table = 'hospital_doctor' verbose_name = '医生' verbose_name_plural = verbose_name class Schedule(models.Model): """医生排班:生成号池的依据""" doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE, verbose_name='医生') date = models.DateField('出诊日期') period = models.CharField('时段', max_length=10, choices=( ('am', '上午'), ('pm', '下午') )) total_slots = models.PositiveIntegerField('号源总数', default=30) start_time = models.TimeField('开始时间', default='08:00') end_time = models.TimeField('结束时间', default='12:00') class Meta: db_table = 'hospital_schedule' verbose_name = '出诊排班' verbose_name_plural = verbose_name constraints = [ models.UniqueConstraint( fields=['doctor', 'date', 'period'], name='uniq_doctor_date_period' ) ] @property def remaining(self): """实时剩余号,属性写法方便模板直接调用""" return self.total_slots - self.registrations.filter(status='paid').count() class Registration(models.Model): """挂号记录:每次挂号落一条,同时消耗一个号位""" patient = models.ForeignKey(User, on_delete=models.CASCADE, related_name='registrations', verbose_name='患者') schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE, related_name='registrations', verbose_name='排班') status = models.CharField('状态', max_length=10, choices=( ('pending', '待支付'), ('paid', '已挂号'), ('cancelled', '已取消') ), default='pending') created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: db_table = 'hospital_registration' verbose_name = '挂号记录' verbose_name_plural = verbose_name ordering = ['-created_at']2.2.1 字段和约束的关键参数说明
上述设计中几个参数不是随手写的,每一处都在解决一个具体问题:
OneToOneField(User):医生和 Django 自带用户模型一对一绑定,这样医生登录系统用的是同一套认证,不用单独写医生登录页。on_delete=models.CASCADE保证删除用户时医生资料一起清理,不会产生脏数据。ForeignKey(Department, on_delete=models.PROTECT):科室被医生引用后不可删除。PROTECT 会在数据库层面拦截删除操作,避免出现“科室没了但医生还挂着该科室 ID”的孤儿记录。UniqueConstraint(fields=['doctor', 'date', 'period']):这是排班模型最重要的约束。它保证一个医生同一天同一时段只能有一条排班记录,从数据库层面杜绝前端重复提交生成的重复排班。没有这个约束,后续的号源统计会立刻产生偏差。related_name='registrations':反向查询时不再需要写registration_set,直接用schedule.registrations.filter(...)或者patient.registrations.all(),代码可读性高很多,模板里调用也更顺手。PositiveIntegerField:号源总数不可能为负数,用这个字段类型比普通 IntergerField 多一层基础校验。
2.3 患者模型不要另起炉灶,直接扩展 User
很多教材会单独设计 Patient 表,但实际开发中这样做等于把用户认证拆成两套体系,登录、密码重置、会话管理全得自己写。推荐的替代方案是创建 User 的 Profile 扩展模型,通过OneToOneField绑定:
from django.db import models from django.contrib.auth.models import User from django.db.models.signals import post_save from django.dispatch import receiver class PatientProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile') id_card = models.CharField('身份证号', max_length=18, unique=True) phone = models.CharField('手机号', max_length=11) address = models.CharField('住址', max_length=200, blank=True) def __str__(self): return f'{self.user.last_name or self.user.username} 的就诊档案' class Meta: db_table = 'hospital_patient_profile' verbose_name = '患者档案' verbose_name_plural = verbose_name @receiver(post_save, sender=User) def create_patient_profile(sender, instance, created, **kwargs): """注册用户后自动创建空档案,省去手动同步的步骤""" if created: PatientProfile.objects.get_or_create(user=instance)逻辑说明:post_save信号监听 User 的创建事件,一旦有新用户注册,自动生成一条关联 profile。这样在注册视图里不需要显式调用 profile 的 save 方法,也不用担心因为表单漏填字段导致关联失败。参数unique=True对身份证号做了唯一约束,防止同一身份信息开多个账号。
真实系统中,患者和医生都可以挂在同一个 User 表下,通过 profile 里加一个user_type字段区分角色。但在毕业设计维度上,一个医生对应一个可登录用户,一个普通前台患者对应一个可登录用户,已经足够覆盖演示和答辩场景。答辩时能解释清楚“为什么患者不单独建表”,本身就是加分项。
3. 挂号及退号的核心视图:用事务和 select_for_update 保障号源不超卖
3.1 挂号业务中的并发隐患到底在哪
普通的增删改查视图学会了,不等于挂号功能能上线。挂号业务最微妙的地方在于“号源总量有限,多人可能同时抢同一个号位”。假设某个排班剩余 1 个号,两个患者同时点“挂号”,按常规写法两个请求都会先查remaining == 1,然后各自生成一条挂号记录,最终结果就是 1 个号位被卖出去 2 次。
这个问题属于典型的资源竞争场景。Django 解决它不需要引入 Redis 锁或者消息队列,靠数据库自身的行锁 + 事务就能处理。做法是在查询可用排班时使用select_for_update(),让数据库对命中的排班行加排他锁,直到事务提交或回滚才释放。第二个请求到达时会阻塞在锁上,等第一个事务结束再继续,此时它读到的剩余号数已经是更新后的数字。
3.2 最小可运行的挂号视图代码
下面是一个基于Transaction和select_for_update的挂号接口实现。它在逻辑上涵盖“锁排班 -> 校验剩余号 -> 创建挂号记录 -> 返回结果”四个步骤,可以平滑适配页面表单提交和 JSON 接口两种调用方式。
from django.db import transaction from django.shortcuts import get_object_or_404 from django.http import JsonResponse from django.views.decorators.http import require_POST from django.contrib.auth.decorators import login_required from .models import Schedule, Registration @login_required @require_POST def create_registration(request): """ 患者挂号接口 请求参数: schedule_id 返回: JSON, 含挂号状态与提示信息 """ schedule_id = request.POST.get('schedule_id') if not schedule_id: return JsonResponse({'code': 400, 'msg': '缺少排班编号'}) # 开启事务,保证锁和后续写操作在同一个原子块内 try: with transaction.atomic(): # 锁定这一条排班记录,直到事务结束 schedule = ( Schedule.objects .select_for_update() .filter(id=schedule_id, date__gte=datetime.date.today()) .select_related('doctor', 'doctor__department') .first() ) if schedule is None: return JsonResponse({'code': 404, 'msg': '排班不存在或已过期'}) # 统计当前已占用号位(只算有效挂号) used_slots = ( Registration.objects .filter(schedule=schedule, status='paid') .count() ) if used_slots >= schedule.total_slots: return JsonResponse({'code': 410, 'msg': '号源已满,请选择其他时段'}) # 检查是否已挂过同一排班,避免重复挂号 if Registration.objects.filter( patient=request.user, schedule=schedule, status='paid' ).exists(): return JsonResponse({'code': 409, 'msg': '您已挂过该医生的号,请勿重复操作'}) reg = Registration.objects.create( patient=request.user, schedule=schedule, status='paid' ) # 事务结束,锁释放 return JsonResponse({ 'code': 200, 'msg': '挂号成功', 'data': { 'registration_id': reg.id, 'doctor_name': schedule.doctor.user.last_name or schedule.doctor.user.username, 'department': schedule.doctor.department.name, 'date': schedule.date.strftime('%Y-%m-%d'), 'period': schedule.get_period_display(), } }) except Exception as e: # 生产环境建议记录日志,这里仅返回错误信息 return JsonResponse({'code': 500, 'msg': f'挂号失败: {str(e)}'})3.2.1 这段代码的关键参数与逻辑点
select_for_update()是这段代码的核心命脉。它只在事务内生效,所以外层必须包裹transaction.atomic()。如果你把锁写在事务外面,Django 会直接抛TransactionManagementError。另一个容易被忽略的点是select_for_update必须配合filter使用才有意义,它锁定的不是“查询条件”,而是查询结果对应的数据库行。
used_slots的统计逻辑选择对status='paid'计数,而不是用remaining = total_slots - used,原因在于退号操作只需要把状态改为cancelled,不需要物理删除记录。这样设计后,所有历史挂号痕迹都保留着,对后续对账和审计非常有帮助。
date__gte=datetime.date.today()是第二个重要过滤条件。它防止患者传入一个已经过期的排班 ID 去占号。日期过滤写在filter里而非 Python 层判断,是为了避免“当前排班查到了,但锁定了无用记录”的情况,减少无畏的行锁开销。
3.3 退号视图与状态机流转
退号逻辑相对简单,但容易踩一个坑:把status改回pending而不是cancelled,导致号位统计异常。正确做法是定义一个清晰的“状态机”:pending(待支付) ->paid(已挂号) ->cancelled(已取消),只有paid可以进入cancelled,只有pending可以进入paid。下面的退号代码同时做了属主和状态的两重校验:
from django.db import transaction from django.http import JsonResponse from django.views.decorators.http import require_POST from django.contrib.auth.decorators import login_required from .models import Registration @login_required @require_POST def cancel_registration(request): """ 患者退号 请求参数: registration_id 校验挂号和状态后改为 cancelled """ reg_id = request.POST.get('registration_id') if not reg_id: return JsonResponse({'code': 400, 'msg': '缺少挂号记录编号'}) try: with transaction.atomic(): # 锁挂号记录,同时锁关联排班,防止退号瞬间有人挂号造成错误计数 reg = ( Registration.objects .select_for_update() .select_related('schedule') .get(id=reg_id, patient=request.user) ) if reg.status != 'paid': return JsonResponse({'code': 409, 'msg': '当前状态不能退号'}) reg.status = 'cancelled' reg.save(update_fields=['status']) return JsonResponse({'code': 200, 'msg': '退号成功,号源已释放'}) except Registration.DoesNotExist: return JsonResponse({'code': 404, 'msg': '挂号记录不存在或无权操作'})有人说退号不需要select_for_update,因为只是改个状态。但这里锁挂号记录的同时通过select_related把排班一并锁进事务,为的是防止极端情况:患者在医生下班前最后几分钟退号,另一个人同时挂这个号,如果这两步操作之间有间隙,数据库层面的计数会出现瞬时不一致。在生产级代码里,宁可多锁一行,也不能留下竞态窗口。
3.4 挂号失败时优先检查哪些东西
代码写完不代表问题解决。实际运行中,最常见的三类报错有清晰的排查路径:
TransactionManagementError: select_for_update cannot be used outside of a transaction:这说明select_for_update写在了atomic块之外,或者查询根本没进事务。把包含锁查询的代码整体挪进with transaction.atomic():内即可。号源没有减少,但挂号记录创建成功:几乎可以断定是
status的默认值写错,或者统计口径用了filter(status='pending')。统计号源的逻辑必须和挂号创建逻辑使用同一个状态常量。前端重复点击导致同一患者挂到两个号:虽然视图里有重复挂号校验,但从工程角度讲,应该在按钮上做
disabled状态控制,同时在视图层用唯一约束(如UniqueConstraint(fields=['patient', 'schedule'], condition=Q(status='paid')))做最后的兜底。
4. 医生排班管理与门诊查询的实用查询写法
4.1 排班列表页的查询优化策略
排班列表是系统里访问频率最高的页面。患者进入挂号页先看“今天有哪些医生出诊”,医生登录后也要看“我这个月排了哪些班”。如果直接在模板里循环schedule.registrations.count或调用schedule.remaining属性,会产生 N+1 次数据库查询:每显示一条排班就多查一次号源统计。当排班记录达到几十条时,页面加载时间会明显变慢。
解决办法是用 Django ORM 的annotate在一次查询中完成统计。下面给出一个门诊排班查询视图的示例:
from django.db.models import Count, Q from django.utils import timezone from django.views.generic import ListView from .models import Schedule class ScheduleListView(ListView): model = Schedule template_name = 'hospital/schedule_list.html' context_object_name = 'schedules' paginate_by = 10 def get_queryset(self): # 只显示今天及之后的排班,并按日期和时间排序 return ( Schedule.objects .filter(date__gte=timezone.localdate()) .select_related('doctor__user', 'doctor__department') .annotate( used_slots=Count( 'registrations', filter=Q(registrations__status='paid') ) ) .order_by('date', 'period') )查询逻辑说明:annotate配合Count('registrations', filter=...)是 Django 2.0 之后才支持的条件聚合写法,内部会被翻译成 SQL 里的SUM(CASE WHEN ... THEN 1 ELSE 0 END)。它的好处是不产生额外查询,全部统计在一条 SQL 内完成。
模板里使用schedule.used_slots替代调用schedule.remaining,因为后者每次访问都会单独查一次数据库。这是“能放进 QuerySet 的就不要放进 Model 属性”的典型例子。真正需要警惕的是,不要在这个视图之外再调用schedule.remaining,否则会破坏批量查询的优化效果。
4.2 科室筛选与日期筛选的兼容处理
列表页一般会提供“按科室筛选”“按日期筛选”两个入口。如果只有一个筛选条件生效,代码很好写;但两个条件同时存在时,必须注意参数是否带默认值。下面是一段兼容多条件的get_queryset写法:
import datetime from django.db.models import Count, Q def get_filtered_schedules(request): queryset = ( Schedule.objects .filter(date__gte=datetime.date.today()) .select_related('doctor__user', 'doctor__department') ) # 科室筛选:没有传入时不做过滤 dept_id = request.GET.get('dept_id') if dept_id: queryset = queryset.filter(doctor__department_id=dept_id) # 日期筛选:支持 ?date=2025-06-01 格式,非法格式忽略 date_str = request.GET.get('date', '').strip() if date_str: try: target = datetime.date.fromisoformat(date_str) queryset = queryset.filter(date=target) except ValueError: pass # 日期格式不合法时忽略该筛选条件 # 按已挂号数排序,号多的优先显示,方便患者找热门医生 queryset = queryset.annotate( used_slots=Count('registrations', filter=Q(registrations__status='paid')) ).order_by('-used_slots', 'date') return queryset这段代码在参数容错上做了两个细节处理。if dept_id判断过滤掉空字符串和None,避免dept_id=这种空参数导致查询异常;日期转换使用fromisoformat,遇到2025/06/01这类非标准格式时主动忽略而不是报 500 错误。排序用-used_slots把热门号源排前面,既方便患者选择,也减少了后半夜蹲守捡漏的体验压力。
4.3 医生端查询:我今天的挂号患者列表
医生登录后最关心的是“今天哪些人挂了我的号”。这个查询相对简单,但涉及跨表关联检索,正确写法是直接用select_related连表查询,避免在模板里通过reg.patient.profile.phone产生 N+1 次查询。
from django.contrib.auth.decorators import login_required from django.shortcuts import render from django.utils import timezone from .models import Registration, Doctor @login_required def my_patients(request): """医生查看自己当日挂号患者""" doctor = Doctor.objects.filter(user=request.user).first() if not doctor: return render(request, 'hospital/error.html', {'msg': '当前账号不是医生账号'}) today = timezone.localdate() regs = ( Registration.objects .filter(schedule__doctor=doctor, schedule__date=today, status='paid') .select_related('patient', 'patient__profile', 'schedule') .order_by('schedule__period', 'created_at') ) context = { 'doctor': doctor, 'regs': regs, 'reg_count': regs.count(), } return render(request, 'hospital/doctor_patients.html', context)filter(schedule__doctor=doctor)是跨模型关联过滤的标准写法,翻译成 SQL 就是JOIN hospital_schedule ON ... WHERE schedule.doctor_id = ?。select_related在这里一口气带出patient、patient.profile和schedule三张关联表的数据,后续模板中无论是显示患者姓名还是手机号,都不会再产生额外 SQL。注意patient__profile的双下划线写法,它表示嵌套关联的层级。
这里有个容易忽略的细节:regs.count()不会因为前面调用了select_related而受影响,它仍然是独立的COUNT(*)查询。如果你发现 count 和列表数据量不一致,优先检查是不是filter条件里有status遗漏。
5. Django Admin 后台改造:让“资料齐全”体现在可维护性上
5.1 为什么毕业设计里的 Admin 后台不能直接用默认配置
Django Admin 是这套系统最容易被低估的部分。默认生成的注册页面确实能增删改查,但直接拿去做项目演示会有三个尴尬:列表显示的全是Schedule object (1)这类对象名;医生排班页里选医生时看到的是名字而不是“科室-职称-姓名”的可读格式;更重要的是,没有做数据过滤和搜索,记录多了之后根本无法管理。
“资料齐全”的源码工程,通常会提供一套经过深度定制的 Admin 页面,这样答辩时可以直接演示数据录入和权限分流,比临时写前端表单省时间很多。下面给出一个实用的 Admin 配置,覆盖列表、筛选、搜索、行内编辑四个维度的优化。
from django.contrib import admin from .models import Department, Doctor, Schedule, Registration @admin.register(Department) class DepartmentAdmin(admin.ModelAdmin): list_display = ('id', 'name', 'intro') search_fields = ('name',) class RegistrationInline(admin.TabularInline): """排班详情页内联展示挂号记录""" model = Registration extra = 0 fields = ('patient', 'status', 'created_at') readonly_fields = ('patient', 'status', 'created_at') can_delete = False def has_add_permission(self, request, obj=None): # 不允许在排班后台直接手工添加挂号,避免绕过号源校验 return False @admin.register(Schedule) class ScheduleAdmin(admin.ModelAdmin): list_display = ('id', 'doctor', 'date', 'period', 'start_time', 'end_time', 'total_slots', 'used_count') list_filter = ('date', 'period', 'doctor__department') search_fields = ('doctor__user__username', 'doctor__user__last_name') date_hierarchy = 'date' autocomplete_fields = ('doctor',) inlines = [RegistrationInline] def get_queryset(self, request): # 聚合统计已挂号数,避免列表页 N+1 查询 from django.db.models import Count, Q return ( super().get_queryset(request) .select_related('doctor__user', 'doctor__department') .annotate(used_count=Count('registrations', filter=Q(registrations__status='paid'))) ) def used_count(self, obj): return obj.used_count used_count.short_description = '已挂号数' @admin.register(Doctor) class DoctorAdmin(admin.ModelAdmin): list_display = ('id', 'name_display', 'department', 'title', 'phone') list_filter = ('department', 'title') search_fields = ('user__username', 'user__last_name', 'phone') def name_display(self, obj): return obj.user.last_name or obj.user.username name_display.short_description = '姓名' @admin.register(Registration) class RegistrationAdmin(admin.ModelAdmin): list_display = ('id', 'patient', 'schedule', 'status', 'created_at') list_filter = ('status', 'schedule__date', 'schedule__doctor__department') search_fields = ('patient__username', 'patient__profile__phone', 'schedule__doctor__user__last_name') date_hierarchy = 'created_at' def get_readonly_fields(self, request, obj=None): # 已取消的挂号记录不允许再编辑 if obj and obj.status == 'cancelled': return self.readonly_fields + ('status',) return self.readonly_fields5.1.1 Admin 配置里最值得 Debug 的几个点
list_display中used_count是函数而不是模型字段,Django Admin 无法自动排序。如果希望在列表页点“已挂号数”列头排序,必须给函数加admin.display(ordering='used_count')装饰器,否则点击排序会报错。没有加这个装饰器时,Django 会用used_count本身去数据库里查找列名,结果必然FieldError。
search_fields中使用'doctor__user__username'这种跨表路径是合法的,但要注意search_fields的跨表搜索性能并不高,因为 Django 会为每个路径生成单独的LIKE条件。对于毕业设计量级的数据(几百条排班)没有压力,不必为此引入搜索引擎。
autocomplete_fields依赖对应 ModelAdmin 里注册了search_fields,如果你在DoctorAdmin里没有写search_fields,ScheduleAdmin的autocomplete_fields会在请求时抛异常。这个隐藏依赖关系非常容易踩到。
RegistrationInline重写has_add_permission返回 False 是一个工程上的好习惯:挂号必须走业务视图才能触发号源锁定逻辑,如果允许从 Admin 直接添加挂号记录,号源校验就被绕过了。同理,can_delete=False防止误删操作,因为退号应该走业务接口将状态改为cancelled。
5.2 Admin 数据导出与备份
在毕业设计文档中,通常需要附上一两句话说明“系统数据如何备份”。Admin 后台本身不直接支持导出 Excel,但可以基于 Django 的QuerySet写一个简单的 CSV 导出视图,作为附加功能扩展到后台中:
import csv from django.http import HttpResponse from django.contrib.admin.views.decorators import staff_member_required from .models import Registration @staff_member_required def export_registrations_csv(request): """导出挂号记录为 CSV,用于离线对账""" response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="registrations.csv"' writer = csv.writer(response) writer.writerow(['挂号单号', '患者', '科室', '医生', '日期', '时段', '状态', '创建时间']) regs = ( Registration.objects .select_related('patient', 'schedule__doctor__department', 'schedule__doctor__user') .order_by('-created_at') ) for reg in regs: writer.writerow([ reg.id, reg.patient.last_name or reg.patient.username, reg.schedule.doctor.department.name, reg.schedule.doctor.user.last_name or reg.schedule.doctor.user.username, reg.schedule.date, reg.schedule.get_period_display(), reg.get_status_display(), reg.created_at.strftime('%Y-%m-%d %H:%M:%S'), ]) return response这个导出视图有两个参数值得解释:Content-Disposition头里的attachment告诉浏览器该响应是一个下载文件,filename需要带.csv后缀,否则 Windows 用户打开时会乱码。get_status_display和get_period_display是 Django 模型内置方法,返回 Choices 字段中手动指定的中文展示值,不需要自己维护一个状态映射字典。
5.3 给排班管理增加批量生成功能
手工逐条创建排班非常痛苦。通过action可以给 Admin 增加批量操作,快速为一个指定日期生成所有医生的排班:
from django.contrib import admin, messages from django.shortcuts import redirect from django.urls import path from django import forms from .models import Schedule, Doctor from datetime import date, timedelta class BatchGenerateForm(forms.Form): start_date = forms.DateField(label='开始日期', widget=forms.DateInput(attrs={'type': 'date'})) days = forms.IntegerField(label='生成天数', min_value=1, max_value=30, initial=7) @admin.register(Schedule) class ScheduleAdminWithAction(admin.ModelAdmin): # 省略前面的字段配置... actions = ['batch_generate'] def get_urls(self): urls = super().get_urls() custom_urls = [ path('batch-generate/', self.admin_site.admin_view(self.batch_generate_view), name='schedule-batch-generate'), ] return custom_urls + urls def batch_generate(self, request, queryset): return redirect('admin:schedule-batch-generate') batch_generate.short_description = '批量生成排班' def batch_generate_view(self, request): if request.method == 'POST': form = BatchGenerateForm(request.POST) if form.is_valid(): start = form.cleaned_data['start_date'] days = form.cleaned_data['days'] created = 0 skipped = 0 for offset in range(days): day = start + timedelta(days=offset) # 跳过周末,演示科室正常门诊 if day.weekday() >= 5: continue for doctor in Doctor.objects.all(): _, was_created = Schedule.objects.get_or_create( doctor=doctor, date=day, period='am', defaults={'total_slots': 30, 'start_time': '08:00', 'end_time': '12:00'} ) if was_created: created += 1 else: skipped += 1 self.message_user(request, f'排班生成完成:新增 {created} 条,跳过重复 {skipped} 条。') return redirect('admin:index') else: form = BatchGenerateForm() context = self.admin_site.each_context(request) context['form'] = form context['title'] = '批量生成排班' return render(request, 'admin/batch_generate.html', context)get_or_create是批量生成的最佳选择:它以doctor + date + period作为匹配键,存在则跳过,不存在则创建。底层依赖的正是模型层定义的UniqueConstraint,如果去掉这个约束,get_or_create在高并发场景下可能仍然产生重复数据,但在此处串行执行没有问题。日期筛选使用weekday() >= 5自动跳过周末,这个规则可以根据医院实际门诊安排调整。
6. 挂对账与门诊数据核验的终极技巧
系统从“能跑”到“敢演示”,最后一公里是对账。排班数据、挂号数据、退号数据之间是否一致,直接决定系统能不能应付答辩老师“数据对不上怎么办”的追问。这里分享一个实战中打磨出来的门诊数据核验方法。
约定对账口径:某一天的医生排班号源总数,减去该日已取消的挂号数,必须等于当日有效挂号数。写成公式即:
sum(total_slots for schedule in day) - cancelled_count = paid_count
利用 Django ORM 的聚合查询,可以写一个独立的管理命令来完成每日对账,放到management/commands/audit_daily.py中:
from django.core.management.base import BaseCommand from django.db.models import Count, Q, Sum from django.utils import timezone from datetime import timedelta from hospital.models import Schedule, Registration class Command(BaseCommand): help = '核对指定日期的号源、挂号与退号数据' def add_arguments(self, parser): parser.add_argument('--date', type=str, help='日期,格式 YYYY-MM-DD,默认今天') def handle(self, *args, **options): date_str = options.get('date') target = timezone.localdate() if date_str: target = timezone.datetime.fromisoformat(date_str).date() schedules = ( Schedule.objects .filter(date=target) .annotate( paid_count=Count('registrations', filter=Q(registrations__status='paid')), cancelled_count=Count('registrations', filter=Q(registrations__status='cancelled')), ) ) total_slots = schedules.aggregate(total=Sum('total_slots'))['total'] or 0 paid_total = sum(s.paid_count for s in schedules) cancelled_total = sum(s.cancelled_count for s in schedules) expected = total_slots - cancelled_total actual = paid_total self.stdout.write(f'日期: {target}') self.stdout.write(f'排班总数: {schedules.count()},号源总量: {total_slots}') self.stdout.write(f'有效挂号数: {paid_total},退号数: {cancelled_total}') self.stdout.write(f'预期剩余号: {expected},实际剩余号: {total_slots - actual}') if expected == actual: self.stdout.write(self.style.SUCCESS('对账通过:数据一致')) else: self.stdout.write(self.style.ERROR(f'对账失败:差值为 {expected - actual},请检查挂号记录'))执行方式很简单:
python manage.py audit_daily --date 2025-06-01没有传--date参数时默认对账当天。这个命令的原理是把对账规则固化到应用层,每一天的数据跑一遍即完成校验。如果差值为负数,说明存在没有排班记录的挂号数据,需要检查是否有人绕过了排班校验直接写入数据;如果差值为正数,说明有排班号源被“吞掉”,优先排查是否有人在关闭支付的情况下仍创建了paid状态的记录。
对这个对账逻辑做一次增强扩展:当一个排班的总号数等于已取消数,说明该排班的所有号全部被退完,此时可以把该排班标记为“无效”,避免后续患者再挂。这是一个值得写进文档的边界场景处理。接口上加一个前置判断即可,逻辑简单,但对系统完整性而言意义很大。
最后一条收尾技巧:在任何涉及数量统计的界面,都建议显示统计时间戳,比如“数据截至 2025-06-01 14:30:00”。因为挂号数据实时变动,患者看到的是一个带时间标记的静态快照,可以避免“数据过期”的质疑。实现只要在模板 context 中传入timezone.now()并在页脚渲染即可,一行代码的成本,却能让整个系统在演示时显得严谨许多。
本文还有配套的精品资源,点击获取