fc / apps /cases /services /case /case_admin_service.py
jun2024's picture
Upload 2241 files
b4f2238 verified
Raw History Blame Contribute Delete
22.4 kB
"""Business logic services."""
from __future__ import annotations
import logging
from importlib import import_module
from typing import TYPE_CHECKING, Protocol, TypedDict, cast
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from apps.cases.models import Case, CaseAssignment, CaseNumber, CaseParty, SupervisingAuthority
from .wiring import get_case_filing_number_service, get_document_service
if TYPE_CHECKING:
from django.db.models import QuerySet
from apps.cases.services.case_import_service import CaseImportPayload
from apps.core.interfaces import ICaseFilingNumberService, IDocumentService
logger = logging.getLogger(__name__)
JSONDict = dict[str, object]
ImportData = dict[str, object]
class MaterialViewPayload(TypedDict):
party_types: list[JSONDict]
non_party_types: list[JSONDict]
our_parties: list[MaterialPartyPayload]
opponent_parties: list[MaterialPartyPayload]
authorities: list[AuthorityPayload]
class SimplePartyPayload(TypedDict):
id: int
name: str
class DetailPartyPayload(TypedDict):
id: int
name: str
client_type: str
legal_status: str | None
legal_status_display: str
class MaterialPartyPayload(TypedDict):
id: int
name: str
legal_status: str | None
legal_status_display: str
class AuthorityPayload(TypedDict):
id: int
name: str
authority_type: str
authority_type_display: str
class CaseMaterialServiceProtocol(Protocol):
def get_used_type_ids(self, *, case_id: int) -> list[int]: ...
def get_material_types_by_category(
self,
*,
category: str,
law_firm_id: int | None,
used_type_ids: list[int],
) -> list[JSONDict]: ...
class CaseImportServiceProtocol(Protocol):
def import_one(self, data: CaseImportPayload) -> Case: ...
class CaseAdminService:
"""案件 Admin 服务"""
def __init__(
self,
document_service: IDocumentService | None = None,
filing_number_service: ICaseFilingNumberService | None = None,
) -> None:
"""
构造函数支持依赖注入
document_service: 文档服务实例(可选,用于依赖注入)
filing_number_service: 建档编号服务实例(可选,用于依赖注入)
"""
self._document_service = document_service
self._filing_number_service = filing_number_service
@property
def document_service(self) -> IDocumentService:
"""
延迟加载文档服务
通过 ServiceLocator 获取 IDocumentService 实例,
支持依赖注入以便于测试.
IDocumentService 实例
"""
if self._document_service is None:
self._document_service = get_document_service()
return self._document_service
@property
def filing_number_service(self) -> ICaseFilingNumberService:
"""
延迟加载建档编号服务
支持依赖注入以便于测试.
FilingNumberService 实例
"""
if self._filing_number_service is None:
self._filing_number_service = get_case_filing_number_service()
return self._filing_number_service
def get_matched_folder_templates(self, case_type: str, legal_statuses: list[str] | None = None) -> str:
"""
获取匹配的文件夹模板
case_type: 案件类型
legal_statuses: 案件的诉讼地位列表(我方当事人的诉讼地位)
模板名称字符串,多个模板用"、"分隔
如果查询失败返回 "查询失败"
"""
try:
if legal_statuses:
return cast(
str,
self.document_service.get_matched_folder_templates_with_legal_status(case_type, legal_statuses),
)
return cast(str, self.document_service.get_matched_folder_templates(case_type))
except Exception:
logger.exception(
"get_matched_folder_templates_failed", extra={"case_type": case_type, "legal_statuses": legal_statuses}
)
return str(_("查询失败"))
def get_matched_folder_templates_list(
self, case_type: str, legal_statuses: list[str] | None = None
) -> list[JSONDict]:
try:
module = import_module("apps.documents.services.template.template_matching_service")
template_matching_service_cls = module.TemplateMatchingService
return cast(
list[JSONDict],
template_matching_service_cls().find_matching_case_folder_templates_list(
case_type=case_type,
legal_statuses=legal_statuses,
),
)
except Exception:
logger.exception("get_matched_folder_templates_list_failed", extra={"case_type": case_type})
return []
def get_matched_case_file_templates(
self,
case_type: str,
case_stage: str,
applicable_institutions: list[str] | None = None,
) -> list[JSONDict]:
try:
return cast(
list[JSONDict],
self.document_service.find_matching_case_file_templates(
case_type=case_type,
case_stage=case_stage,
applicable_institutions=applicable_institutions,
),
)
except Exception:
logger.exception(
"get_matched_case_file_templates_failed",
extra={
"case_type": case_type,
"case_stage": case_stage,
"applicable_institutions": applicable_institutions,
},
)
return []
def get_case_file_sub_type_choices(self) -> list[tuple[str, str]]:
"""获取案件文件子类型选项。"""
try:
choices_module = import_module("apps.documents.models.choices")
return list(choices_module.DocumentCaseFileSubType.choices)
except Exception:
logger.exception("get_case_file_sub_type_choices_failed")
return []
def get_case_file_templates_for_detail(self, case: Case) -> tuple[list[JSONDict], str]:
"""获取详情页案件文件模板与缺失原因。"""
if not case.case_type:
return [], str(_("未设置案件类型"))
if not case.current_stage:
return [], str(_("未设置案件阶段"))
applicable_institutions = self._get_case_applicable_institutions(case)
return (
self.get_matched_case_file_templates(
case_type=case.case_type,
case_stage=case.current_stage,
applicable_institutions=applicable_institutions,
),
"",
)
def _get_case_applicable_institutions(self, case: Case) -> list[str]:
names: list[str] = []
seen: set[str] = set()
for authority in case.supervising_authorities.all():
name = str(getattr(authority, "name", "") or "").strip()
if not name or name in seen:
continue
seen.add(name)
names.append(name)
return names
def build_our_legal_entities(self, case: Case) -> list[SimplePartyPayload]:
"""构建我方主体(法人)视图数据。"""
return [
{"id": party.client.id, "name": party.client.name}
for party in case.parties.all()
if getattr(party.client, "is_our_client", False) and getattr(party.client, "client_type", "") == "legal"
]
def build_our_parties(self, case: Case) -> list[DetailPartyPayload]:
"""构建我方当事人视图数据。"""
parties: list[DetailPartyPayload] = []
for party in case.parties.all():
client = party.client
if not getattr(client, "is_our_client", False):
continue
parties.append(
{
"id": client.id,
"name": client.name,
"client_type": getattr(client, "client_type", "") or "",
"legal_status": getattr(party, "legal_status", None),
"legal_status_display": (
getattr(party, "get_legal_status_display", lambda: "")()
if getattr(party, "legal_status", None)
else ""
),
}
)
return parties
def build_respondents(self, case: Case) -> list[SimplePartyPayload]:
"""构建对方当事人视图数据。"""
return [
{"id": party.client.id, "name": party.client.name}
for party in case.parties.all()
if not getattr(party.client, "is_our_client", False)
]
def build_material_view_parties(self, case: Case) -> tuple[list[MaterialPartyPayload], list[MaterialPartyPayload]]:
"""构建材料页我方/对方当事人数据。"""
our_parties: list[MaterialPartyPayload] = []
opponent_parties: list[MaterialPartyPayload] = []
for party in case.parties.all():
client = party.client
item: MaterialPartyPayload = {
"id": party.id,
"name": getattr(client, "name", "") or "",
"legal_status": getattr(party, "legal_status", None),
"legal_status_display": (
getattr(party, "get_legal_status_display", lambda: "")()
if getattr(party, "legal_status", None)
else ""
),
}
if getattr(client, "is_our_client", False):
our_parties.append(item)
else:
opponent_parties.append(item)
return our_parties, opponent_parties
def build_material_view_authorities(self, case: Case) -> list[AuthorityPayload]:
"""构建材料页主管机关数据。"""
return [
{
"id": authority.id,
"name": authority.name or "",
"authority_type": authority.authority_type or "",
"authority_type_display": authority.get_authority_type_display() if authority.authority_type else "",
}
for authority in case.supervising_authorities.all().order_by("created_at")
]
def get_case_with_admin_relations(self, case_id: int) -> Case | None:
"""按 admin 详情页需求查询案件及关联数据。"""
from django.db.models import Prefetch
from apps.cases.models import CaseLog
try:
return cast(
Case,
Case.objects.select_related(
"contract",
"folder_binding",
)
.prefetch_related(
"case_numbers",
"supervising_authorities",
"parties__client",
"assignments__lawyer",
Prefetch(
"logs",
queryset=CaseLog.objects.select_related("actor")
.prefetch_related("attachments")
.order_by("-created_at"),
),
"chats",
)
.get(pk=case_id),
)
except Case.DoesNotExist:
return None
def build_materials_view_payload(
self,
*,
case: Case,
material_service: CaseMaterialServiceProtocol,
law_firm_id: int | None,
) -> MaterialViewPayload:
"""构建材料页所需数据。"""
used_type_ids = material_service.get_used_type_ids(case_id=case.id)
party_types = material_service.get_material_types_by_category(
category="party",
law_firm_id=law_firm_id,
used_type_ids=used_type_ids,
)
non_party_types = material_service.get_material_types_by_category(
category="non_party",
law_firm_id=law_firm_id,
used_type_ids=used_type_ids,
)
our_parties, opponent_parties = self.build_material_view_parties(case)
authorities = self.build_material_view_authorities(case)
return {
"party_types": party_types,
"non_party_types": non_party_types,
"our_parties": our_parties,
"opponent_parties": opponent_parties,
"authorities": authorities,
}
def group_templates_by_sub_type(
self,
templates: list[JSONDict],
sub_type_choices: list[tuple[str, str]],
*,
exclude_special_sub_types: bool = True,
) -> list[tuple[str, list[JSONDict]]]:
"""按案件文件子类型分组模板。"""
special_sub_types = {"power_of_attorney_materials", "property_preservation_materials"}
label_map = dict(sub_type_choices)
groups: dict[str, list[JSONDict]] = {}
for template in templates:
sub_type = cast(str, template.get("case_sub_type", "other_materials"))
if exclude_special_sub_types and sub_type in special_sub_types:
continue
groups.setdefault(sub_type, []).append(template)
order = [choice[0] for choice in sub_type_choices]
order_set = set(order)
ordered_keys = [key for key in order if key in groups]
extra_keys = sorted(key for key in groups.keys() if key not in order_set)
ordered_keys.extend(extra_keys)
return [(label_map.get(key, key), groups[key]) for key in ordered_keys]
def detect_special_template_flags(self, unified_templates: list[JSONDict]) -> tuple[bool, bool]:
"""识别详情页特殊模板标记。"""
has_preservation_template = any(
template.get("function_code") == "preservation_application"
or "财产保全申请书" in cast(str, template.get("name") or "")
for template in unified_templates
)
has_delay_delivery_template = any(
template.get("function_code") == "delay_delivery_application"
or "暂缓送达申请书" in cast(str, template.get("name") or "")
for template in unified_templates
)
return has_preservation_template, has_delay_delivery_template
def serialize_queryset_for_export(self, queryset: QuerySet[Case]) -> list[JSONDict]:
"""序列化案件列表用于 Admin 导出。"""
from apps.cases.services.case.case_admin_export_bridge import get_case_admin_export_prefetches
from apps.cases.services.case.case_contract_export_bridge import (
get_case_admin_contract_export_prefetches,
serialize_contract_for_case_export,
)
from apps.cases.services.case.case_export_serializer_service import serialize_case_obj
result: list[JSONDict] = []
for case in queryset.prefetch_related(
*get_case_admin_export_prefetches(),
*get_case_admin_contract_export_prefetches(),
):
data = serialize_case_obj(case)
data["contract"] = serialize_contract_for_case_export(case.contract) if case.contract else None
result.append(data)
return result
def collect_file_paths_for_export(self, queryset: QuerySet[Case]) -> list[str]:
"""收集案件导出相关文件路径。"""
from apps.cases.services.case.case_admin_export_bridge import (
collect_case_file_paths_for_export,
get_case_admin_file_prefetches,
)
from apps.cases.services.case.case_contract_export_bridge import (
collect_contract_file_paths_for_case_export,
get_case_admin_contract_file_prefetches,
)
seen: set[str] = set()
paths: list[str] = []
def _add(path: str) -> None:
if path and path not in seen:
seen.add(path)
paths.append(path)
for case in queryset.prefetch_related(
*get_case_admin_file_prefetches(),
*get_case_admin_contract_file_prefetches(),
):
if case.contract:
collect_contract_file_paths_for_case_export(case.contract, _add)
collect_case_file_paths_for_export(case, _add)
return paths
def import_cases_from_json_data(
self,
data_list: list[ImportData],
*,
case_import_service: CaseImportServiceProtocol,
) -> tuple[int, int, list[str]]:
"""执行 Admin JSON 导入并返回成功/跳过/错误统计。"""
success = skipped = 0
errors: list[str] = []
for index, item in enumerate(data_list, 1):
try:
filing_number = item.get("filing_number")
before = Case.objects.filter(filing_number=filing_number).exists() if filing_number else False
case_import_service.import_one(item)
if before:
skipped += 1
else:
success += 1
except Exception as exc:
logger.exception("导入案件失败", extra={"index": index, "case_name": item.get("name", "?")})
errors.append(f"[{index}] {item.get('name', '?')} ({type(exc).__name__}): {exc}")
return success, skipped, errors
@transaction.atomic
def duplicate_case(self, case_id: int) -> Case:
"""
复制案件及其所有关联数据(不复制日志和群聊)
case_id: 原案件ID
新创建的案件对象
"""
# 获取原案件
original = Case.objects.get(pk=case_id)
# 复制主对象
new_case = Case.objects.create(
contract=original.contract,
is_archived=False, # 副本默认未建档
name=f"{original.name} (副本)",
status=original.status,
effective_date=original.effective_date,
cause_of_action=original.cause_of_action,
target_amount=original.target_amount,
case_type=original.case_type,
current_stage=original.current_stage,
)
# 批量复制当事人
parties_to_create = [
CaseParty(
case=new_case,
client=party.client,
legal_status=party.legal_status,
)
for party in original.parties.all()
]
CaseParty.objects.bulk_create(parties_to_create)
# 批量复制律师指派
assignments_to_create = [
CaseAssignment(
case=new_case,
lawyer=assignment.lawyer,
)
for assignment in original.assignments.all()
]
CaseAssignment.objects.bulk_create(assignments_to_create)
# 批量复制主管机关
authorities_to_create = [
SupervisingAuthority(
case=new_case,
name=authority.name,
authority_type=authority.authority_type,
)
for authority in original.supervising_authorities.all()
]
SupervisingAuthority.objects.bulk_create(authorities_to_create)
# 批量复制案号
case_numbers_to_create = [
CaseNumber(
case=new_case,
number=case_number.number,
remarks=case_number.remarks,
)
for case_number in original.case_numbers.all()
]
CaseNumber.objects.bulk_create(case_numbers_to_create)
# 注意:不复制 CaseLog(日志)和 CaseChat(群聊)
return new_case
@transaction.atomic
def handle_case_filing_change(self, case_id: int, is_archived: bool) -> str | None:
"""
处理案件建档状态变化
业务逻辑:
- 如果 is_archived=True 且 filing_number 为空,调用 FilingNumberService 生成编号
- 如果 is_archived=True 且 filing_number 已存在,返回现有编号
- 如果 is_archived=False,不修改 filing_number(保留在数据库中)
case_id: 案件ID
is_archived: 是否已建档
str | None: 建档编号(如果已建档)
NotFoundError: 案件不存在
ValidationException: 数据验证失败
Requirements: 5.1, 5.2, 6.1, 6.2, 6.3, 6.4
"""
from apps.core.exceptions import NotFoundError
try:
case = Case.objects.get(pk=case_id)
except Case.DoesNotExist:
raise NotFoundError(
message=_("案件不存在"), code="CASE_NOT_FOUND", errors={"case_id": f"ID为 {case_id} 的案件不存在"}
) from None
# 如果取消建档,不修改 filing_number(保留在数据库中)
if not is_archived:
logger.info(
"取消案件建档,保留建档编号",
extra={"case_id": case_id, "filing_number": case.filing_number, "action": "handle_case_filing_change"},
)
return None
# 如果已建档且已有编号,返回现有编号
if case.filing_number:
logger.info(
"案件已有建档编号,返回现有编号",
extra={"case_id": case_id, "filing_number": case.filing_number, "action": "handle_case_filing_change"},
)
return cast(str, case.filing_number)
# 如果已建档但没有编号,生成新编号
created_year = case.start_date.year
case_type = str(case.case_type or "")
filing_number = cast(
str,
self.filing_number_service.generate_case_filing_number_internal(
case_id=case_id,
case_type=case_type,
created_year=created_year,
),
)
# 保存编号到数据库
case.filing_number = filing_number
case.save(update_fields=["filing_number"])
logger.info(
"生成并保存案件建档编号",
extra={"case_id": case_id, "filing_number": filing_number, "action": "handle_case_filing_change"},
)
return filing_number