feat: update cms case create

This commit is contained in:
2026-06-15 17:39:20 +08:00
parent 8a40fde923
commit 2bb9ff50d8
2 changed files with 194 additions and 13 deletions
+74 -9
View File
@@ -12,6 +12,7 @@
""" """
import logging import logging
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, inline_serializer from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiResponse, inline_serializer
from rest_framework import viewsets, filters, status, serializers as drf_serializers from rest_framework import viewsets, filters, status, serializers as drf_serializers
@@ -26,10 +27,13 @@ from apps.cms.permissions import (
) )
from apps.user.models import Institution, Department from apps.user.models import Institution, Department
from apps.user.throttling import PdfParseUserThrottle from apps.user.throttling import PdfParseUserThrottle
from .models import CaseBase from .models import CaseBase, ScoringRule
from .serializers import CaseBaseListSerializer from .serializers import CaseBaseListSerializer
from .services import case_importer from .services import case_importer, scoring_rule_generator
from .services.case_writer import create_case_full, build_full_response from .services.case_writer import (
create_case_full, update_case_full, build_full_response, build_scoring_rule_input,
validate_scoring_rules, SCORING_RULE_FIELDS,
)
audit = logging.getLogger('audit') audit = logging.getLogger('audit')
@@ -84,14 +88,50 @@ class CmsCaseViewSet(viewsets.ModelViewSet):
# ── CMS-CASE-3 表单新增(草稿)/ CMS-CASE-1 列表 ────────────────────── # ── CMS-CASE-3 表单新增(草稿)/ CMS-CASE-1 列表 ──────────────────────
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
"""新增病例(草稿)。评分规则非必填——评分规则在「发布」时由 AI 生成并落库。"""
self._require_editor() self._require_editor()
case, n_rules, n_items = create_case_full(request.data, request.user, institution=self._resolve_institution()) case, n_rules, n_items = create_case_full(
request.data, request.user,
institution=self._resolve_institution(),
require_scoring_rules=False,
)
audit.info('CMS_CASE_CREATE case_id=%s by=%s inst=%s scoring_rules=%d exam_items=%d', audit.info('CMS_CASE_CREATE case_id=%s by=%s inst=%s scoring_rules=%d exam_items=%d',
case.id, request.user.id, case.institution_id, n_rules, n_items) case.id, request.user.id, case.institution_id, n_rules, n_items)
return Response(build_full_response(case), status=status.HTTP_201_CREATED) return Response(build_full_response(case), status=status.HTTP_201_CREATED)
# list 用默认实现(默认管理器已排除软删;医院管理员审核用 ?publish_status=1 取待审核) # list 用默认实现(默认管理器已排除软删;医院管理员审核用 ?publish_status=1 取待审核)
# ── CMS-CASE-8 编辑草稿(再次保存)──────────────────────────────────
@extend_schema(
summary='CMS-CASE-8 编辑草稿(再次保存)',
description='对已保存的草稿病例,编辑录入后的表单(表单/PDF/AI 三种录入数据均在表单中)'
'再次保存,整体更新草稿内容。仅草稿(publish_status=0)可编辑。',
request=inline_serializer('CmsCaseSaveDraftRequest', fields={
'title': drf_serializers.CharField(required=False),
'case_type': drf_serializers.ChoiceField(choices=['traditional', 'teaching'], required=False,
help_text='只读校验,不支持修改;传入须与原病例一致'),
'department_name': drf_serializers.CharField(required=False, help_text='科室名称(解析为 department_id'),
'traditional': drf_serializers.DictField(required=False),
'teaching': drf_serializers.DictField(required=False),
'exam_items': drf_serializers.ListField(
child=drf_serializers.DictField(), required=False,
help_text='检查项(传入即整表替换;同一病例 item_code 不可重复)'),
'scoring_rules': drf_serializers.ListField(
child=drf_serializers.DictField(), required=False,
help_text='评分规则(一般发布时自动生成;如传入则整表替换)'),
}),
responses={200: OpenApiResponse(description='完整病例结构(同 CMS-CASE-7')},
tags=['CMS-病例'],
)
@action(detail=True, methods=['post'], url_path='save-draft')
def save_draft(self, request, pk=None):
"""编辑录入后的表单再次保存:更新草稿内容(主表+子表+检查项,评分规则可选)。"""
self._require_editor()
case = self.get_object()
case = update_case_full(case, request.data, request.user)
audit.info('CMS_CASE_SAVE_DRAFT case_id=%s by=%s', case.id, request.user.id)
return Response(build_full_response(case))
# ── CMS-CASE-7 / CMS-AUDIT-2 病例查看(完整结构)───────────────────── # ── CMS-CASE-7 / CMS-AUDIT-2 病例查看(完整结构)─────────────────────
@extend_schema(summary='CMS-CASE-7 病例查看(完整结构)', tags=['CMS-病例']) @extend_schema(summary='CMS-CASE-7 病例查看(完整结构)', tags=['CMS-病例'])
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
@@ -218,12 +258,37 @@ class CmsCaseViewSet(viewsets.ModelViewSet):
@extend_schema(summary='CMS-AUDIT-3 发布(正常 → 已发布)', tags=['CMS-病例审核']) @extend_schema(summary='CMS-AUDIT-3 发布(正常 → 已发布)', tags=['CMS-病例审核'])
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def publish(self, request, pk=None): def publish(self, request, pk=None):
"""医院管理员审核发布:正常(1) → 已发布(2)。发布后对本院移动端医学生可见。超管不做审核。""" """审核发布:正常(1) → 已发布(2)。
发布前先由 AI 生成该病例评分规则并落库;评分规则生成或入库失败则不发布
(评分规则落库与发布状态变更在同一事务内,保证「无评分规则不发布」)。
发布后对本院移动端医学生可见。
"""
self._require_publisher() self._require_publisher()
case = self.get_object() case = self.get_object()
if case.publish_status != 1: if case.publish_status != 1:
raise AppError('CASE_NOT_PUBLISHABLE', '仅「正常」状态病例可发布', status_code=400) raise AppError('CASE_NOT_PUBLISHABLE', '仅「正常」状态病例可发布', status_code=400)
case.publish_status = 2 # 已发布
case.save(update_fields=['publish_status', 'updated_at']) # 1) AI 生成评分规则(慢 / 可能失败,放在事务外,失败则不触碰数据库)
audit.info('CMS_CASE_PUBLISH case_id=%s by=%s', case.id, request.user.id) gen = scoring_rule_generator.generate(build_scoring_rule_input(case))
return Response({'message': '已发布', 'id': case.id, 'publish_status': case.publish_status}) rules = gen['scoring_rules']
validate_scoring_rules(rules)
# 2) 评分规则落库 + 发布状态变更,同一事务原子完成
with transaction.atomic():
case.scoring_rules.all().delete() # 幂等:清掉历史规则后重新写入
ScoringRule.objects.bulk_create([
ScoringRule(case=case, **{k: v for k, v in r.items() if k in SCORING_RULE_FIELDS})
for r in rules
])
case.publish_status = 2 # 已发布
case.save(update_fields=['publish_status', 'updated_at'])
audit.info('CMS_CASE_PUBLISH case_id=%s by=%s scoring_rules=%d prompt_version=%s',
case.id, request.user.id, len(rules), gen.get('prompt_version'))
return Response({
'message': '已发布',
'id': case.id,
'publish_status': case.publish_status,
'scoring_rules_generated': len(rules),
})
+120 -4
View File
@@ -51,10 +51,12 @@ def validate_scoring_rules(rules):
_UNSET = object() _UNSET = object()
def create_case_full(data, user, institution=_UNSET): def create_case_full(data, user, institution=_UNSET, require_scoring_rules=True):
"""主表 + 子表 + scoring_rules(≥1) + exam_items 同一事务入库。 """主表 + 子表 + scoring_rules + exam_items 同一事务入库。
institution:病例所属机构;默认取创建者所属机构(`user.institution`)。 institution:病例所属机构;默认取创建者所属机构(`user.institution`)。
require_scoring_rules:是否强制评分规则(≥1 条)。CMS 新增病例时为 False
(评分规则延迟到「发布」时由 AI 生成并落库);业务域 full-create 仍为 True。
返回 (case, scoring_rule_count, exam_item_count)。校验失败抛 AppError。 返回 (case, scoring_rule_count, exam_item_count)。校验失败抛 AppError。
""" """
if institution is _UNSET: if institution is _UNSET:
@@ -74,9 +76,10 @@ def create_case_full(data, user, institution=_UNSET):
raise AppError('CASE_SUBTYPE_CONFLICT', '不允许同时传入两种子表数据', status_code=400) raise AppError('CASE_SUBTYPE_CONFLICT', '不允许同时传入两种子表数据', status_code=400)
scoring_rules_data = data.get('scoring_rules', []) scoring_rules_data = data.get('scoring_rules', [])
if not scoring_rules_data: if require_scoring_rules and not scoring_rules_data:
raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400) raise AppError('CASE_VALIDATION_ERROR', 'scoring_rules 必填且至少 1 条', status_code=400)
validate_scoring_rules(scoring_rules_data) if scoring_rules_data:
validate_scoring_rules(scoring_rules_data)
exam_items_data = normalize_exam_items(data.get('exam_items') or []) exam_items_data = normalize_exam_items(data.get('exam_items') or [])
assert_no_duplicate_exam_items(exam_items_data) assert_no_duplicate_exam_items(exam_items_data)
@@ -113,6 +116,119 @@ def create_case_full(data, user, institution=_UNSET):
return case, len(rule_objs), len(exam_items_data) return case, len(rule_objs), len(exam_items_data)
# 与 scoring_rule_generator.CONTEXT_FIELDS 对应的主表字段(发布时据此构建生成入参)
_SCORING_CONTEXT_FIELDS = (
'title', 'chief_complaint', 'description', 'patient_age', 'patient_gender',
'icd_codes', 'symptom_tags', 'disease_tags', 'competency_tags',
'guideline_tags', 'knowledge_points',
)
def build_scoring_rule_input(case):
"""由已落库的病例对象构建评分规则生成入参(供发布时调用 AI 生成评分规则)。"""
data = {'case_type': case.case_type}
for field in _SCORING_CONTEXT_FIELDS:
data[field] = getattr(case, field, None)
if case.case_type == 'traditional':
try:
tc = case.traditionalcase
data['traditional'] = {
'standard_diagnosis': tc.standard_diagnosis,
'standard_treatment': tc.standard_treatment,
'guideline_reference': tc.guideline_reference,
}
except TraditionalCase.DoesNotExist:
data['traditional'] = {}
else:
try:
tc = case.teachingcase
data['teaching'] = {
'teaching_goal': tc.teaching_goal,
'discussion_questions': tc.discussion_questions,
'teacher_guide': tc.teacher_guide,
'scoring_focus': tc.scoring_focus,
}
except TeachingCase.DoesNotExist:
data['teaching'] = {}
return data
def update_case_full(case, data, user):
"""更新草稿病例内容(主表 + 子表 + exam_itemsscoring_rules 可选)。
供 CMS「编辑录入后的表单再次保存」复用:三种录入方式(表单 / PDF / AI)的病例数据
都汇总到前端表单,再次保存时整体更新草稿。仅草稿(publish_status=0)可更新。
- 主表字段:传入即覆盖(CASE_BASE_FIELDS 白名单)。
- 子表:按 case_type 局部更新;不允许传入另一种子表。
- exam_items:传入即整表替换(去重校验);不传则保持不变。
- scoring_rules:通常发布时才生成;如传入则整表替换(校验)。
返回更新后的 case。校验失败抛 AppError。
"""
if case.publish_status != 0:
raise AppError('CASE_NOT_EDITABLE', '仅草稿可编辑,请先下架', status_code=400)
if 'stages' in data:
raise AppError('CASE_FIELD_NOT_ALLOWED', '本接口不接收 stages 字段', status_code=400)
case_type = case.case_type
new_type = data.get('case_type')
if new_type and new_type != case_type:
raise AppError('CASE_TYPE_NOT_SUPPORTED', '不支持修改 case_type', status_code=400)
other_type = 'teaching' if case_type == 'traditional' else 'traditional'
if data.get(other_type):
raise AppError('CASE_SUBTYPE_CONFLICT', '不允许传入另一种子表数据', status_code=400)
scoring_rules_data = data.get('scoring_rules')
if scoring_rules_data is not None:
validate_scoring_rules(scoring_rules_data)
exam_items_data = None
if 'exam_items' in data:
exam_items_data = normalize_exam_items(data.get('exam_items') or [])
assert_no_duplicate_exam_items(exam_items_data)
with transaction.atomic():
changed = []
for field in CASE_BASE_FIELDS:
if field in data:
setattr(case, field, data[field])
changed.append(field)
if 'department_name' in data:
case.department = resolve_department(data['department_name'])
changed.append('department')
if changed:
case.save()
sub_data = data.get(case_type)
if sub_data:
sub_model = TraditionalCase if case_type == 'traditional' else TeachingCase
allowed_sub = TRADITIONAL_FIELDS if case_type == 'traditional' else TEACHING_FIELDS
sub_obj, _ = sub_model.objects.get_or_create(case=case)
for k, v in sub_data.items():
if k in allowed_sub:
setattr(sub_obj, k, v)
sub_obj.save()
if scoring_rules_data is not None:
case.scoring_rules.all().delete()
ScoringRule.objects.bulk_create([
ScoringRule(case=case, **{k: v for k, v in rule.items() if k in SCORING_RULE_FIELDS})
for rule in scoring_rules_data
])
if exam_items_data is not None:
case.exam_items.all().delete()
CaseExamItem.objects.bulk_create([
CaseExamItem(case=case, **{k: v for k, v in item.items() if k in EXAM_ITEM_FIELDS})
for item in exam_items_data
])
case.refresh_from_db()
return case
def build_full_response(case): def build_full_response(case):
"""组装病例完整结构(主表 + 子表 + scoring_rules + exam_items)。""" """组装病例完整结构(主表 + 子表 + scoring_rules + exam_items)。"""
if hasattr(case, '_prefetched_objects_cache') and 'exam_items' in getattr( if hasattr(case, '_prefetched_objects_cache') and 'exam_items' in getattr(