feat(application-form-fill): 展示自动填表工作流卡片
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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/<int:batch_id>/status/",
|
||||
application_form_fill_batch_status,
|
||||
name="application_form_fill_batch_status",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/"
|
||||
>
|
||||
<section class="summary-section upload-section">
|
||||
|
||||
48
tests/test_application_form_fill_frontend.py
Normal file
48
tests/test_application_form_fill_frontend.py
Normal file
@@ -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
|
||||
113
tests/test_application_form_fill_views.py
Normal file
113
tests/test_application_form_fill_views.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user