From 74af8c1f3bbfba0f8c39023fff087a84cbaa3fcd Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 13 May 2026 21:16:27 +0800 Subject: [PATCH] feat: add governance import center --- backend/app/api/governance.py | 9 + backend/app/services/governance_service.py | 252 ++++++++++++++ backend/tests/test_governance_api.py | 44 +++ backend/tests/test_governance_service.py | 13 + frontend/src/app/governance/imports/page.tsx | 308 ++++++++++++++++++ frontend/src/app/governance/page.tsx | 3 + frontend/src/app/governance/releases/page.tsx | 4 + frontend/src/services/governance.ts | 6 + frontend/src/types/api.ts | 49 +++ ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 42 +++ .../governance-productization-backlog.csv | 2 +- 11 files changed, 731 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/governance/imports/page.tsx diff --git a/backend/app/api/governance.py b/backend/app/api/governance.py index fa920397..df80a0e6 100644 --- a/backend/app/api/governance.py +++ b/backend/app/api/governance.py @@ -20,6 +20,7 @@ ROUTE_PERMISSION_ACTIONS = { "evidence_save": "governance:evidences:save", "release_summary": "governance:releases:summary", "release_detail": "governance:releases:detail", + "formal_launch_import_preview": "governance:imports:formal_launch:preview", "formal_launch_import": "governance:imports:formal_launch", } @@ -257,6 +258,14 @@ def get_governance_release_detail( return success_response(governance_service.get_release_detail(current_user, release_batch_id)) +@router.get("/imports/formal-launch/preview") +def preview_formal_launch_governance_import( + governance_service: GovernanceService = Depends(get_governance_service), + current_user: dict = Depends(require_current_user), +): + return success_response(governance_service.preview_formal_launch_import(current_user)) + + @router.post("/imports/formal-launch") def import_formal_launch_governance( batch_name: Optional[str] = Form(default=None), diff --git a/backend/app/services/governance_service.py b/backend/app/services/governance_service.py index 953a2fba..3d1e02d8 100644 --- a/backend/app/services/governance_service.py +++ b/backend/app/services/governance_service.py @@ -314,6 +314,160 @@ class GovernanceService: "gate_runs": [self._to_gate_run_item(item) for item in gate_runs], } + def preview_formal_launch_import(self, current_user: Dict[str, Any]) -> dict: + self._require_admin(current_user) + tenant_id = current_user["tenant_id"] + source_rows = self._get_formal_launch_source_rows() + assets = self.asset_repository.list_paginated(tenant_id, 1, 100000) + assignments = self.assignment_repository.list_paginated(tenant_id, 1, 100000) + active_assignments = [item for item in assignments if item.is_active] + current_summary = self._build_asset_summary(tenant_id) + recent_batches = self.release_batch_repository.list_recent(tenant_id, limit=10) + + asset_identity_map = {(asset.asset_type, asset.asset_id): asset for asset in assets} + assignment_key_map = {(assignment.governance_asset_id, assignment.assignment_role) for assignment in active_assignments} + existing_import_evidence_keys = set() + for asset in assets: + for evidence in self.evidence_repository.list_by_asset_id(tenant_id, asset.id): + metadata = evidence.metadata_json or {} + source_path = metadata.get("source_path") + if metadata.get("source") == "formal_launch_import" and source_path: + existing_import_evidence_keys.add((asset.id, source_path)) + + source_asset_identities: set[tuple[str, str]] = set() + source_assignment_specs: list[tuple[tuple[str, str], str]] = [] + source_evidence_specs: list[tuple[tuple[str, str], str]] = [] + source_decision_ready_rows = 0 + + def append_evidence_spec(asset_type: str, asset_id: str, evidence_path: Optional[str]) -> None: + normalized_path = (evidence_path or "").strip() + if normalized_path: + source_evidence_specs.append(((asset_type, asset_id), normalized_path)) + + for row in source_rows["review_task_rows"]: + asset_type = (row.get("asset_type") or "unknown").strip() + asset_id = (row.get("asset_id") or "").strip() + identity = (asset_type, asset_id) + source_asset_identities.add(identity) + for assignment_role in self._expand_assignment_roles((row.get("review_track") or "").strip(), (row.get("required_reviewer_role") or "").strip()): + source_assignment_specs.append((identity, assignment_role)) + if self._should_import_decision((row.get("approval_result") or "").strip()): + source_decision_ready_rows += 1 + append_evidence_spec(asset_type, asset_id, row.get("evidence_path")) + + for row in source_rows["material_rows"]: + asset_type = "production_material" + asset_id = (row.get("material_id") or "").strip() + identity = (asset_type, asset_id) + source_asset_identities.add(identity) + source_assignment_specs.append((identity, self._normalize_assignment_role((row.get("signoff_role") or "").strip() or "material_signoff"))) + if self._should_import_decision((row.get("signoff_result") or "").strip()): + source_decision_ready_rows += 1 + append_evidence_spec(asset_type, asset_id, row.get("evidence_path")) + + for row in source_rows["template_rows"]: + asset_type = "contract_template" + asset_id = (row.get("template_id") or "").strip() + identity = (asset_type, asset_id) + source_asset_identities.add(identity) + source_assignment_specs.append((identity, "legal_reviewer")) + source_assignment_specs.append((identity, "business_reviewer")) + if self._should_import_decision((row.get("production_approval_result") or "").strip()): + source_decision_ready_rows += 1 + append_evidence_spec(asset_type, asset_id, row.get("evidence_path")) + + for row in source_rows["knowledge_rows"]: + asset_type = (row.get("asset_type") or "legal_knowledge_card").strip() + asset_id = (row.get("asset_id") or "").strip() + identity = (asset_type, asset_id) + source_asset_identities.add(identity) + source_assignment_specs.append((identity, "legal_reviewer")) + if self._should_import_decision((row.get("production_approval_result") or "").strip()): + source_decision_ready_rows += 1 + append_evidence_spec(asset_type, asset_id, row.get("evidence_path")) + + assets_to_create = sum(1 for identity in source_asset_identities if identity not in asset_identity_map) + assignments_to_create = 0 + for identity, assignment_role in source_assignment_specs: + asset = asset_identity_map.get(identity) + if asset is None: + assignments_to_create += 1 + continue + if (asset.id, assignment_role) not in assignment_key_map: + assignments_to_create += 1 + + evidences_to_create = 0 + evidence_status_counts = {"ready": 0, "template": 0, "missing": 0} + for identity, evidence_path in source_evidence_specs: + status_value = self._resolve_import_evidence_status(evidence_path) + evidence_status_counts[status_value] = evidence_status_counts.get(status_value, 0) + 1 + asset = asset_identity_map.get(identity) + if asset is None: + evidences_to_create += 1 + continue + if (asset.id, evidence_path) not in existing_import_evidence_keys: + evidences_to_create += 1 + + source_inputs = [ + { + "key": "review_task_rows", + "label": "复核任务包", + "path": str(source_rows["review_task_path"].relative_to(self.repo_root)).replace("\\", "/"), + "row_count": len(source_rows["review_task_rows"]), + }, + { + "key": "material_rows", + "label": "生产资料签收台账", + "path": str(source_rows["material_path"].relative_to(self.repo_root)).replace("\\", "/"), + "row_count": len(source_rows["material_rows"]), + }, + { + "key": "template_rows", + "label": "模板签收台账", + "path": str(source_rows["template_path"].relative_to(self.repo_root)).replace("\\", "/"), + "row_count": len(source_rows["template_rows"]), + }, + { + "key": "knowledge_rows", + "label": "知识卡/规则/条款签收台账", + "path": str(source_rows["knowledge_path"].relative_to(self.repo_root)).replace("\\", "/"), + "row_count": len(source_rows["knowledge_rows"]), + }, + ] + + return { + "source_inputs": source_inputs, + "source_metrics": { + "review_task_rows": len(source_rows["review_task_rows"]), + "material_rows": len(source_rows["material_rows"]), + "template_rows": len(source_rows["template_rows"]), + "knowledge_rows": len(source_rows["knowledge_rows"]), + "total_rows": sum(item["row_count"] for item in source_inputs), + "decision_ready_rows": source_decision_ready_rows, + "evidence_rows": len(source_evidence_specs), + "ready_evidence_rows": evidence_status_counts.get("ready", 0), + "template_evidence_rows": evidence_status_counts.get("template", 0), + "missing_evidence_rows": evidence_status_counts.get("missing", 0), + }, + "current_summary": current_summary, + "preview_diff": { + "assets_to_create": assets_to_create, + "assignments_to_create": assignments_to_create, + "evidences_to_create": evidences_to_create, + "decision_ready_rows": source_decision_ready_rows, + "existing_assets": len(assets), + "existing_assignments": len(active_assignments), + "existing_release_batches": len(recent_batches), + }, + "package_progress": [ + self._build_collaboration_package_preview("LEGAL_CORE"), + self._build_collaboration_package_preview("BUSINESS_TEMPLATE"), + self._build_collaboration_package_preview("OPS_MATERIAL"), + self._build_collaboration_package_preview("FINAL_APPROVER"), + ], + "recent_batches": [self._to_release_batch_item(item) for item in recent_batches], + } + def get_formal_launch_database_snapshot(self, current_user: Dict[str, Any]) -> dict: self._require_admin(current_user) tenant_id = current_user["tenant_id"] @@ -908,6 +1062,104 @@ class GovernanceService: "blocking_items": blocking_items, } + def _get_formal_launch_source_rows(self) -> dict: + review_task_path = self.repo_root / "project-docs" / "formal-launch-ops" / "reviewers" / "real-review-task-package.csv" + material_path = self.repo_root / "project-docs" / "formal-launch-ops" / "signoff" / "production-material-signoff-register.csv" + template_path = self.repo_root / "project-docs" / "formal-launch-ops" / "signoff" / "production-template-signoff-register.csv" + knowledge_path = self.repo_root / "project-docs" / "formal-launch-ops" / "signoff" / "production-knowledge-signoff-register.csv" + return { + "review_task_path": review_task_path, + "material_path": material_path, + "template_path": template_path, + "knowledge_path": knowledge_path, + "review_task_rows": self._read_csv_rows(review_task_path), + "material_rows": self._read_csv_rows(material_path), + "template_rows": self._read_csv_rows(template_path), + "knowledge_rows": self._read_csv_rows(knowledge_path), + } + + def _build_collaboration_package_preview(self, group: str) -> dict: + bundle_path = self.repo_root / "project-docs" / "formal-launch-ops" / "reviewers" / "collaboration-packages" / group / "return-bundle.csv" + handoff_path = self.repo_root / "project-docs" / "formal-launch-ops" / "reviewers" / "collaboration-packages" / group / "handoff.md" + checklist_path = self.repo_root / "project-docs" / "formal-launch-ops" / "reviewers" / "collaboration-packages" / group / "final-launch-approval-checklist.md" + + if group == "FINAL_APPROVER": + checklist_exists = checklist_path.exists() + checklist_status = "missing" + if checklist_exists: + content = checklist_path.read_text(encoding="utf-8", errors="ignore") + if "EVIDENCE_STATUS: READY" in content and "TODO_REAL_REVIEWER" not in content and "TODO_YYYY-MM-DD" not in content: + checklist_status = "ready" + else: + checklist_status = "template" + return { + "group": group, + "reviewer_name": self._extract_markdown_value(handoff_path, "reviewer_name"), + "due_date": self._extract_markdown_value(handoff_path, "due_date"), + "bundle_rows": 1 if checklist_exists else 0, + "approved_rows": 1 if checklist_status == "ready" else 0, + "pending_rows": 0 if checklist_status == "ready" else (1 if checklist_exists else 0), + "ready_evidence_rows": 1 if checklist_status == "ready" else 0, + "template_evidence_rows": 1 if checklist_status == "template" else 0, + "missing_evidence_rows": 1 if checklist_status == "missing" else 0, + "return_bundle_path": None, + "handoff_path": str(handoff_path.relative_to(self.repo_root)).replace("\\", "/"), + } + + bundle_rows = self._read_csv_rows(bundle_path) + approved_rows = 0 + pending_rows = 0 + ready_evidence_rows = 0 + template_evidence_rows = 0 + missing_evidence_rows = 0 + for row in bundle_rows: + if self._is_bundle_row_approved(row): + approved_rows += 1 + else: + pending_rows += 1 + evidence_status = self._resolve_import_evidence_status((row.get("evidence_path") or "").strip()) + if evidence_status == "ready": + ready_evidence_rows += 1 + elif evidence_status == "template": + template_evidence_rows += 1 + else: + missing_evidence_rows += 1 + + return { + "group": group, + "reviewer_name": self._extract_markdown_value(handoff_path, "reviewer_name"), + "due_date": self._extract_markdown_value(handoff_path, "due_date"), + "bundle_rows": len(bundle_rows), + "approved_rows": approved_rows, + "pending_rows": pending_rows, + "ready_evidence_rows": ready_evidence_rows, + "template_evidence_rows": template_evidence_rows, + "missing_evidence_rows": missing_evidence_rows, + "return_bundle_path": str(bundle_path.relative_to(self.repo_root)).replace("\\", "/"), + "handoff_path": str(handoff_path.relative_to(self.repo_root)).replace("\\", "/"), + } + + def _is_bundle_row_approved(self, row: dict[str, str]) -> bool: + record_type = (row.get("record_type") or "").strip() + if record_type == "review_task": + return self._should_import_decision((row.get("approval_result") or "").strip()) + if record_type == "material_signoff": + return self._should_import_decision((row.get("signoff_result") or "").strip()) + if record_type == "template_signoff": + return self._should_import_decision((row.get("production_approval_result") or "").strip()) + if record_type == "knowledge_signoff": + return self._should_import_decision((row.get("production_approval_result") or "").strip()) + return False + + def _extract_markdown_value(self, path: Path, key: str) -> str: + if not path.exists(): + return "" + prefix = f"- {key}: " + for line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + if line.startswith(prefix): + return line[len(prefix):].strip() + return "" + def _derive_release_status(self, summary: dict) -> str: if summary.get("total_assets", 0) == 0: return "draft" diff --git a/backend/tests/test_governance_api.py b/backend/tests/test_governance_api.py index 628c39c5..f04f94d7 100644 --- a/backend/tests/test_governance_api.py +++ b/backend/tests/test_governance_api.py @@ -196,6 +196,47 @@ class StubGovernanceService: "gate_run": {"id": "govgate-123", "status": "blocked"}, } + def preview_formal_launch_import(self, current_user): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "source_inputs": [ + {"key": "review_task_rows", "label": "复核任务包", "path": "project-docs/formal-launch-ops/reviewers/real-review-task-package.csv", "row_count": 99}, + {"key": "material_rows", "label": "生产资料签收台账", "path": "project-docs/formal-launch-ops/signoff/production-material-signoff-register.csv", "row_count": 7}, + ], + "source_metrics": { + "review_task_rows": 99, + "material_rows": 7, + "template_rows": 10, + "knowledge_rows": 26, + "total_rows": 142, + "decision_ready_rows": 0, + "evidence_rows": 142, + "ready_evidence_rows": 0, + "template_evidence_rows": 142, + "missing_evidence_rows": 0, + }, + "current_summary": { + "total_assets": 10, + "blocking_items": [{"type": "assignment", "status": "pending", "count": 4}], + }, + "preview_diff": { + "assets_to_create": 2, + "assignments_to_create": 4, + "evidences_to_create": 8, + "decision_ready_rows": 0, + "existing_assets": 10, + "existing_assignments": 12, + "existing_release_batches": 1, + }, + "package_progress": [ + {"group": "LEGAL_CORE", "reviewer_name": "法务负责人A", "bundle_rows": 101, "approved_rows": 0, "pending_rows": 101, "ready_evidence_rows": 0, "template_evidence_rows": 101, "missing_evidence_rows": 0, "handoff_path": "project-docs/formal-launch-ops/reviewers/collaboration-packages/LEGAL_CORE/handoff.md", "return_bundle_path": "project-docs/formal-launch-ops/reviewers/collaboration-packages/LEGAL_CORE/return-bundle.csv", "due_date": "2026-05-20"}, + ], + "recent_batches": [ + {"id": "govrel-123", "batch_name": "formal-launch-import", "status": "blocked"}, + ], + } + class GovernanceApiTestCase(unittest.TestCase): def setUp(self): @@ -290,9 +331,12 @@ class GovernanceApiTestCase(unittest.TestCase): self._set_admin() summary_response = self.client.get("/api/v1/governance/releases/summary") + preview_response = self.client.get("/api/v1/governance/imports/formal-launch/preview") import_response = self.client.post("/api/v1/governance/imports/formal-launch", data={"batch_name": "formal-launch-import"}) self.assertEqual(summary_response.status_code, 200) + self.assertEqual(preview_response.status_code, 200) + self.assertEqual(preview_response.json()["data"]["source_metrics"]["total_rows"], 142) self.assertEqual(summary_response.json()["data"]["recent_batches"][0]["status"], "blocked") self.assertEqual(import_response.status_code, 200) self.assertEqual(import_response.json()["data"]["release_batch"]["batch_name"], "formal-launch-import") diff --git a/backend/tests/test_governance_service.py b/backend/tests/test_governance_service.py index 2c9cfbbb..86f929f5 100644 --- a/backend/tests/test_governance_service.py +++ b/backend/tests/test_governance_service.py @@ -102,6 +102,19 @@ class GovernanceServiceTestCase(unittest.TestCase): self.assertEqual(snapshot["latest_release_batch"]["batch_name"], "formal-launch-import") self.assertGreater(snapshot["approval_metrics"]["review_tasks"], 0) + def test_formal_launch_import_preview_reports_source_and_diff(self): + preview_before = self.service.preview_formal_launch_import(self.admin_user) + + self.assertGreater(preview_before["source_metrics"]["total_rows"], 0) + self.assertGreater(len(preview_before["source_inputs"]), 0) + self.assertGreater(len(preview_before["package_progress"]), 0) + self.assertGreater(preview_before["preview_diff"]["assets_to_create"], 0) + + self.service.import_formal_launch(self.admin_user, batch_name="formal-launch-import-preview") + preview_after = self.service.preview_formal_launch_import(self.admin_user) + + self.assertEqual(preview_after["preview_diff"]["assets_to_create"], 0) + def test_placeholder_assignee_names_are_not_real_reviewers(self): placeholders = [ "TBD_REAL_REVIEWER", diff --git a/frontend/src/app/governance/imports/page.tsx b/frontend/src/app/governance/imports/page.tsx new file mode 100644 index 00000000..3d6729e1 --- /dev/null +++ b/frontend/src/app/governance/imports/page.tsx @@ -0,0 +1,308 @@ +"use client"; + +import Link from "next/link"; +import { ReloadOutlined, SyncOutlined } from "@ant-design/icons"; +import { Alert, Button, Card, Empty, Input, Space, Table, Tag, Typography, message } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { importFormalLaunchGovernance, previewFormalLaunchGovernanceImport } from "@/services/governance"; +import { useAuthStore } from "@/stores/auth.store"; +import type { + GovernanceFormalLaunchImportPackageProgress, + GovernanceFormalLaunchImportPreview, + GovernanceFormalLaunchImportSourceInput, +} from "@/types/api"; +import { + governanceProductionStatusMeta, + governanceReviewStatusMeta, + renderGovernanceTag, +} from "@/components/governance/governance-meta"; + +const { Paragraph, Text, Title } = Typography; + +function formatPlannedDate(value: string | null | undefined) { + if (!value) { + return "-"; + } + if (/^\d{8}$/.test(value)) { + return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`; + } + return value; +} + +export default function GovernanceImportsPage() { + const [messageApi, contextHolder] = message.useMessage(); + const currentUser = useAuthStore((state) => state.currentUser); + const [loading, setLoading] = useState(true); + const [importing, setImporting] = useState(false); + const [batchName, setBatchName] = useState("formal-launch-import-center"); + const [preview, setPreview] = useState(null); + + const loadPreview = useCallback(async () => { + if (currentUser?.role !== "admin") { + setLoading(false); + return; + } + setLoading(true); + try { + const result = await previewFormalLaunchGovernanceImport(); + if (result.code !== 0) { + messageApi.error(result.message || "导入预览加载失败"); + return; + } + setPreview(result.data); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "导入预览加载失败,请稍后重试"); + } finally { + setLoading(false); + } + }, [currentUser?.role, messageApi]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadPreview(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadPreview]); + + const handleImport = async () => { + setImporting(true); + try { + const result = await importFormalLaunchGovernance(batchName.trim() || undefined); + if (result.code !== 0) { + messageApi.error(result.message || "台账同步失败"); + return; + } + messageApi.success(`formal-launch 台账已同步,生成批次:${result.data.release_batch.batch_name}`); + await loadPreview(); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "台账同步失败,请稍后重试"); + } finally { + setImporting(false); + } + }; + + const sourceColumns: ColumnsType = [ + { title: "来源台账", dataIndex: "label", key: "label", width: 180 }, + { + title: "路径", + dataIndex: "path", + key: "path", + render: (value: string) => {value}, + }, + { title: "行数", dataIndex: "row_count", key: "row_count", width: 100 }, + ]; + + const packageColumns: ColumnsType = [ + { title: "协作包", dataIndex: "group", key: "group", width: 150 }, + { title: "负责人", dataIndex: "reviewer_name", key: "reviewer_name", width: 140, render: (value: string) => value || "-" }, + { title: "截止日期", dataIndex: "due_date", key: "due_date", width: 120, render: (value: string) => formatPlannedDate(value) }, + { + title: "完成度", + key: "progress", + width: 140, + render: (_, item) => `${item.approved_rows} / ${item.bundle_rows}`, + }, + { + title: "证据状态", + key: "evidence", + width: 220, + render: (_, item) => ( + + READY {item.ready_evidence_rows} + TEMPLATE {item.template_evidence_rows} + {item.missing_evidence_rows ? MISSING {item.missing_evidence_rows} : null} + + ), + }, + { + title: "交付入口", + key: "handoff_path", + render: (_, item) => ( + + {item.handoff_path} + {item.return_bundle_path ? {item.return_bundle_path} : null} + + ), + }, + ]; + + const summaryCards = useMemo(() => { + if (!preview) { + return []; + } + return [ + { label: "源台账总行数", value: preview.source_metrics.total_rows, tone: "neutral" }, + { label: "预计新增资产", value: preview.preview_diff.assets_to_create, tone: "info" }, + { label: "预计新增分配", value: preview.preview_diff.assignments_to_create, tone: "warning" }, + { label: "预计新增证据", value: preview.preview_diff.evidences_to_create, tone: "success" }, + { label: "模板证据行", value: preview.source_metrics.template_evidence_rows, tone: "danger" }, + ]; + }, [preview]); + + if (currentUser?.role !== "admin") { + return ( +
+ {contextHolder} + +
+ ); + } + + return ( +
+ {contextHolder} + +
+
+ Import Center + 先预览差异,再把 formal-launch 台账同步进治理真相源。 + + 这里把“仓库里的 formal-launch 台账”与“数据库里的治理状态”放在一起看,管理员导入前能先判断会新增什么、现在卡在哪个协作包。 + +
+ + Sync Action + + 同步入口 + + + 同步动作仍然只会导入台账,不会替真实 reviewer 提交结论或签署证据。 + + setBatchName(event.target.value)} placeholder="输入导入批次名称" /> + + + + + + + + + + + + + +
+ +
+ {summaryCards.map((item) => ( + + {item.label} + {item.value} + + ))} +
+ + {preview?.current_summary.blocking_items?.length ? ( + + + {preview.current_summary.blocking_items.map((item) => ( + + {item.type} · {item.status} · {item.count} + + ))} + + + ) : null} + +
+ + {preview ? ( + + + + {renderGovernanceTag("pending", governanceReviewStatusMeta)} + 模板证据行:{preview.source_metrics.template_evidence_rows} + 有效证据行:{preview.source_metrics.ready_evidence_rows} + 缺失证据行:{preview.source_metrics.missing_evidence_rows} + + + ) : ( + + )} + + + + {preview?.recent_batches?.length ? ( + + {preview.recent_batches.slice(0, 5).map((batch) => ( +
+ {batch.batch_name} + {renderGovernanceTag(batch.status, governanceProductionStatusMeta)} +
+ ))} +
+ ) : ( + + )} +
+
+ + + , + }} + scroll={{ x: 960 }} + /> + + + +
, + }} + scroll={{ x: 1180 }} + /> + + + +
    +
  • 台账同步不会替 reviewer 填真实 review_date、结论或 evidence READY。
  • +
  • 如果协作包里还是 `TEMPLATE`,导入后数据库会继续保持阻断。
  • +
  • 最适合先看的是真实 reviewer 包进度,再决定是否执行同步。
  • +
  • 上线批准页用于看批次和门禁快照,不替代真实复核导入。
  • +
+
+ + + ); +} + +function DescriptionsLike({ items }: { items: Array<[string, string]> }) { + return ( +
+ {items.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+ ); +} diff --git a/frontend/src/app/governance/page.tsx b/frontend/src/app/governance/page.tsx index 4eaf7d4e..45ad1d57 100644 --- a/frontend/src/app/governance/page.tsx +++ b/frontend/src/app/governance/page.tsx @@ -254,6 +254,9 @@ export default function GovernancePage() { + + + diff --git a/frontend/src/app/governance/releases/page.tsx b/frontend/src/app/governance/releases/page.tsx index e55da383..9c6e9f6e 100644 --- a/frontend/src/app/governance/releases/page.tsx +++ b/frontend/src/app/governance/releases/page.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { ReloadOutlined, SyncOutlined } from "@ant-design/icons"; import { Alert, Button, Card, Empty, Input, Space, Table, Tag, Typography, message } from "antd"; import type { ColumnsType } from "antd/es/table"; @@ -151,6 +152,9 @@ export default function GovernanceReleasesPage() { + + + diff --git a/frontend/src/services/governance.ts b/frontend/src/services/governance.ts index 74339a12..0ba02fef 100644 --- a/frontend/src/services/governance.ts +++ b/frontend/src/services/governance.ts @@ -9,6 +9,7 @@ import type { GovernanceDecisionMutationResponse, GovernanceDecisionSubmitPayload, GovernanceEvidenceMutationResponse, + GovernanceFormalLaunchImportPreview, GovernanceEvidenceSavePayload, GovernanceImportResult, GovernanceReleaseDetailResponse, @@ -134,6 +135,11 @@ export async function getGovernanceReleaseDetail(releaseBatchId: string) { return response.data; } +export async function previewFormalLaunchGovernanceImport() { + const response = await http.get>("/governance/imports/formal-launch/preview"); + return response.data; +} + export async function importFormalLaunchGovernance(batchName?: string) { const formData = new FormData(); appendOptionalFormField(formData, "batch_name", batchName); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 6a9de627..2e1b7fb1 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -570,6 +570,55 @@ export type GovernanceReleaseDetailResponse = { gate_runs: GovernanceGateRunItem[]; }; +export type GovernanceFormalLaunchImportSourceInput = { + key: string; + label: string; + path: string; + row_count: number; +}; + +export type GovernanceFormalLaunchImportPackageProgress = { + group: string; + reviewer_name: string; + due_date: string; + bundle_rows: number; + approved_rows: number; + pending_rows: number; + ready_evidence_rows: number; + template_evidence_rows: number; + missing_evidence_rows: number; + return_bundle_path: string | null; + handoff_path: string; +}; + +export type GovernanceFormalLaunchImportPreview = { + source_inputs: GovernanceFormalLaunchImportSourceInput[]; + source_metrics: { + review_task_rows: number; + material_rows: number; + template_rows: number; + knowledge_rows: number; + total_rows: number; + decision_ready_rows: number; + evidence_rows: number; + ready_evidence_rows: number; + template_evidence_rows: number; + missing_evidence_rows: number; + }; + current_summary: GovernanceAssetSummary; + preview_diff: { + assets_to_create: number; + assignments_to_create: number; + evidences_to_create: number; + decision_ready_rows: number; + existing_assets: number; + existing_assignments: number; + existing_release_batches: number; + }; + package_progress: GovernanceFormalLaunchImportPackageProgress[]; + recent_batches: GovernanceReleaseBatchItem[]; +}; + export type GovernanceImportResult = { imported: { assets: number; diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 640d754a..697845db 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -881,3 +881,45 @@ - 把 `EVIDENCE_STATUS: TEMPLATE` 改成 `READY`。 - 在 `return-bundle.csv` 中填写真实复核结果并导入。 - 在最终批准清单中填写真实批准日期并完成最终签署。 + +## 2026-05-13 GOV-WEB-005 台账导入与同步页完成 + +### 本轮完成项 +- 已完成 `GOV-WEB-005`: + - 新增 Web 页面:`/governance/imports` + - 页面支持查看 formal-launch 台账导入预览、协作包进度、最近批次,并执行正式同步。 +- 已补齐后端 preview 能力: + - 新增 `GET /api/v1/governance/imports/formal-launch/preview` + - 返回源台账行数、当前数据库治理摘要、预计导入差异、协作包进度和最近批次。 +- 已接通前端治理导航: + - `/governance` 增加“台账导入与同步”入口 + - `/governance/releases` 增加“查看导入中心”入口 + +### 本轮验证 +- 后端测试: + - `python -m unittest tests.test_governance_service tests.test_governance_api` + - 结果:`12 tests OK` +- 后端编译: + - `python -m py_compile backend/app/services/governance_service.py backend/app/api/governance.py` + - 结果:通过 +- 前端检查: + - `npm --prefix frontend run lint` + - 结果:通过 + - `npm --prefix frontend run build` + - 结果:通过,构建产物包含 `/governance/imports` +- 本地页面可达: + - 前端 dev server 已启动:`http://127.0.0.1:3000` + - `http://127.0.0.1:3000/governance/imports` 返回 `200` + +### 当前状态更新 +- `GOV-WEB-005` 已从 `PLANNED` 更新为 `COMPLETED`。 +- 现在管理员可以在 Web 端完成这几件事: + - 预览 formal-launch 台账的源行数和当前数据库状态差异 + - 查看各协作包的 reviewer、截止日期、完成度和证据模板状态 + - 直接发起 formal-launch 台账同步 + +### 当前阻塞项 +- 治理导入和同步入口已经产品化;剩余阻塞继续回到真实人工动作: + - reviewer 填写真实 `return-bundle.csv` + - reviewer 将 evidence 从 `TEMPLATE` 改成 `READY` + - 最终批准人填写真实批准日期并签署清单 diff --git a/project-docs/formal-launch-ops/governance-productization-backlog.csv b/project-docs/formal-launch-ops/governance-productization-backlog.csv index e821b8cd..dd032d16 100644 --- a/project-docs/formal-launch-ops/governance-productization-backlog.csv +++ b/project-docs/formal-launch-ops/governance-productization-backlog.csv @@ -17,7 +17,7 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce 5C,P0,frontend,GOV-WEB-002,新增复核人工作台页面,当前真实法务业务运营没有系统内待办页,新增 /governance/workbench 页面支持按角色查看待处理任务,GOV-API-001;GOV-API-002;GOV-API-003,复核人可只看到与自己相关的任务包,COMPLETED 5C,P0,frontend,GOV-WEB-003,新增资产详情与证据页,当前无法在系统内查看单资产的签署链和证据链,新增治理资产详情页和证据面板,GOV-API-001;GOV-API-003;GOV-API-004,可在页面内查看每个资产的复核记录和证据状态,COMPLETED 5C,P0,frontend,GOV-WEB-004,新增上线批准页,当前没有正式上线批准人的系统内操作页,新增 /governance/releases 页面展示 gate summary 和 final approval checklist,GOV-API-005,上线批准人可在系统内完成最终批准或驳回,COMPLETED -5C,P1,frontend,GOV-WEB-005,新增台账导入与同步页,当前 CSV 导入完全依赖脚本,新增导入中心页面支持预览导入差异和执行导入,GOV-API-006,管理员可在Web端完成台账同步,PLANNED +5C,P1,frontend,GOV-WEB-005,新增台账导入与同步页,当前 CSV 导入完全依赖脚本,新增导入中心页面支持预览导入差异和执行导入,GOV-API-006,管理员可在Web端完成台账同步,COMPLETED 5D,P0,integration,GOV-INT-001,实现现有 formal-launch 台账到数据库的首轮回填,当前历史台账和协作包仍是仓库真相源,编写一次性 backfill 脚本和映射规则,GOV-API-006,数据库中出现与当前 CSV 对齐的治理资产和复核任务,IMPLEMENTED_VALIDATED 5D,P0,integration,GOV-INT-002,门禁脚本增加数据库读模式,当前上线门禁只读 project-docs CSV 和 md,为 run-formal-launch-approval-gate.ps1 增加优先读数据库的模式,GOV-DB-005;GOV-API-005;GOV-INT-001,门禁可同时校验系统内治理状态和历史兼容输入,IMPLEMENTED_VALIDATED 5D,P1,integration,GOV-INT-003,正式上线资料包与文件中心打通,当前用户协议隐私政策免责声明等资料只存在 docs,将生产资料包纳入系统文件中心并绑定治理资产,GOV-DB-001;GOV-API-004,资料包可在系统内查看版本和签署状态,PLANNED