import logging from pathlib import Path from typing import TYPE_CHECKING, Any from django import forms from django.apps import apps as django_apps from django.contrib import admin, messages from django.db.models import QuerySet from django.forms import ModelForm from django.http import HttpRequest, JsonResponse from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from apps.client.models import Client, ClientIdentityDoc from apps.client.ports import CredentialPort, GsxtReportPort from apps.client.services.client_export_serializer_service import serialize_client_obj from apps.client.services.wiring import get_credential_port, get_gsxt_report_port from apps.core.admin.mixins import AdminImportExportMixin logger = logging.getLogger("apps.client") def _get_admin_service() -> Any: """工厂函数:创建 ClientAdminService 实例""" from apps.client.services import ClientAdminService return ClientAdminService() def _get_gsxt_report_task_model() -> type[Any]: """延迟获取 GsxtReportTask 模型。""" return django_apps.get_model("automation", "GsxtReportTask") class GsxtReportTaskInlineForm(forms.ModelForm[Any]): # type: ignore[misc] class Meta: model = None # type: ignore[misc] fields: list[str] = [] def __init__(self, *args: Any, **kwargs: Any) -> None: # 动态设置 Meta.model if self._meta.model is None: self._meta.model = _get_gsxt_report_task_model() super().__init__(*args, **kwargs) class Media: css = {"all": ("automation/gsxt_inline.css",)} class GsxtReportTaskInline(admin.TabularInline[Any]): # type: ignore[type-arg] form = GsxtReportTaskInlineForm extra = 0 can_delete = False fields = ("created_at", "status", "error_message", "inbox_link") # type: ignore[assignment] readonly_fields = ("created_at", "status", "error_message", "inbox_link") # type: ignore[assignment] ordering = ("-created_at",) verbose_name = _("企业信用报告任务") verbose_name_plural = _("企业信用报告任务") def get_model(self) -> type[Any]: # type: ignore[override] """延迟获取模型。""" return _get_gsxt_report_task_model() def inbox_link(self, obj: Any) -> str: """收件箱链接,使用端口获取状态检查。""" gsxt_port: GsxtReportPort = get_gsxt_report_port() credential_port: CredentialPort = get_credential_port() # 获取状态选项用于比较 status_choices = gsxt_port.get_task_status_choices() waiting_email_value = next( (v for v, label in status_choices if "waiting_email" in v), "waiting_email", ) if obj.status != waiting_email_value: return "—" credential = credential_port.get_gsxt_credential() email = credential.account if credential else "huangsong94@163.com" return format_html( '📬 打开 {} 收件箱', email, ) inbox_link.short_description = _("收件箱") # type: ignore[attr-defined] def save_formset( self, request: HttpRequest, form: Any, formset: Any, change: bool, ) -> None: """保存表单集,使用端口处理报告上传。""" from django.conf import settings instances = formset.save(commit=False) gsxt_port: GsxtReportPort = get_gsxt_report_port() for f in formset.forms: uploaded = f.cleaned_data.get("report_upload") if f.cleaned_data else None if not uploaded: continue obj: Any = f.instance if not obj.pk: continue client = obj.client rel_path = f"client_docs/{client.pk}/{client.name[:20]}_企业信用报告.pdf" abs_path = Path(settings.MEDIA_ROOT) / rel_path abs_path.parent.mkdir(parents=True, exist_ok=True) with abs_path.open("wb") as fp: for chunk in uploaded.chunks(): fp.write(chunk) doc, _ = ClientIdentityDoc.objects.get_or_create( client=client, doc_type=ClientIdentityDoc.BUSINESS_LICENSE, ) doc.file_path = str(rel_path) doc.save(update_fields=["file_path"]) # 使用端口标记任务成功 gsxt_port.upload_report( task_id=obj.pk, file_content=b"", # 文件已保存到本地,这里不传内容 file_name=rel_path, ) formset.save_m2m() class ClientIdentityDocInlineForm(forms.ModelForm[ClientIdentityDoc]): upload = forms.FileField(required=False, label=_("上传文件")) class Meta: model = ClientIdentityDoc fields = ["doc_type", "upload"] class ClientIdentityDocInline(admin.TabularInline[ClientIdentityDoc]): # type: ignore[type-arg] model = ClientIdentityDoc form = ClientIdentityDocInlineForm extra = 1 fields = ("doc_type", "file_link", "upload") # type: ignore[assignment] readonly_fields = ("file_link",) # type: ignore[assignment] def file_link(self, obj: ClientIdentityDoc) -> str: url = obj.media_url if url: return format_html('{}', url, Path(obj.file_path or "").name) return "" file_link.short_description = _("文件") # type: ignore[attr-defined] class ClientAdminForm(forms.ModelForm[Client]): class Meta: model = Client fields = "__all__" class Media: css = {"all": ("client/admin.css",)} js = ("admin/js/jquery.init.js", "client/admin.js") def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) ct = None if self.instance and getattr(self.instance, "client_type", None): ct = self.instance.client_type elif "client_type" in self.data: ct = self.data.get("client_type") elif self.initial.get("client_type"): ct = self.initial.get("client_type") self.fields["id_number"].label = _("身份证号码") if ct == "natural" else _("统一社会信用代码") @admin.register(Client) class ClientAdmin(AdminImportExportMixin, admin.ModelAdmin[Client]): list_display = ("id", "name", "client_type", "is_our_client", "phone", "legal_representative") # type: ignore[assignment] search_fields = ("name", "phone", "id_number") # type: ignore[assignment] list_filter = ("client_type", "is_our_client") # type: ignore[assignment] ordering = ("-pk",) # type: ignore[assignment] form = ClientAdminForm inlines: list[type[Any]] = [] # type: ignore[assignment,misc] export_model_name = "client" import_required_fields = ("name",) actions = ["export_selected_as_json", "export_all_as_json"] # type: ignore[assignment] def get_urls(self) -> list[Any]: from django.urls import path urls = super().get_urls() custom = [ path( "/fetch-gsxt-report/", self.admin_site.admin_view(self._fetch_gsxt_report_view), name="client_client_fetch_gsxt_report", ), path( "/upload-gsxt-report//", self.admin_site.admin_view(self._upload_gsxt_report_view), name="client_client_upload_gsxt_report", ), path( "check-oa-credential/", self.admin_site.admin_view(self._check_oa_credential_view), name="client_client_check_oa_credential", ), ] return custom + urls def _fetch_gsxt_report_view(self, request: HttpRequest, client_id: int) -> Any: """获取企业信用报告视图。""" from django.shortcuts import redirect from apps.automation.services.gsxt.gsxt_login_service import GsxtLoginError client = Client.objects.get(pk=client_id) if client.client_type != "legal": self.message_user( request, _("仅法人/非法人组织当事人支持获取企业信用报告"), messages.WARNING, ) return redirect(f"../../{client_id}/change/") # 使用端口获取凭证 credential_port: CredentialPort = get_credential_port() credential = credential_port.get_gsxt_credential() if not credential: self.message_user( request, _("未找到国家企业信用信息公示系统账号,请先在账号密码管理中添加"), messages.ERROR, ) return redirect(f"../../{client_id}/change/") # 使用端口创建任务 gsxt_port: GsxtReportPort = get_gsxt_report_port() try: task_id = gsxt_port.create_report_task( client_id=client.id, company_name=client.name, credit_code=client.id_number or "", ) # 非阻塞:启动 Chrome、填账号密码,立即返回 gsxt_port.start_login( credential_id=credential.id, task_id=task_id, ) except GsxtLoginError as e: self.message_user(request, str(e), messages.ERROR) return redirect(f"../../{client_id}/change/") self.message_user( request, _("Chrome 已打开登录页,请在浏览器中完成验证码,系统将自动继续后续流程"), messages.SUCCESS, ) return redirect(f"../../{client_id}/change/") def _upload_gsxt_report_view( self, request: HttpRequest, client_id: int, task_id: int, ) -> Any: """上传企业信用报告视图。""" from django.conf import settings from django.shortcuts import redirect if request.method != "POST" or not request.FILES.get("report_file"): self.message_user(request, _("请选择 PDF 文件"), messages.WARNING) return redirect(f"../../{client_id}/change/") # 使用端口获取任务 gsxt_port: GsxtReportPort = get_gsxt_report_port() task = gsxt_port.get_waiting_email_task(client_id=client_id) if not task or task.id != task_id: self.message_user(request, _("任务不存在或状态不正确"), messages.ERROR) return redirect(f"../../{client_id}/change/") client = task.client uploaded: Any = request.FILES["report_file"] rel_path = f"client_docs/{client.pk}/{client.name[:20]}_企业信用报告.pdf" abs_path = Path(settings.MEDIA_ROOT) / rel_path abs_path.parent.mkdir(parents=True, exist_ok=True) file_content = b"" with abs_path.open("wb") as f: for chunk in uploaded.chunks(): f.write(chunk) file_content += chunk doc, _ = ClientIdentityDoc.objects.get_or_create( client=client, doc_type=ClientIdentityDoc.BUSINESS_LICENSE, ) doc.file_path = str(rel_path) doc.save(update_fields=["file_path"]) # 使用端口标记任务成功 gsxt_port.upload_report( task_id=task_id, file_content=file_content, file_name=rel_path, ) self.message_user( request, _("报告已上传并保存为营业执照附件"), messages.SUCCESS, ) return redirect(f"../../{client_id}/change/") def _check_oa_credential_view(self, request: HttpRequest) -> JsonResponse: """检查当前用户是否有金诚同达OA凭证。""" from django.db.models import Q lawyer_id = getattr(request.user, "id", None) if lawyer_id is None: return JsonResponse({"has_credential": False, "error": "无效用户"}) from apps.organization.models import AccountCredential credential = AccountCredential.objects.filter( Q(account__icontains="jtn.com") | Q(url__icontains="jtn.com"), lawyer_id=lawyer_id, ).exists() return JsonResponse({"has_credential": credential}) def get_changeform_initial_data(self, request: HttpRequest) -> dict[str, Any]: return {"client_type": "legal"} def get_inlines(self, request: HttpRequest, obj: Client | None = None) -> list[type[Any]]: inlines: list[type[Any]] = [ClientIdentityDocInline] if obj and obj.client_type == "legal": inlines.append(GsxtReportTaskInline) return inlines def save_formset(self, request: HttpRequest, form: ModelForm[Client], formset: Any, change: bool) -> None: # 收集需要处理的上传文件信息(在 save 之前) upload_info: list[dict[str, Any]] = [] for f in formset.forms: if not f.cleaned_data: continue if f.cleaned_data.get("DELETE"): continue uploaded_file = f.cleaned_data.get("upload") if uploaded_file: upload_info.append( { "form": f, "uploaded_file": uploaded_file, "doc_type": f.cleaned_data.get("doc_type"), } ) # 调用父类 save,让 Django 处理保存和设置 new_objects 等属性 formset.save() # 处理文件上传和重命名 if upload_info: admin_service = _get_admin_service() client = form.instance for info in upload_info: instance = info["form"].instance if instance.pk: admin_service.save_and_rename_file( client_id=client.id, client_name=client.name, doc_id=instance.pk, doc_type=info["doc_type"], uploaded_file=info["uploaded_file"], ) # 如果上传了营业执照,把 WAITING_EMAIL 的报告任务标记为成功 has_business_license = any(info["doc_type"] == ClientIdentityDoc.BUSINESS_LICENSE for info in upload_info) if has_business_license: gsxt_port: GsxtReportPort = get_gsxt_report_port() task = gsxt_port.get_waiting_email_task(client_id=form.instance.id) if task: gsxt_port.upload_report( task_id=task.id, file_content=b"", file_name="manual_upload", ) def handle_json_import( self, data_list: list[dict[str, Any]], user: str, zip_file: Any ) -> tuple[int, int, list[str]]: from apps.client.services.client_resolve_service import ClientResolveService svc = ClientResolveService() success = skipped = 0 errors: list[str] = [] for i, item in enumerate(data_list, 1): try: id_number = item.get("id_number") before = Client.objects.filter(id_number=id_number).exists() if id_number else False svc.resolve_with_attachments(item) if not before: success += 1 else: skipped += 1 except Exception as exc: logger.exception("导入客户失败", extra={"index": i, "client_name": item.get("name", "?")}) errors.append(f"[{i}] {item.get('name', '?')} ({type(exc).__name__}): {exc}") return success, skipped, errors def serialize_queryset(self, queryset: QuerySet[Client]) -> list[dict[str, Any]]: result = [] for obj in queryset.prefetch_related("identity_docs", "property_clues__attachments"): result.append(serialize_client_obj(obj)) return result def get_file_paths(self, queryset: QuerySet[Client]) -> list[str]: paths = [] for obj in queryset.prefetch_related("identity_docs", "property_clues__attachments"): for doc in obj.identity_docs.all(): if doc.file_path: paths.append(doc.file_path) for clue in obj.property_clues.all(): for att in clue.attachments.all(): if att.file_path: paths.append(att.file_path) return paths