From 2fb0fcc650109874bcf79030119827c92af52888 Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 13 May 2026 22:24:19 +0800 Subject: [PATCH] feat: link formal launch materials to file center --- backend/app/repositories/file_repository.py | 14 ++++- backend/app/services/file_service.py | 56 ++++++++++++++++++- backend/app/services/governance_service.py | 33 ++++++++++- backend/tests/test_governance_service.py | 33 +++++++++++ .../src/app/governance/assets/[id]/page.tsx | 21 ++++++- ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 49 ++++++++++++++++ .../governance-productization-backlog.csv | 2 +- .../signoff/evidence/MAT-ADMIN-RUNBOOK-001.md | 22 ++++++++ .../signoff/evidence/MAT-AI-DISCLAIMER-001.md | 22 ++++++++ .../evidence/MAT-CUSTOMER-ONBOARDING-001.md | 22 ++++++++ .../evidence/MAT-LEGAL-REVIEW-SOP-001.md | 22 ++++++++ .../evidence/MAT-PRIVACY-POLICY-001.md | 22 ++++++++ .../signoff/evidence/MAT-SUPPORT-SOP-001.md | 22 ++++++++ .../evidence/MAT-USER-AGREEMENT-001.md | 22 ++++++++ ...rmal-launch-reviewer-assignment-matrix.ps1 | 54 ++++++++++++++++++ 15 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 project-docs/formal-launch-ops/signoff/evidence/MAT-ADMIN-RUNBOOK-001.md create mode 100644 project-docs/formal-launch-ops/signoff/evidence/MAT-AI-DISCLAIMER-001.md create mode 100644 project-docs/formal-launch-ops/signoff/evidence/MAT-CUSTOMER-ONBOARDING-001.md create mode 100644 project-docs/formal-launch-ops/signoff/evidence/MAT-LEGAL-REVIEW-SOP-001.md create mode 100644 project-docs/formal-launch-ops/signoff/evidence/MAT-PRIVACY-POLICY-001.md create mode 100644 project-docs/formal-launch-ops/signoff/evidence/MAT-SUPPORT-SOP-001.md create mode 100644 project-docs/formal-launch-ops/signoff/evidence/MAT-USER-AGREEMENT-001.md diff --git a/backend/app/repositories/file_repository.py b/backend/app/repositories/file_repository.py index 73cf68bc..4233ab2c 100644 --- a/backend/app/repositories/file_repository.py +++ b/backend/app/repositories/file_repository.py @@ -22,6 +22,18 @@ class FileRepository: File.status == "active" ).first() + def get_by_hash(self, tenant_id: str, file_hash: str, file_name: Optional[str] = None, file_type: Optional[str] = None) -> Optional[File]: + query = self.db.query(File).filter( + File.tenant_id == tenant_id, + File.file_hash == file_hash, + File.status == "active", + ) + if file_name: + query = query.filter(File.file_name == file_name) + if file_type: + query = query.filter(File.file_type == file_type) + return query.order_by(File.created_at.desc()).first() + def list_paginated(self, tenant_id: str, page: int = 1, page_size: int = 20) -> List[File]: offset = (page - 1) * page_size return self.db.query(File).filter( @@ -55,4 +67,4 @@ class FileRepository: File.tenant_id == tenant_id, File.file_type == file_type, File.status == "active" - ).order_by(File.created_at.desc()).offset(offset).limit(page_size).all() \ No newline at end of file + ).order_by(File.created_at.desc()).offset(offset).limit(page_size).all() diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py index 1c784787..e1337c2f 100644 --- a/backend/app/services/file_service.py +++ b/backend/app/services/file_service.py @@ -3,6 +3,7 @@ from typing import Any, Dict, Optional from uuid import uuid4 import hashlib import io +import mimetypes from fastapi import HTTPException, UploadFile, status from minio import Minio @@ -79,6 +80,7 @@ class FileService: content = file.file.read() file_size = len(content) file_hash = hashlib.sha256(content).hexdigest() + uploader_id = self._resolve_uploader_id(current_user) file_id = f"file-{uuid4().hex[:12]}" safe_file_name = Path(file.filename or "uploaded-file").name @@ -90,7 +92,7 @@ class FileService: file_record = File( id=file_id, tenant_id=tenant_id, - uploaded_by=current_user["id"], + uploaded_by=uploader_id, bucket=storage_bucket, object_key=object_key, file_name=safe_file_name, @@ -122,6 +124,7 @@ class FileService: if require_admin: self._require_admin(current_user) tenant_id = current_user["tenant_id"] + uploader_id = self._resolve_uploader_id(current_user) file_id = f"file-{uuid4().hex[:12]}" safe_file_name = Path(file_name).name or "generated-file" object_key = f"{tenant_id}/{file_type}/{uuid4().hex}/{safe_file_name}" @@ -132,7 +135,7 @@ class FileService: file_record = File( id=file_id, tenant_id=tenant_id, - uploaded_by=current_user["id"], + uploaded_by=uploader_id, bucket=storage_bucket, object_key=object_key, file_name=safe_file_name, @@ -151,6 +154,47 @@ class FileService: return self._to_item(created) + def ensure_file_record_from_path( + self, + current_user: Dict[str, Any], + repo_root: Path, + source_path: str, + file_type: str = "document", + bucket: str = "formal-launch-materials", + require_admin: bool = True, + ) -> Optional[dict]: + if require_admin: + self._require_admin(current_user) + normalized_path = (source_path or "").strip() + if not normalized_path or normalized_path.startswith("http://") or normalized_path.startswith("https://"): + return None + + full_path = Path(normalized_path) + if not full_path.is_absolute(): + full_path = (repo_root / normalized_path).resolve() + if not full_path.exists() or full_path.is_dir(): + return None + + content = full_path.read_bytes() + file_hash = hashlib.sha256(content).hexdigest() + safe_file_name = full_path.name + existing = self.repository.get_by_hash(current_user["tenant_id"], file_hash, safe_file_name, file_type) + if existing is not None: + return self._to_item(existing) + + mime_type, _encoding = mimetypes.guess_type(safe_file_name) + if safe_file_name.lower().endswith(".md") and not mime_type: + mime_type = "text/markdown" + return self.create_file_record( + current_user=current_user, + file_name=safe_file_name, + content=content, + file_type=file_type, + mime_type=mime_type or "application/octet-stream", + bucket=bucket, + require_admin=require_admin, + ) + def build_upload_file(self, file_name: str, content: bytes, mime_type: str) -> UploadFile: upload = UploadFile(filename=file_name, file=io.BytesIO(content)) upload.headers = upload.headers.__class__({"content-type": mime_type}) @@ -284,6 +328,14 @@ class FileService: detail="forbidden", ) + def _resolve_uploader_id(self, current_user: Dict[str, Any]) -> str: + return str( + current_user.get("id") + or current_user.get("username") + or current_user.get("display_name") + or "system" + ) + def _get_active_file(self, current_user: Dict[str, Any], file_id: str, allow_owner_access: bool = False) -> File: tenant_id = current_user["tenant_id"] file_record = self.repository.get_by_id(file_id, tenant_id) diff --git a/backend/app/services/governance_service.py b/backend/app/services/governance_service.py index 3d1e02d8..a80446ff 100644 --- a/backend/app/services/governance_service.py +++ b/backend/app/services/governance_service.py @@ -14,6 +14,7 @@ from app.models.governance_gate_run import GovernanceGateRun from app.models.governance_release_batch import GovernanceReleaseBatch from app.models.governance_review_assignment import GovernanceReviewAssignment from app.models.governance_review_decision import GovernanceReviewDecision +from app.services.file_service import FileService from app.repositories.governance_asset_repository import GovernanceAssetRepository from app.repositories.governance_evidence_repository import GovernanceEvidenceRepository from app.repositories.governance_gate_run_repository import GovernanceGateRunRepository @@ -31,6 +32,7 @@ class GovernanceService: self.evidence_repository = GovernanceEvidenceRepository(db) self.release_batch_repository = GovernanceReleaseBatchRepository(db) self.gate_run_repository = GovernanceGateRunRepository(db) + self.file_service = None self.repo_root = Path(__file__).resolve().parents[3] def list_assets( @@ -644,6 +646,7 @@ class GovernanceService: imported["evidences"] += 1 if evidence else 0 for row in material_rows: + source_file = self._ensure_formal_launch_file_record(current_user, (row.get("file_path") or "").strip() or None, bucket="formal-launch-materials") asset, asset_created = self._upsert_governance_asset( tenant_id, asset_type="production_material", @@ -651,7 +654,11 @@ class GovernanceService: asset_name=(row.get("material_name") or row.get("material_id") or "").strip(), source_path=(row.get("file_path") or "").strip() or None, source_table="formal_launch_material_signoff", - metadata={"signoff_role": row.get("signoff_role")}, + metadata={ + "signoff_role": row.get("signoff_role"), + "source_file_id": source_file["id"] if source_file else None, + "source_bucket": source_file["bucket"] if source_file else None, + }, ) asset_map[(asset.asset_type, asset.asset_id)] = asset imported["assets"] += 1 if asset_created else 0 @@ -677,12 +684,14 @@ class GovernanceService: decision_at=(row.get("signoff_date") or "").strip() or None, ) imported["decisions"] += 1 + evidence_file = self._ensure_formal_launch_file_record(current_user, (row.get("evidence_path") or "").strip() or None, bucket="formal-launch-evidence") evidence = self._import_evidence_row( current_user=current_user, asset=asset, decision_id=None, evidence_path=(row.get("evidence_path") or "").strip() or None, reviewer_name=(row.get("signer_name") or row.get("owner") or "").strip() or None, + file_id=evidence_file["id"] if evidence_file else None, ) imported["evidences"] += 1 if evidence else 0 @@ -1335,6 +1344,7 @@ class GovernanceService: decision_id: Optional[str], evidence_path: Optional[str], reviewer_name: Optional[str], + file_id: Optional[str] = None, ) -> Optional[GovernanceEvidence]: if not evidence_path: return None @@ -1347,6 +1357,7 @@ class GovernanceService: and existing_metadata.get("source_path") == evidence_path and existing.status == status_value and existing.reviewer_name == reviewer_name + and existing.file_id == file_id ): return None evidence = GovernanceEvidence( @@ -1354,7 +1365,7 @@ class GovernanceService: tenant_id=current_user["tenant_id"], governance_asset_id=asset.id, decision_id=decision_id, - file_id=None, + file_id=file_id, evidence_type="document", storage_type="external" if evidence_path.startswith("http") else "internal", external_url=evidence_path if evidence_path.startswith("http") else None, @@ -1394,6 +1405,24 @@ class GovernanceService: with path.open("r", encoding="utf-8-sig", newline="") as handle: return [dict(row) for row in csv.DictReader(handle)] + def _get_file_service(self) -> FileService: + if self.file_service is None: + self.file_service = FileService(self.db) + return self.file_service + + def _ensure_formal_launch_file_record(self, current_user: Dict[str, Any], source_path: Optional[str], bucket: str) -> Optional[dict]: + normalized_path = (source_path or "").strip() + if not normalized_path: + return None + return self._get_file_service().ensure_file_record_from_path( + current_user=current_user, + repo_root=self.repo_root, + source_path=normalized_path, + file_type="document", + bucket=bucket, + require_admin=True, + ) + def _is_real_assignee(self, value: Optional[str]) -> bool: if not value or not value.strip(): return False diff --git a/backend/tests/test_governance_service.py b/backend/tests/test_governance_service.py index 86f929f5..a41469bf 100644 --- a/backend/tests/test_governance_service.py +++ b/backend/tests/test_governance_service.py @@ -8,6 +8,27 @@ from app.services.governance_service import GovernanceService import app.models # noqa: F401 +class StubGovernanceFileService: + def __init__(self): + self.items = {} + + def ensure_file_record_from_path(self, current_user, repo_root, source_path, file_type="document", bucket="formal-launch-materials", require_admin=True): + normalized_path = (source_path or "").strip() + if not normalized_path: + return None + item = self.items.get(normalized_path) + if item is None: + item = { + "id": f"file-{len(self.items) + 1}", + "tenant_id": current_user["tenant_id"], + "file_name": normalized_path.split("/")[-1].split("\\")[-1], + "bucket": bucket, + "file_type": file_type, + } + self.items[normalized_path] = item + return item + + class GovernanceServiceTestCase(unittest.TestCase): def setUp(self): self.engine = create_engine("sqlite+pysqlite:///:memory:", future=True) @@ -15,6 +36,7 @@ class GovernanceServiceTestCase(unittest.TestCase): Base.metadata.create_all(self.engine) self.db = TestingSessionLocal() self.service = GovernanceService(self.db) + self.service.file_service = StubGovernanceFileService() self.admin_user = { "id": "user-admin", "tenant_id": "tenant-demo-001", @@ -91,6 +113,17 @@ class GovernanceServiceTestCase(unittest.TestCase): self.assertEqual(result["release_batch"]["batch_name"], "formal-launch-import") self.assertIn(result["release_batch"]["status"], {"blocked", "in_review", "approved"}) + material_asset = self.service.asset_repository.get_by_identity( + self.admin_user["tenant_id"], + "production_material", + "MAT-USER-AGREEMENT-001", + ) + self.assertIsNotNone(material_asset) + self.assertEqual(material_asset.metadata_json.get("source_file_id"), "file-1") + + evidences = self.service.evidence_repository.list_by_asset_id(self.admin_user["tenant_id"], material_asset.id) + self.assertTrue(any(item.file_id for item in evidences)) + def test_formal_launch_database_snapshot_returns_release_view(self): self.service.import_formal_launch(self.admin_user, batch_name="formal-launch-import") diff --git a/frontend/src/app/governance/assets/[id]/page.tsx b/frontend/src/app/governance/assets/[id]/page.tsx index a9da15ef..b6350ef2 100644 --- a/frontend/src/app/governance/assets/[id]/page.tsx +++ b/frontend/src/app/governance/assets/[id]/page.tsx @@ -121,7 +121,19 @@ export default function GovernanceAssetDetailPage() { { title: "证据", key: "link", - render: (_, item) => item.external_url ? 打开链接 : (item.file_name || "-"), + render: (_, item) => { + if (item.file_id) { + return ( + + + 查看文件资产 + + {item.file_name ? {item.file_name} : null} + + ); + } + return item.external_url ? 打开链接 : (item.file_name || "-"); + }, }, { title: "登记人", dataIndex: "reviewer_name", key: "reviewer_name", render: (value: string | null) => value || "-" }, { title: "登记日期", dataIndex: "collected_at", key: "collected_at", render: (value: string | null) => formatGovernanceDateTime(value) }, @@ -248,6 +260,13 @@ export default function GovernanceAssetDetailPage() { {detail.asset.asset_id} {detail.asset.source_table || "-"} {detail.asset.source_path || "-"} + + {typeof detail.asset.metadata?.source_file_id === "string" && detail.asset.metadata.source_file_id ? ( + + {detail.asset.metadata.source_file_id} + + ) : "-"} +
{stringifyGovernanceMetadata(detail.asset.metadata)}
diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 93551a69..0c2517fb 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -969,3 +969,52 @@ - `review_rules` 仍缺 publish / unpublish / rollback - `knowledge_bases` 仍缺 publish / unpublish / rollback - 正式生产放行层面仍然继续阻塞在真实人工复核和最终批准签署 + +## 2026-05-13 GOV-INT-003 正式生产资料包与文件中心基础打通 + +### 本轮完成项 +- 已补强文件中心底座: + - `FileRepository` 新增按 `file_hash + file_name + file_type` 复用文件资产的能力 + - `FileService` 新增 `ensure_file_record_from_path(...)`,可把仓库内现有资料文件按路径纳入文件中心,而不是每次导入都重复创建 + - `uploaded_by` 现在在系统导入场景下支持从 `id / username / display_name / system` 回退,避免 formal-launch 脚本导入时出现 `files.uploaded_by` 为空 +- 已把 formal-launch 的生产资料导入链打通到文件中心: + - `production_material` 资产导入时会为 `file_path` 创建/复用文件资产,并把 `source_file_id` 写入治理资产 `metadata` + - `material_signoff` 的本地 evidence 文件会尝试写入文件中心,并把 `file_id` 写入治理证据 +- 已补强 reviewer 分配脚本: + - `scripts/apply-formal-launch-reviewer-assignment-matrix.ps1 -Apply` 现在会自动补齐 7 份 `project-docs/formal-launch-ops/signoff/evidence/*.md` 生产资料签收证据模板 + - 这些模板会预填 `signer_name` 和 `planned_due_date`,但继续保持 `EVIDENCE_STATUS: TEMPLATE` +- 已补强治理资产详情页: + - `production_material` 资产现在可直接跳到文件中心查看 `source_file_id` + - 证据记录如已绑定 `file_id`,也可直接从治理资产页跳到 `/files?highlight=...` + +### 本轮验证 +- 后端测试: + - `python -m unittest tests.test_governance_service tests.test_governance_api tests.test_files_api` + - 结果:`22 tests OK` +- 后端编译: + - `python -m py_compile backend/app/services/governance_service.py backend/app/services/file_service.py backend/app/repositories/file_repository.py` + - 结果:通过 +- 前端检查: + - `npm --prefix frontend run lint` + - 结果:通过 + - `npm --prefix frontend run build` + - 结果:通过 +- 远程真相源验收(通过 SSH 隧道连接远程 PostgreSQL + MinIO): + - `FORMAL_LAUNCH_GOVERNANCE_DB_IMPORT_PASS` + - 产物:`project-docs/qa-artifacts/formal-launch-governance-db-import/2026-05-13T22-20-20-293/` + - 验证结果: + - `production_material / MAT-USER-AGREEMENT-001` 已存在 `source_file_id` + - 其最新本地 signoff evidence 已存在带 `file_id` 的 `template` 证据记录 + +### 当前状态更新 +- `GOV-INT-003` 已从 `PLANNED` 推进到 `IN_PROGRESS` +- 正式生产资料不再只是 docs 文件路径: + - 至少生产资料主文件已可进入文件中心并绑定治理资产 + - 生产资料签收证据模板也已具备文件中心绑定能力 +- 当前本机仍无法直连 `192.168.3.90:9000`,但通过 SSH 隧道可以完成远程验收;这更像本机到对象存储端口的网络可达性问题,不是生产 MinIO 不可用 + +### 当前阻塞项 +- `GOV-INT-003` 仍未完全结束: + - 文件中心里的“版本视角”仍较基础,后续还可继续增强生产资料版本链可见性 + - 真实 reviewer 仍需把这些生产资料证据从 `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 f9afef34..a55a9e1e 100644 --- a/project-docs/formal-launch-ops/governance-productization-backlog.csv +++ b/project-docs/formal-launch-ops/governance-productization-backlog.csv @@ -20,5 +20,5 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce 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 +5D,P1,integration,GOV-INT-003,正式上线资料包与文件中心打通,当前用户协议隐私政策免责声明等资料只存在 docs,将生产资料包纳入系统文件中心并绑定治理资产,GOV-DB-001;GOV-API-004,资料包可在系统内查看版本和签署状态,IN_PROGRESS 5D,P1,integration,GOV-INT-004,权限与审计补齐,当前治理动作权限模型不完整,为治理域增加权限点和审计事件定义,GOV-API-002;GOV-API-003;GOV-API-004;GOV-API-005,所有治理关键动作都可审计可追责,PLANNED diff --git a/project-docs/formal-launch-ops/signoff/evidence/MAT-ADMIN-RUNBOOK-001.md b/project-docs/formal-launch-ops/signoff/evidence/MAT-ADMIN-RUNBOOK-001.md new file mode 100644 index 00000000..ad68f866 --- /dev/null +++ b/project-docs/formal-launch-ops/signoff/evidence/MAT-ADMIN-RUNBOOK-001.md @@ -0,0 +1,22 @@ +# Formal Launch Material Signoff Evidence + +EVIDENCE_STATUS: TEMPLATE + +- material_id: MAT-ADMIN-RUNBOOK-001 +- material_name: 管理员上线操作手册 +- signer_name: 诗融 +- planned_due_date: 20271221 +- signoff_date: TODO_YYYY-MM-DD +- signoff_result: TODO_APPROVED_OR_REJECTED + +## Review Basis + +TODO_REAL_REVIEWER: record the business, legal, or operations basis for signing off this material. + +## Required Changes + +TODO_REAL_REVIEWER: list required changes, or write none. + +## Final Decision + +TODO_REAL_REVIEWER: replace EVIDENCE_STATUS with READY only after the real signer confirms this material. \ No newline at end of file diff --git a/project-docs/formal-launch-ops/signoff/evidence/MAT-AI-DISCLAIMER-001.md b/project-docs/formal-launch-ops/signoff/evidence/MAT-AI-DISCLAIMER-001.md new file mode 100644 index 00000000..2f2cdcd5 --- /dev/null +++ b/project-docs/formal-launch-ops/signoff/evidence/MAT-AI-DISCLAIMER-001.md @@ -0,0 +1,22 @@ +# Formal Launch Material Signoff Evidence + +EVIDENCE_STATUS: TEMPLATE + +- material_id: MAT-AI-DISCLAIMER-001 +- material_name: AI输出免责声明 +- signer_name: 陈钰麒 +- planned_due_date: 20271221 +- signoff_date: TODO_YYYY-MM-DD +- signoff_result: TODO_APPROVED_OR_REJECTED + +## Review Basis + +TODO_REAL_REVIEWER: record the business, legal, or operations basis for signing off this material. + +## Required Changes + +TODO_REAL_REVIEWER: list required changes, or write none. + +## Final Decision + +TODO_REAL_REVIEWER: replace EVIDENCE_STATUS with READY only after the real signer confirms this material. \ No newline at end of file diff --git a/project-docs/formal-launch-ops/signoff/evidence/MAT-CUSTOMER-ONBOARDING-001.md b/project-docs/formal-launch-ops/signoff/evidence/MAT-CUSTOMER-ONBOARDING-001.md new file mode 100644 index 00000000..ded1182e --- /dev/null +++ b/project-docs/formal-launch-ops/signoff/evidence/MAT-CUSTOMER-ONBOARDING-001.md @@ -0,0 +1,22 @@ +# Formal Launch Material Signoff Evidence + +EVIDENCE_STATUS: TEMPLATE + +- material_id: MAT-CUSTOMER-ONBOARDING-001 +- material_name: 客户上线核对表 +- signer_name: 诗融 +- planned_due_date: 20271221 +- signoff_date: TODO_YYYY-MM-DD +- signoff_result: TODO_APPROVED_OR_REJECTED + +## Review Basis + +TODO_REAL_REVIEWER: record the business, legal, or operations basis for signing off this material. + +## Required Changes + +TODO_REAL_REVIEWER: list required changes, or write none. + +## Final Decision + +TODO_REAL_REVIEWER: replace EVIDENCE_STATUS with READY only after the real signer confirms this material. \ No newline at end of file diff --git a/project-docs/formal-launch-ops/signoff/evidence/MAT-LEGAL-REVIEW-SOP-001.md b/project-docs/formal-launch-ops/signoff/evidence/MAT-LEGAL-REVIEW-SOP-001.md new file mode 100644 index 00000000..4cfa9ff4 --- /dev/null +++ b/project-docs/formal-launch-ops/signoff/evidence/MAT-LEGAL-REVIEW-SOP-001.md @@ -0,0 +1,22 @@ +# Formal Launch Material Signoff Evidence + +EVIDENCE_STATUS: TEMPLATE + +- material_id: MAT-LEGAL-REVIEW-SOP-001 +- material_name: 法务复核SOP +- signer_name: 陈钰麒 +- planned_due_date: 20271221 +- signoff_date: TODO_YYYY-MM-DD +- signoff_result: TODO_APPROVED_OR_REJECTED + +## Review Basis + +TODO_REAL_REVIEWER: record the business, legal, or operations basis for signing off this material. + +## Required Changes + +TODO_REAL_REVIEWER: list required changes, or write none. + +## Final Decision + +TODO_REAL_REVIEWER: replace EVIDENCE_STATUS with READY only after the real signer confirms this material. \ No newline at end of file diff --git a/project-docs/formal-launch-ops/signoff/evidence/MAT-PRIVACY-POLICY-001.md b/project-docs/formal-launch-ops/signoff/evidence/MAT-PRIVACY-POLICY-001.md new file mode 100644 index 00000000..c03a8075 --- /dev/null +++ b/project-docs/formal-launch-ops/signoff/evidence/MAT-PRIVACY-POLICY-001.md @@ -0,0 +1,22 @@ +# Formal Launch Material Signoff Evidence + +EVIDENCE_STATUS: TEMPLATE + +- material_id: MAT-PRIVACY-POLICY-001 +- material_name: 隐私政策草案 +- signer_name: 陈钰麒 +- planned_due_date: 20271221 +- signoff_date: TODO_YYYY-MM-DD +- signoff_result: TODO_APPROVED_OR_REJECTED + +## Review Basis + +TODO_REAL_REVIEWER: record the business, legal, or operations basis for signing off this material. + +## Required Changes + +TODO_REAL_REVIEWER: list required changes, or write none. + +## Final Decision + +TODO_REAL_REVIEWER: replace EVIDENCE_STATUS with READY only after the real signer confirms this material. \ No newline at end of file diff --git a/project-docs/formal-launch-ops/signoff/evidence/MAT-SUPPORT-SOP-001.md b/project-docs/formal-launch-ops/signoff/evidence/MAT-SUPPORT-SOP-001.md new file mode 100644 index 00000000..7c1c8791 --- /dev/null +++ b/project-docs/formal-launch-ops/signoff/evidence/MAT-SUPPORT-SOP-001.md @@ -0,0 +1,22 @@ +# Formal Launch Material Signoff Evidence + +EVIDENCE_STATUS: TEMPLATE + +- material_id: MAT-SUPPORT-SOP-001 +- material_name: 客服与故障响应SOP +- signer_name: 诗融 +- planned_due_date: 20271221 +- signoff_date: TODO_YYYY-MM-DD +- signoff_result: TODO_APPROVED_OR_REJECTED + +## Review Basis + +TODO_REAL_REVIEWER: record the business, legal, or operations basis for signing off this material. + +## Required Changes + +TODO_REAL_REVIEWER: list required changes, or write none. + +## Final Decision + +TODO_REAL_REVIEWER: replace EVIDENCE_STATUS with READY only after the real signer confirms this material. \ No newline at end of file diff --git a/project-docs/formal-launch-ops/signoff/evidence/MAT-USER-AGREEMENT-001.md b/project-docs/formal-launch-ops/signoff/evidence/MAT-USER-AGREEMENT-001.md new file mode 100644 index 00000000..c10ada45 --- /dev/null +++ b/project-docs/formal-launch-ops/signoff/evidence/MAT-USER-AGREEMENT-001.md @@ -0,0 +1,22 @@ +# Formal Launch Material Signoff Evidence + +EVIDENCE_STATUS: TEMPLATE + +- material_id: MAT-USER-AGREEMENT-001 +- material_name: 用户协议草案 +- signer_name: 陈钰麒 +- planned_due_date: 20271221 +- signoff_date: TODO_YYYY-MM-DD +- signoff_result: TODO_APPROVED_OR_REJECTED + +## Review Basis + +TODO_REAL_REVIEWER: record the business, legal, or operations basis for signing off this material. + +## Required Changes + +TODO_REAL_REVIEWER: list required changes, or write none. + +## Final Decision + +TODO_REAL_REVIEWER: replace EVIDENCE_STATUS with READY only after the real signer confirms this material. \ No newline at end of file diff --git a/scripts/apply-formal-launch-reviewer-assignment-matrix.ps1 b/scripts/apply-formal-launch-reviewer-assignment-matrix.ps1 index c538c470..225b9a69 100644 --- a/scripts/apply-formal-launch-reviewer-assignment-matrix.ps1 +++ b/scripts/apply-formal-launch-reviewer-assignment-matrix.ps1 @@ -209,6 +209,54 @@ function Set-IfColumnExists { } } +function Ensure-SignoffEvidenceTemplate { + param( + [Parameter(Mandatory = $true)][string]$EvidencePath, + [Parameter(Mandatory = $true)][string]$MaterialId, + [Parameter(Mandatory = $true)][string]$MaterialName, + [Parameter(Mandatory = $true)][string]$SignerName, + [string]$DueDate = "" + ) + + if ([string]::IsNullOrWhiteSpace($EvidencePath)) { + return $false + } + $fullPath = if ([System.IO.Path]::IsPathRooted($EvidencePath)) { $EvidencePath } else { Join-Path $repoRoot $EvidencePath } + if (Test-Path -LiteralPath $fullPath) { + return $false + } + + $dir = Split-Path -Parent $fullPath + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $lines = New-MarkdownLines + $lines.Add("# Formal Launch Material Signoff Evidence") | Out-Null + $lines.Add("") | Out-Null + $lines.Add("EVIDENCE_STATUS: TEMPLATE") | Out-Null + $lines.Add("") | Out-Null + $lines.Add("- material_id: $MaterialId") | Out-Null + $lines.Add("- material_name: $MaterialName") | Out-Null + $lines.Add("- signer_name: $SignerName") | Out-Null + if (-not [string]::IsNullOrWhiteSpace($DueDate)) { + $lines.Add("- planned_due_date: $DueDate") | Out-Null + } + $lines.Add("- signoff_date: TODO_YYYY-MM-DD") | Out-Null + $lines.Add("- signoff_result: TODO_APPROVED_OR_REJECTED") | Out-Null + $lines.Add("") | Out-Null + $lines.Add("## Review Basis") | Out-Null + $lines.Add("") | Out-Null + $lines.Add("TODO_REAL_REVIEWER: record the business, legal, or operations basis for signing off this material.") | Out-Null + $lines.Add("") | Out-Null + $lines.Add("## Required Changes") | Out-Null + $lines.Add("") | Out-Null + $lines.Add("TODO_REAL_REVIEWER: list required changes, or write none.") | Out-Null + $lines.Add("") | Out-Null + $lines.Add("## Final Decision") | Out-Null + $lines.Add("") | Out-Null + $lines.Add("TODO_REAL_REVIEWER: replace EVIDENCE_STATUS with READY only after the real signer confirms this material.") | Out-Null + Write-Utf8File -Path $fullPath -Text ($lines -join [Environment]::NewLine) + return $true +} + function Update-EvidenceTemplate { param( [Parameter(Mandatory = $true)][string]$EvidencePath, @@ -482,10 +530,16 @@ if ($Apply -and $errors.Count -eq 0 -and $missingGroups.Count -eq 0) { $materialId = ([string]$row.material_id).Trim() $roleText = (([string]$row.signoff_role) + " " + ([string]$row.owner) + " " + $materialId) $name = $opsName + $materialDueDate = Get-DueDate -Matrix $matrix -Group "OPS_MATERIAL" if ($roleText.Contains($script:CnLegal) -or @("MAT-USER-AGREEMENT-001", "MAT-PRIVACY-POLICY-001", "MAT-AI-DISCLAIMER-001", "MAT-LEGAL-REVIEW-SOP-001") -contains $materialId) { $name = $legalName + $materialDueDate = Get-DueDate -Matrix $matrix -Group "LEGAL_CORE" } Set-IfColumnExists -Row $row -Column "signer_name" -Value $name + $evidencePath = ([string]$row.evidence_path).Trim() + if (Ensure-SignoffEvidenceTemplate -EvidencePath $evidencePath -MaterialId $materialId -MaterialName ([string]$row.material_name).Trim() -SignerName $name -DueDate $materialDueDate) { + $changedFiles.Add($evidencePath) | Out-Null + } } Export-CsvRows -Rows $materialSignoffs -Path $materialSignoffPath $changedFiles.Add($materialSignoffPath) | Out-Null