feat: link formal launch materials to file center

This commit is contained in:
2026-05-13 22:24:19 +08:00
parent 1da8acf9a4
commit 2fb0fcc650
15 changed files with 409 additions and 7 deletions
+13 -1
View File
@@ -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()
).order_by(File.created_at.desc()).offset(offset).limit(page_size).all()
+54 -2
View File
@@ -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)
+31 -2
View File
@@ -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
+33
View File
@@ -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")
@@ -121,7 +121,19 @@ export default function GovernanceAssetDetailPage() {
{
title: "证据",
key: "link",
render: (_, item) => item.external_url ? <a className="governance-table-link" href={item.external_url} target="_blank" rel="noreferrer">打开链接</a> : (item.file_name || "-"),
render: (_, item) => {
if (item.file_id) {
return (
<Space direction="vertical" size={2}>
<Link className="governance-table-link" href={`/files?highlight=${item.file_id}`}>
查看文件资产
</Link>
{item.file_name ? <Text type="secondary">{item.file_name}</Text> : null}
</Space>
);
}
return item.external_url ? <a className="governance-table-link" href={item.external_url} target="_blank" rel="noreferrer">打开链接</a> : (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() {
<Descriptions.Item label="资产编号">{detail.asset.asset_id}</Descriptions.Item>
<Descriptions.Item label="来源表">{detail.asset.source_table || "-"}</Descriptions.Item>
<Descriptions.Item label="来源路径">{detail.asset.source_path || "-"}</Descriptions.Item>
<Descriptions.Item label="来源文件资产">
{typeof detail.asset.metadata?.source_file_id === "string" && detail.asset.metadata.source_file_id ? (
<Link className="governance-table-link" href={`/files?highlight=${detail.asset.metadata.source_file_id}`}>
{detail.asset.metadata.source_file_id}
</Link>
) : "-"}
</Descriptions.Item>
<Descriptions.Item label="元数据">
<pre className="governance-json-preview">{stringifyGovernanceMetadata(detail.asset.metadata)}</pre>
</Descriptions.Item>
@@ -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`
- 正式生产放行继续阻塞在真人复核和最终批准签署
@@ -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
1 batch priority area task_id title current_gap target_deliverable depends_on acceptance status
20 5C P1 frontend GOV-WEB-005 新增台账导入与同步页 当前 CSV 导入完全依赖脚本 新增导入中心页面支持预览导入差异和执行导入 GOV-API-006 管理员可在Web端完成台账同步 COMPLETED
21 5D P0 integration GOV-INT-001 实现现有 formal-launch 台账到数据库的首轮回填 当前历史台账和协作包仍是仓库真相源 编写一次性 backfill 脚本和映射规则 GOV-API-006 数据库中出现与当前 CSV 对齐的治理资产和复核任务 IMPLEMENTED_VALIDATED
22 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
23 5D P1 integration GOV-INT-003 正式上线资料包与文件中心打通 当前用户协议隐私政策免责声明等资料只存在 docs 将生产资料包纳入系统文件中心并绑定治理资产 GOV-DB-001;GOV-API-004 资料包可在系统内查看版本和签署状态 PLANNED IN_PROGRESS
24 5D P1 integration GOV-INT-004 权限与审计补齐 当前治理动作权限模型不完整 为治理域增加权限点和审计事件定义 GOV-API-002;GOV-API-003;GOV-API-004;GOV-API-005 所有治理关键动作都可审计可追责 PLANNED
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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