File size: 16,725 Bytes
b4f2238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
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(
            '<a href="https://mail.163.com" target="_blank">📬 打开 {} 收件箱</a>',
            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('<a href="{}" target="_blank">{}</a>', 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(
                "<int:client_id>/fetch-gsxt-report/",
                self.admin_site.admin_view(self._fetch_gsxt_report_view),
                name="client_client_fetch_gsxt_report",
            ),
            path(
                "<int:client_id>/upload-gsxt-report/<int:task_id>/",
                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