From 4ac9c04dbf5cac00e889cd05595f272700d818ac Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 18:43:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(application-form-fill):=20=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E8=87=AA=E5=8A=A8=E5=A1=AB=E8=A1=A8=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- review_agent/application_form_fill/views.py | 122 ++++++++++++++++++- review_agent/urls.py | 14 +++ review_agent/views.py | 32 ++++- static/js/app.js | 10 +- templates/home.html | 1 + tests/test_application_form_fill_frontend.py | 48 ++++++++ tests/test_application_form_fill_views.py | 113 +++++++++++++++++ 7 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 tests/test_application_form_fill_frontend.py create mode 100644 tests/test_application_form_fill_views.py diff --git a/review_agent/application_form_fill/views.py b/review_agent/application_form_fill/views.py index 510ac0d..fb147b4 100644 --- a/review_agent/application_form_fill/views.py +++ b/review_agent/application_form_fill/views.py @@ -1,7 +1,127 @@ -from django.http import JsonResponse +import json + +from django.contrib.auth.decorators import login_required +from django.conf import settings +from django.http import Http404, JsonResponse from django.views.decorators.http import require_http_methods +from review_agent.application_form_fill.workflow import ( + create_application_form_fill_batch, + find_latest_successful_summary_batch, + start_application_form_fill_workflow, +) +from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileSummaryBatch, WorkflowNodeRun + @require_http_methods(["GET"]) def health(request): return JsonResponse({"workflow_type": "application_form_fill", "status": "available"}) + + +@login_required +@require_http_methods(["POST"]) +def start(request): + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return JsonResponse({"error": "JSON 格式错误。"}, status=400) + + conversation = Conversation.objects.filter(pk=payload.get("conversation_id"), user=request.user).first() + if not conversation: + raise Http404("对话不存在。") + + summary_batch = None + if payload.get("file_summary_batch_id"): + summary_batch = FileSummaryBatch.objects.filter( + pk=payload.get("file_summary_batch_id"), + conversation=conversation, + user=request.user, + status=FileSummaryBatch.Status.SUCCESS, + ).first() + if summary_batch is None: + summary_batch = find_latest_successful_summary_batch(conversation) + if summary_batch is None: + return JsonResponse({"error": "请先上传资料并完成文件汇总。"}, status=400) + + batch = create_application_form_fill_batch( + conversation=conversation, + user=request.user, + source_summary_batch=summary_batch, + requested_templates=payload.get("template_codes") or [], + output_types=payload.get("output_types") or None, + ) + start_application_form_fill_workflow(batch, async_run=getattr(settings, "APPLICATION_FORM_FILL_ASYNC", True)) + return JsonResponse( + { + "batch_id": batch.pk, + "workflow_type": "application_form_fill", + "status": batch.status, + "selected_templates": batch.selected_templates, + } + ) + + +@login_required +@require_http_methods(["GET"]) +def batch_status(request, batch_id: int): + batch = ApplicationFormFillBatch.objects.filter( + pk=batch_id, + conversation__user=request.user, + is_deleted=False, + ).first() + if not batch: + raise Http404("填表批次不存在。") + exports = ExportedSummaryFile.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + ).order_by("id") + return JsonResponse( + { + "batch": { + "id": batch.pk, + "workflow_type": "application_form_fill", + "batch_no": batch.batch_no, + "status": batch.status, + "product_name": batch.product_name, + "selected_templates": batch.selected_templates, + "conflict_count": len(batch.conflict_summary or []), + "risk_summary_text": _risk_summary_text(batch), + "error_message": batch.error_message, + }, + "nodes": [ + { + "node_code": node.node_code, + "node_name": node.node_name, + "status": node.status, + "progress": node.progress, + "message": node.message, + } + for node in WorkflowNodeRun.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + ).order_by("id") + ], + "conflicts": batch.conflict_summary or [], + "exports": [ + { + "id": export.pk, + "export_type": export.export_type, + "export_category": export.export_category, + "file_name": export.file_name, + "download_url": f"/api/review-agent/file-summary/exports/{export.pk}/download/", + } + for export in exports + ], + } + ) + + +def _risk_summary_text(batch: ApplicationFormFillBatch) -> str: + parts = [] + if batch.selected_templates: + parts.append("模板 " + "、".join(batch.selected_templates)) + if batch.conflict_summary: + parts.append(f"冲突字段 {len(batch.conflict_summary)}") + if batch.risk_notes: + parts.append(f"提示 {len(batch.risk_notes)}") + return " · ".join(parts) diff --git a/review_agent/urls.py b/review_agent/urls.py index 50f4c32..44deeb7 100644 --- a/review_agent/urls.py +++ b/review_agent/urls.py @@ -16,6 +16,10 @@ from .regulatory_review.views import ( review_issues as regulatory_review_review_issues, start_full_review as regulatory_review_start_full_review, ) +from .application_form_fill.views import ( + batch_status as application_form_fill_batch_status, + start as application_form_fill_start, +) urlpatterns = [ @@ -84,4 +88,14 @@ urlpatterns = [ regulatory_review_review_issues, name="regulatory_review_review_issues", ), + path( + "api/review-agent/application-form-fill/start/", + application_form_fill_start, + name="application_form_fill_start", + ), + path( + "api/review-agent/application-form-fill//status/", + application_form_fill_batch_status, + name="application_form_fill_batch_status", + ), ] diff --git a/review_agent/views.py b/review_agent/views.py index 2f78b2b..4b0d3da 100644 --- a/review_agent/views.py +++ b/review_agent/views.py @@ -11,7 +11,7 @@ from .services import ( send_message, stream_message, ) -from .models import Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun +from .models import ApplicationFormFillBatch, Conversation, FileAttachment, FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun from .regulatory_review.services.info_extract import ensure_regulatory_condition_candidates @@ -155,6 +155,25 @@ def build_workflow_cards(conversation: Conversation) -> list[dict[str, object]]: ), } ) + form_fill_batches = ApplicationFormFillBatch.objects.filter(conversation=conversation, is_deleted=False) + for batch in form_fill_batches: + cards.append( + { + "id": batch.pk, + "workflow_type": "application_form_fill", + "batch_no": batch.batch_no, + "status": batch.status, + "error_message": batch.error_message, + "risk_label": _format_form_fill_label(batch), + "created_at": batch.created_at, + "nodes": list( + WorkflowNodeRun.objects.filter( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + ).order_by("id") + ), + } + ) return sorted(cards, key=lambda item: item["created_at"], reverse=True)[:5] @@ -187,3 +206,14 @@ def _format_risk_label(risk_summary: dict) -> str: if count: parts.append(f"{label} {count}") return " · ".join(parts) + + +def _format_form_fill_label(batch: ApplicationFormFillBatch) -> str: + parts = [] + if batch.selected_templates: + parts.append("模板 " + "、".join(batch.selected_templates)) + if batch.conflict_summary: + parts.append(f"冲突字段 {len(batch.conflict_summary)}") + if batch.risk_notes: + parts.append(f"提示 {len(batch.risk_notes)}") + return " · ".join(parts) diff --git a/static/js/app.js b/static/js/app.js index d1d4c60..ef6bd75 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -464,8 +464,12 @@ } function statusUrlForWorkflow(workflow_type, batchId) { - var attributeName = - workflow_type === "regulatory_review" ? "data-regulatory-status-url-template" : "data-status-url-template"; + var attributeName = "data-status-url-template"; + if (workflow_type === "regulatory_review") { + attributeName = "data-regulatory-status-url-template"; + } else if (workflow_type === "application_form_fill") { + attributeName = "data-application-form-fill-status-url-template"; + } return templateUrl(attributeName, "__batch_id__", batchId); } @@ -832,7 +836,7 @@ } function isWorkflowTerminalStatus(status) { - return status === "success" || status === "failed"; + return status === "success" || status === "partial_success" || status === "failed"; } function workflowTimerKey(batchId, workflow_type) { diff --git a/templates/home.html b/templates/home.html index 50301fb..f6f49a1 100644 --- a/templates/home.html +++ b/templates/home.html @@ -216,6 +216,7 @@ data-message-url-template="/api/review-agent/conversations/__conversation_id__/messages/" data-status-url-template="/api/review-agent/file-summary/__batch_id__/status/" data-regulatory-status-url-template="/api/review-agent/regulatory-review/__batch_id__/status/" + data-application-form-fill-status-url-template="/api/review-agent/application-form-fill/__batch_id__/status/" data-events-url-template="/api/review-agent/file-summary/__batch_id__/events/" >
diff --git a/tests/test_application_form_fill_frontend.py b/tests/test_application_form_fill_frontend.py new file mode 100644 index 0000000..ae16656 --- /dev/null +++ b/tests/test_application_form_fill_frontend.py @@ -0,0 +1,48 @@ +import pytest +from django.urls import reverse + +from review_agent.models import ApplicationFormFillBatch, Conversation, FileSummaryBatch, WorkflowNodeRun + + +pytestmark = pytest.mark.django_db + + +def test_workspace_renders_application_form_fill_workflow_card(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-CARD") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-CARD", + status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS, + selected_templates=["registration_certificate"], + risk_notes=[{"type": "pdf_pending"}], + ) + WorkflowNodeRun.objects.create( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + node_group="form_fill", + node_code="word_fill", + node_name="填写 Word", + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + ) + client.force_login(user) + + response = client.get(f"{reverse('home')}?conversation={conversation.pk}") + + content = response.content.decode("utf-8") + assert "AFF-CARD" in content + assert 'data-workflow-type="application_form_fill"' in content + assert "填写 Word" in content + assert "data-application-form-fill-status-url-template" in content + + +def test_frontend_selects_application_form_fill_status_url_and_terminal_status(): + script = open("static/js/app.js", encoding="utf-8").read() + + assert 'workflow_type === "application_form_fill"' in script + assert "data-application-form-fill-status-url-template" in script + assert 'status === "partial_success"' in script diff --git a/tests/test_application_form_fill_views.py b/tests/test_application_form_fill_views.py new file mode 100644 index 0000000..65c6b77 --- /dev/null +++ b/tests/test_application_form_fill_views.py @@ -0,0 +1,113 @@ +import json + +import pytest +from django.urls import reverse + +from review_agent.application_form_fill.constants import FORM_FILL_NODE_DEFINITIONS +from review_agent.models import ( + ApplicationFormFillBatch, + Conversation, + ExportedSummaryFile, + FileSummaryBatch, + WorkflowNodeRun, +) + + +pytestmark = pytest.mark.django_db + + +def test_application_form_fill_start_requires_conversation_owner(client, monkeypatch, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + FileSummaryBatch.objects.create( + conversation=conversation, + user=owner, + batch_no="FS-VIEW", + status=FileSummaryBatch.Status.SUCCESS, + ) + monkeypatch.setattr("review_agent.application_form_fill.views.start_application_form_fill_workflow", lambda batch, async_run=True: None) + client.force_login(other) + + response = client.post( + reverse("application_form_fill_start"), + data=json.dumps({"conversation_id": conversation.pk}), + content_type="application/json", + ) + + assert response.status_code == 404 + + +def test_application_form_fill_start_creates_batch(client, monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileSummaryBatch.objects.create( + conversation=conversation, + user=user, + batch_no="FS-VIEW-OK", + status=FileSummaryBatch.Status.SUCCESS, + ) + monkeypatch.setattr("review_agent.application_form_fill.views.start_application_form_fill_workflow", lambda batch, async_run=True: None) + client.force_login(user) + + response = client.post( + reverse("application_form_fill_start"), + data=json.dumps({"conversation_id": conversation.pk, "template_codes": ["registration_certificate"]}), + content_type="application/json", + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["workflow_type"] == "application_form_fill" + assert ApplicationFormFillBatch.objects.filter(conversation=conversation).exists() + + +def test_application_form_fill_status_requires_owner_and_returns_nodes_exports(client, tmp_path, django_user_model): + owner = django_user_model.objects.create_user(username="owner", password="pass") + other = django_user_model.objects.create_user(username="other", password="pass") + conversation = Conversation.objects.create(user=owner, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=owner, batch_no="FS-STATUS") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=owner, + source_summary_batch=summary, + batch_no="AFF-STATUS", + status=ApplicationFormFillBatch.Status.PARTIAL_SUCCESS, + selected_templates=["registration_certificate"], + conflict_summary=[{"field_key": "product_name"}], + risk_notes=[{"type": "pdf_pending"}], + ) + WorkflowNodeRun.objects.create( + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + node_group="form_fill", + node_code=FORM_FILL_NODE_DEFINITIONS[0][0], + node_name=FORM_FILL_NODE_DEFINITIONS[0][1], + status=WorkflowNodeRun.Status.SUCCESS, + progress=100, + ) + output = tmp_path / "filled.docx" + output.write_bytes(b"word") + exported = ExportedSummaryFile.objects.create( + batch=summary, + workflow_type="application_form_fill", + workflow_batch_id=batch.pk, + export_category="filled_template", + export_type=ExportedSummaryFile.ExportType.WORD, + file_name="filled.docx", + storage_path=str(output), + ) + + client.force_login(other) + denied = client.get(reverse("application_form_fill_batch_status", args=[batch.pk])) + assert denied.status_code == 404 + + client.force_login(owner) + allowed = client.get(reverse("application_form_fill_batch_status", args=[batch.pk])) + assert allowed.status_code == 200 + payload = allowed.json() + assert payload["batch"]["workflow_type"] == "application_form_fill" + assert payload["batch"]["status"] == ApplicationFormFillBatch.Status.PARTIAL_SUCCESS + assert payload["batch"]["conflict_count"] == 1 + assert payload["nodes"][0]["node_code"] == "prepare" + assert payload["exports"][0]["id"] == exported.pk