From 1da8acf9a4f3d5278915473448aef98d70e38932 Mon Sep 17 00:00:00 2001 From: yuqei Date: Wed, 13 May 2026 21:44:29 +0800 Subject: [PATCH] feat: add template publish workflow --- backend/app/api/templates.py | 76 ++++++++++ backend/app/models/contract_template.py | 4 + backend/app/services/template_service.py | 129 +++++++++++++++++ ...260513_0018_add_template_publish_fields.py | 136 ++++++++++++++++++ backend/tests/test_template_service.py | 87 +++++++++++ backend/tests/test_templates_api.py | 80 +++++++++++ frontend/src/app/templates/page.tsx | 130 ++++++++++++++++- frontend/src/services/template.ts | 27 ++++ frontend/src/types/api.ts | 8 ++ ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 46 ++++++ .../governance-productization-backlog.csv | 4 +- 11 files changed, 721 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/versions/20260513_0018_add_template_publish_fields.py diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py index a9dc43aa..a6bc92f8 100644 --- a/backend/app/api/templates.py +++ b/backend/app/api/templates.py @@ -16,6 +16,9 @@ ROUTE_PERMISSION_ACTIONS = { "create": "templates:create", "seed_defaults": "templates:seed_defaults", "update": "templates:update", + "publish": "templates:publish", + "unpublish": "templates:unpublish", + "rollback": "templates:rollback", "delete": "templates:delete", } @@ -192,6 +195,79 @@ def update_template( return success_response(result) +@router.post("/{template_id}/publish") +def publish_template( + template_id: str, + template_service: TemplateService = Depends(get_template_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = template_service.publish_template(current_user, template_id) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="publish_template", + resource_type="template", + resource_id=result["id"], + detail_json={ + "name": result["name"], + "template_code": result.get("template_code"), + "published_at": result.get("published_at"), + "published_by": result.get("published_by"), + "latest_governance_asset_id": result.get("latest_governance_asset_id"), + }, + ) + return success_response(result) + + +@router.post("/{template_id}/unpublish") +def unpublish_template( + template_id: str, + template_service: TemplateService = Depends(get_template_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = template_service.unpublish_template(current_user, template_id) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="unpublish_template", + resource_type="template", + resource_id=result["id"], + detail_json={ + "name": result["name"], + "template_code": result.get("template_code"), + }, + ) + return success_response(result) + + +@router.post("/{template_id}/rollback") +def rollback_template( + template_id: str, + target_version_label: str = Form(...), + change_summary: Optional[str] = Form(default=None), + template_service: TemplateService = Depends(get_template_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = template_service.rollback_template(current_user, template_id, target_version_label, change_summary) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="rollback_template", + resource_type="template", + resource_id=result["id"], + detail_json={ + "name": result["name"], + "template_code": result.get("template_code"), + "target_version_label": target_version_label, + "current_version_label": result.get("current_version_label"), + }, + ) + return success_response(result) + + @router.delete("/{template_id}") def delete_template( template_id: str, diff --git a/backend/app/models/contract_template.py b/backend/app/models/contract_template.py index c59c3570..4339abfb 100644 --- a/backend/app/models/contract_template.py +++ b/backend/app/models/contract_template.py @@ -14,6 +14,7 @@ class ContractTemplate(Base): tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) name: Mapped[str] = mapped_column(String(100), nullable=False) contract_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + template_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) template_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) industry_category: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) @@ -24,6 +25,9 @@ class ContractTemplate(Base): latest_change_summary: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) latest_version_created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) version_count: Mapped[int] = mapped_column(default=1) + published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + published_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + latest_governance_asset_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) created_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) updated_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py index 8cf82980..bc5fed1a 100644 --- a/backend/app/services/template_service.py +++ b/backend/app/services/template_service.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from app.models.contract_template import ContractTemplate from app.models.contract_template_version import ContractTemplateVersion +from app.repositories.governance_asset_repository import GovernanceAssetRepository from app.repositories.contract_template_repository import ContractTemplateRepository from app.repositories.contract_template_version_repository import ContractTemplateVersionRepository @@ -120,6 +121,7 @@ class TemplateService: self.db = db self.repository = ContractTemplateRepository(db) if db is not None else None self.version_repository = ContractTemplateVersionRepository(db) if db is not None else None + self.governance_asset_repository = GovernanceAssetRepository(db) if db is not None else None def list_templates( self, @@ -196,6 +198,7 @@ class TemplateService: tenant_id=current_user["tenant_id"], name=name.strip(), contract_type=normalized_contract_type, + template_code=self._normalize_template_code(None, normalized_contract_type, name), template_category=self._normalize_template_category(template_category, normalized_contract_type), industry_category=self._normalize_industry_category(industry_category, normalized_contract_type), description=description.strip() if description else None, @@ -236,6 +239,7 @@ class TemplateService: contract_template.name = name.strip() contract_template.contract_type = normalized_contract_type + contract_template.template_code = self._normalize_template_code(contract_template.template_code, normalized_contract_type, name) contract_template.template_category = self._normalize_template_category(template_category, normalized_contract_type) contract_template.industry_category = self._normalize_industry_category(industry_category, normalized_contract_type) contract_template.description = description.strip() if description else None @@ -277,6 +281,7 @@ class TemplateService: tenant_id=tenant_id, name=template_data["name"], contract_type=template_data["contract_type"], + template_code=self._normalize_template_code(None, template_data["contract_type"], template_data["name"]), template_category=template_data["template_category"], industry_category=template_data["industry_category"], description=template_data["description"], @@ -303,6 +308,73 @@ class TemplateService: "template_names": [item["name"] for item in created_items], } + def publish_template(self, current_user: Dict[str, Any], template_id: str) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + governance_repository = self._require_governance_asset_repository() + contract_template = self._require_template(current_user, template_id) + governance_asset = self._require_publishable_governance_asset(current_user, contract_template) + + contract_template.enabled = True + contract_template.published_at = self._now() + contract_template.published_by = current_user.get("id") + contract_template.latest_governance_asset_id = governance_asset.id + contract_template.updated_by = current_user.get("id") + updated = repository.update(contract_template) + return self._to_item(updated) + + def unpublish_template(self, current_user: Dict[str, Any], template_id: str) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + contract_template = self._require_template(current_user, template_id) + contract_template.enabled = False + contract_template.published_at = None + contract_template.published_by = None + contract_template.latest_governance_asset_id = None + contract_template.updated_by = current_user.get("id") + updated = repository.update(contract_template) + return self._to_item(updated) + + def rollback_template( + self, + current_user: Dict[str, Any], + template_id: str, + target_version_label: str, + change_summary: Optional[str] = None, + ) -> dict: + self._require_admin(current_user) + repository = self._require_repository() + version_repository = self._require_version_repository() + contract_template = self._require_template(current_user, template_id) + target_version = next( + (item for item in version_repository.list_by_template_id(current_user["tenant_id"], template_id) if item.version_label == target_version_label), + None, + ) + if target_version is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="template version not found") + + next_version_label = self._bump_version_label(contract_template.current_version_label) + normalized_change_summary = self._normalize_change_summary(change_summary, default=f"Rollback to {target_version_label}") + contract_template.name = target_version.name + contract_template.contract_type = target_version.contract_type + contract_template.template_category = target_version.template_category + contract_template.industry_category = target_version.industry_category + contract_template.description = target_version.description + contract_template.content = target_version.content + contract_template.variables_json = target_version.variables_json + contract_template.current_version_label = next_version_label + contract_template.latest_change_summary = normalized_change_summary + contract_template.latest_version_created_at = self._now() + contract_template.version_count = max(contract_template.version_count or 0, 1) + 1 + contract_template.enabled = False + contract_template.published_at = None + contract_template.published_by = None + contract_template.latest_governance_asset_id = None + contract_template.updated_by = current_user.get("id") + updated = repository.update(contract_template) + self._create_version_snapshot(updated, current_user, normalized_change_summary, is_current=True) + return self._to_item(updated) + def _require_template(self, current_user: Dict[str, Any], template_id: str) -> ContractTemplate: repository = self._require_repository() contract_template = repository.get_by_id(template_id) @@ -319,6 +391,11 @@ class TemplateService: raise RuntimeError("template repository is not configured") return self.repository + def _require_governance_asset_repository(self) -> GovernanceAssetRepository: + if self.governance_asset_repository is None: + raise RuntimeError("governance asset repository is not configured") + return self.governance_asset_repository + def _require_version_repository(self) -> ContractTemplateVersionRepository: if self.version_repository is None: raise RuntimeError("template version repository is not configured") @@ -416,6 +493,54 @@ class TemplateService: "nda": "general-enterprise", }.get((contract_type or "").strip().lower(), "general-enterprise") + def _normalize_template_code(self, template_code: Optional[str], contract_type: Optional[str], name: str) -> str: + normalized = template_code.strip() if template_code else "" + if normalized: + return normalized + normalized_contract_type = (contract_type or "").strip().lower() + explicit_map = { + ("labor", "劳动合同标准模板"): "TPL-LABOR-CONTRACT-001", + ("procurement", "采购合同标准模板"): "TPL-PURCHASE-GOODS-001", + ("nda", "保密协议标准模板"): "TPL-NDA-COMMERCIAL-001", + ("sales", "销售合同模板"): "TPL-SALES-GOODS-001", + } + mapped = explicit_map.get((normalized_contract_type, name.strip())) + if mapped: + return mapped + if "saas" in name.strip().lower() or "软件即服务" in name: + return "TPL-SOFTWARE-SAAS-001" + if "技术开发" in name: + return "TPL-TECH-DEVELOPMENT-001" + if "合作框架" in name: + return "TPL-COOPERATION-FRAMEWORK-001" + if "委托服务" in name: + return "TPL-COMMISSION-SERVICE-001" + if "房屋租赁" in name: + return "TPL-LEASE-HOUSE-001" + if normalized_contract_type == "service": + return "TPL-SERVICE-GENERAL-001" + if normalized_contract_type == "procurement": + return "TPL-PURCHASE-GOODS-001" + if normalized_contract_type == "labor": + return "TPL-LABOR-CONTRACT-001" + if normalized_contract_type == "nda": + return "TPL-NDA-COMMERCIAL-001" + if normalized_contract_type == "sales": + return "TPL-SALES-GOODS-001" + slug = re.sub(r"[^A-Za-z0-9]+", "-", name.strip().upper()).strip("-") or "GENERAL" + return f"TPL-{slug[:48]}-001" + + def _require_publishable_governance_asset(self, current_user: Dict[str, Any], contract_template: ContractTemplate): + governance_repository = self._require_governance_asset_repository() + if not contract_template.template_code: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="template_code is required before publish") + governance_asset = governance_repository.get_by_identity(current_user["tenant_id"], "contract_template", contract_template.template_code) + if governance_asset is None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="governance asset not found for template_code") + if governance_asset.production_status not in {"ready", "approved"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="governance asset is not approved for production publish") + return governance_asset + def _bump_version_label(self, current_version_label: Optional[str]) -> str: if not current_version_label: return "v1.1" @@ -434,12 +559,16 @@ class TemplateService: "id": contract_template.id, "name": contract_template.name, "contract_type": contract_template.contract_type, + "template_code": contract_template.template_code, "template_category": contract_template.template_category, "industry_category": contract_template.industry_category, "description": contract_template.description, "content": contract_template.content, "variables": contract_template.variables_json or [], "enabled": contract_template.enabled, + "published_at": contract_template.published_at.isoformat() if contract_template.published_at else None, + "published_by": contract_template.published_by, + "latest_governance_asset_id": contract_template.latest_governance_asset_id, "current_version_label": contract_template.current_version_label or "v1.0", "latest_change_summary": contract_template.latest_change_summary, "latest_version_created_at": ( diff --git a/backend/migrations/versions/20260513_0018_add_template_publish_fields.py b/backend/migrations/versions/20260513_0018_add_template_publish_fields.py new file mode 100644 index 00000000..5ec8992e --- /dev/null +++ b/backend/migrations/versions/20260513_0018_add_template_publish_fields.py @@ -0,0 +1,136 @@ +"""add template publish fields + +Revision ID: 20260513_0018 +Revises: 20260512_0017 +Create Date: 2026-05-13 16:30:00 +""" + +from __future__ import annotations + +from typing import Optional + +import sqlalchemy as sa +from alembic import op + + +revision = "20260513_0018" +down_revision = "20260512_0017" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def _column_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {column["name"] for column in inspector.get_columns(table_name)} + + +def _index_names(inspector: sa.Inspector, table_name: str) -> set[str]: + return {index["name"] for index in inspector.get_indexes(table_name)} + + +def _derive_template_code(contract_type: Optional[str], name: str) -> str: + normalized_contract_type = (contract_type or "").strip().lower() + normalized_name = (name or "").strip().lower() + explicit_map = { + ("labor", "劳动合同标准模板"): "TPL-LABOR-CONTRACT-001", + ("procurement", "采购合同标准模板"): "TPL-PURCHASE-GOODS-001", + ("nda", "保密协议标准模板"): "TPL-NDA-COMMERCIAL-001", + ("sales", "销售合同模板"): "TPL-SALES-GOODS-001", + } + for (mapped_contract_type, mapped_name), template_code in explicit_map.items(): + if normalized_contract_type == mapped_contract_type and name == mapped_name: + return template_code + + if "saas" in normalized_name or "软件即服务" in name: + return "TPL-SOFTWARE-SAAS-001" + if "技术开发" in name: + return "TPL-TECH-DEVELOPMENT-001" + if "合作框架" in name: + return "TPL-COOPERATION-FRAMEWORK-001" + if "委托服务" in name: + return "TPL-COMMISSION-SERVICE-001" + if "房屋租赁" in name: + return "TPL-LEASE-HOUSE-001" + if "销售" in name: + return "TPL-SALES-GOODS-001" + if normalized_contract_type == "service": + return "TPL-SERVICE-GENERAL-001" + if normalized_contract_type == "procurement": + return "TPL-PURCHASE-GOODS-001" + if normalized_contract_type == "labor": + return "TPL-LABOR-CONTRACT-001" + if normalized_contract_type == "nda": + return "TPL-NDA-COMMERCIAL-001" + + slug = ( + normalized_name.replace("/", " ") + .replace("\\", " ") + .replace("-", " ") + .replace("_", " ") + ) + slug = "-".join(part for part in slug.split() if part) or "GENERAL" + return f"TPL-{slug[:48].upper()}-001" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "contract_templates" not in _table_names(inspector): + return + + columns = _column_names(inspector, "contract_templates") + if "template_code" not in columns: + op.add_column("contract_templates", sa.Column("template_code", sa.String(length=64), nullable=True)) + if "published_at" not in columns: + op.add_column("contract_templates", sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)) + if "published_by" not in columns: + op.add_column("contract_templates", sa.Column("published_by", sa.String(length=64), nullable=True)) + if "latest_governance_asset_id" not in columns: + op.add_column("contract_templates", sa.Column("latest_governance_asset_id", sa.String(length=64), nullable=True)) + + inspector = sa.inspect(bind) + indexes = _index_names(inspector, "contract_templates") + if "ix_contract_templates_template_code" not in indexes: + op.create_index("ix_contract_templates_template_code", "contract_templates", ["template_code"], unique=False) + + rows = bind.execute(sa.text("SELECT id, contract_type, name, template_code FROM contract_templates")).mappings().all() + for row in rows: + if row["template_code"]: + continue + bind.execute( + sa.text( + """ + UPDATE contract_templates + SET template_code = :template_code + WHERE id = :template_id + """ + ), + { + "template_id": row["id"], + "template_code": _derive_template_code(row.get("contract_type"), row.get("name") or ""), + }, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "contract_templates" not in _table_names(inspector): + return + + indexes = _index_names(inspector, "contract_templates") + if "ix_contract_templates_template_code" in indexes: + op.drop_index("ix_contract_templates_template_code", table_name="contract_templates") + + columns = _column_names(inspector, "contract_templates") + if "latest_governance_asset_id" in columns: + op.drop_column("contract_templates", "latest_governance_asset_id") + if "published_by" in columns: + op.drop_column("contract_templates", "published_by") + if "published_at" in columns: + op.drop_column("contract_templates", "published_at") + if "template_code" in columns: + op.drop_column("contract_templates", "template_code") diff --git a/backend/tests/test_template_service.py b/backend/tests/test_template_service.py index 3b6cff20..5df51ad7 100644 --- a/backend/tests/test_template_service.py +++ b/backend/tests/test_template_service.py @@ -75,11 +75,30 @@ class StubVersionRepository: item.is_current = False +class StubGovernanceAsset: + def __init__(self, asset_id: str, production_status: str = "ready"): + self.id = f"govast-{asset_id.lower()}" + self.asset_id = asset_id + self.production_status = production_status + + +class StubGovernanceAssetRepository: + def __init__(self): + self.items = {} + + def add(self, asset_type, asset_id, production_status="ready"): + self.items[(asset_type, asset_id)] = StubGovernanceAsset(asset_id, production_status) + + def get_by_identity(self, tenant_id, asset_type, source_asset_id): + return self.items.get((asset_type, source_asset_id)) + + class TemplateServiceTestCase(unittest.TestCase): def setUp(self): self.service = TemplateService(db=None) self.service.repository = StubRepository() self.service.version_repository = StubVersionRepository() + self.service.governance_asset_repository = StubGovernanceAssetRepository() self.current_user = { "id": "user-admin", "tenant_id": "tenant-demo-001", @@ -108,6 +127,7 @@ class TemplateServiceTestCase(unittest.TestCase): self.assertEqual(created["description"], "Standard employment template") self.assertEqual(created["content"], "Clause one") self.assertEqual(created["variables"][0]["name"], "employee_name") + self.assertEqual(created["template_code"], "TPL-LABOR-CONTRACT-001") self.assertEqual(created["template_category"], "employment-hr") self.assertEqual(created["industry_category"], "general-enterprise") self.assertEqual(created["current_version_label"], "v1.0") @@ -202,6 +222,7 @@ class TemplateServiceTestCase(unittest.TestCase): self.assertEqual(updated["current_version_label"], "v1.1") self.assertEqual(updated["latest_change_summary"], "Add amount variable") self.assertEqual(updated["version_count"], 2) + self.assertEqual(updated["template_code"], "TPL-PURCHASE-GOODS-001") self.assertEqual(updated["template_category"], "procurement-supply") self.assertEqual(updated["industry_category"], "general-enterprise") @@ -274,6 +295,72 @@ class TemplateServiceTestCase(unittest.TestCase): self.assertEqual(filtered["total"], 1) self.assertEqual(filtered["items"][0]["name"], "Service Template") + def test_publish_and_unpublish_template_require_governance_asset(self): + created = self.service.create_template( + self.current_user, + name="Procurement Template", + contract_type="procurement", + description="Ready for publish", + content="Template body", + variables_json=None, + enabled=False, + ) + self.service.governance_asset_repository.add("contract_template", "TPL-PURCHASE-GOODS-001", production_status="ready") + + published = self.service.publish_template(self.current_user, created["id"]) + self.assertEqual(published["published_by"], self.current_user["id"]) + self.assertIsNotNone(published["published_at"]) + self.assertEqual(published["latest_governance_asset_id"], "govast-tpl-purchase-goods-001") + self.assertTrue(published["enabled"]) + + unpublished = self.service.unpublish_template(self.current_user, created["id"]) + self.assertIsNone(unpublished["published_at"]) + self.assertIsNone(unpublished["published_by"]) + self.assertIsNone(unpublished["latest_governance_asset_id"]) + self.assertFalse(unpublished["enabled"]) + + def test_publish_template_blocks_without_ready_governance_asset(self): + created = self.service.create_template( + self.current_user, + name="Procurement Template", + contract_type="procurement", + description="Ready for publish", + content="Template body", + variables_json=None, + enabled=False, + ) + with self.assertRaises(HTTPException) as context: + self.service.publish_template(self.current_user, created["id"]) + self.assertEqual(context.exception.status_code, 409) + + def test_rollback_template_creates_new_version_and_unpublishes(self): + created = self.service.create_template( + self.current_user, + name="Procurement Template", + contract_type="procurement", + description="Original", + content="Original content", + variables_json=None, + enabled=True, + change_summary="Initial version", + ) + self.service.update_template( + self.current_user, + created["id"], + name="Procurement Template Updated", + contract_type="procurement", + description="Updated", + content="Updated content", + variables_json=None, + enabled=True, + change_summary="Second version", + ) + rolled_back = self.service.rollback_template(self.current_user, created["id"], "v1.0") + self.assertEqual(rolled_back["content"], "Original content") + self.assertEqual(rolled_back["current_version_label"], "v1.2") + self.assertFalse(rolled_back["enabled"]) + self.assertIsNone(rolled_back["published_at"]) + def test_variables_json_must_be_valid_object_array(self): with self.assertRaises(HTTPException) as invalid_json_context: self.service.create_template( diff --git a/backend/tests/test_templates_api.py b/backend/tests/test_templates_api.py index 6700c2d4..16670fb0 100644 --- a/backend/tests/test_templates_api.py +++ b/backend/tests/test_templates_api.py @@ -31,12 +31,16 @@ class StubTemplateService: "id": "tpl-123", "name": "Employment Template", "contract_type": contract_type or "labor", + "template_code": "TPL-LABOR-CONTRACT-001", "template_category": template_category or "employment-hr", "industry_category": industry_category or "general-enterprise", "description": "Standard employment template", "content": "Clause one", "variables": [{"name": "employee_name", "label": "Employee Name"}], "enabled": True, + "published_at": None, + "published_by": None, + "latest_governance_asset_id": None, "current_version_label": "v1.2", "latest_change_summary": "Add probation clause", "latest_version_created_at": "2026-04-17T00:00:00+00:00", @@ -67,6 +71,7 @@ class StubTemplateService: "change_summary": "Add probation clause", "name": "Employment Template", "contract_type": "labor", + "template_code": "TPL-LABOR-CONTRACT-001", "template_category": "employment-hr", "industry_category": "general-enterprise", "description": "Standard employment template", @@ -87,12 +92,16 @@ class StubTemplateService: "id": "tpl-123", "name": name, "contract_type": contract_type, + "template_code": "TPL-PROCUREMENT-DEMO-001", "template_category": template_category or "general-business", "industry_category": industry_category or "general-enterprise", "description": description, "content": content, "variables": self._parse_variables(variables_json), "enabled": enabled, + "published_at": None, + "published_by": None, + "latest_governance_asset_id": None, "current_version_label": "v1.0", "latest_change_summary": change_summary or "Initial template version", "latest_version_created_at": "2026-04-16T00:00:00+00:00", @@ -110,12 +119,16 @@ class StubTemplateService: "id": template_id, "name": name, "contract_type": contract_type, + "template_code": "TPL-PROCUREMENT-DEMO-001", "template_category": template_category or "general-business", "industry_category": industry_category or "general-enterprise", "description": description, "content": content, "variables": self._parse_variables(variables_json), "enabled": enabled, + "published_at": None, + "published_by": None, + "latest_governance_asset_id": None, "current_version_label": "v1.1", "latest_change_summary": change_summary or "v1.1 template update", "latest_version_created_at": "2026-04-16T00:00:00+00:00", @@ -138,6 +151,51 @@ class StubTemplateService: "template_names": [], } + def publish_template(self, current_user, template_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "id": template_id, + "name": "Employment Template", + "contract_type": "labor", + "template_code": "TPL-LABOR-CONTRACT-001", + "template_category": "employment-hr", + "industry_category": "general-enterprise", + "description": "Standard employment template", + "content": "Clause one", + "variables": [{"name": "employee_name", "label": "Employee Name"}], + "enabled": True, + "published_at": "2026-05-13T08:00:00+00:00", + "published_by": current_user["id"], + "latest_governance_asset_id": "govast-tpl-labor-contract-001", + "current_version_label": "v1.2", + "latest_change_summary": "Add probation clause", + "latest_version_created_at": "2026-04-17T00:00:00+00:00", + "version_count": 3, + "created_by": "user-admin", + "updated_by": current_user["id"], + "created_at": "2026-04-16T00:00:00+00:00", + "updated_at": "2026-05-13T08:00:00+00:00", + } + + def unpublish_template(self, current_user, template_id): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + result = self.publish_template(current_user, template_id) + result["enabled"] = False + result["published_at"] = None + result["published_by"] = None + result["latest_governance_asset_id"] = None + return result + + def rollback_template(self, current_user, template_id, target_version_label, change_summary=None): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + result = self.unpublish_template(current_user, template_id) + result["current_version_label"] = "v1.3" + result["latest_change_summary"] = change_summary or f"Rollback to {target_version_label}" + return result + def delete_template(self, current_user, template_id): if current_user["role"] != "admin": raise HTTPException(status_code=403, detail="forbidden") @@ -173,6 +231,7 @@ class TemplatesApiTestCase(unittest.TestCase): payload = response.json()["data"]["items"][0] self.assertEqual(payload["name"], "Employment Template") self.assertEqual(payload["contract_type"], "labor") + self.assertEqual(payload["template_code"], "TPL-LABOR-CONTRACT-001") self.assertEqual(payload["template_category"], "employment-hr") self.assertEqual(payload["industry_category"], "general-enterprise") self.assertEqual(payload["current_version_label"], "v1.2") @@ -363,6 +422,27 @@ class TemplatesApiTestCase(unittest.TestCase): }, ) + def test_admin_can_publish_unpublish_and_rollback_template(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + publish_response = self.client.post("/api/v1/templates/tpl-123/publish") + unpublish_response = self.client.post("/api/v1/templates/tpl-123/unpublish") + rollback_response = self.client.post("/api/v1/templates/tpl-123/rollback", data={"target_version_label": "v1.1"}) + + self.assertEqual(publish_response.status_code, 200) + self.assertEqual(unpublish_response.status_code, 200) + self.assertEqual(rollback_response.status_code, 200) + self.assertEqual(publish_response.json()["data"]["published_by"], "user-admin") + self.assertEqual(unpublish_response.json()["data"]["published_at"], None) + self.assertEqual(rollback_response.json()["data"]["current_version_label"], "v1.3") + self.assertEqual(self.audit_log_service.events[-3]["action"], "publish_template") + self.assertEqual(self.audit_log_service.events[-2]["action"], "unpublish_template") + self.assertEqual(self.audit_log_service.events[-1]["action"], "rollback_template") + def test_member_cannot_access_templates(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-member", diff --git a/frontend/src/app/templates/page.tsx b/frontend/src/app/templates/page.tsx index 453375d1..8ec6a2e2 100644 --- a/frontend/src/app/templates/page.tsx +++ b/frontend/src/app/templates/page.tsx @@ -5,7 +5,18 @@ import { Alert, Button, Card, Empty, Form, Input, Popconfirm, Select, Space, Swi import type { ColumnsType, TablePaginationConfig } from "antd/es/table"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { createTemplate, deleteTemplate, getTemplateVersions, listTemplatesWithFilters, seedDefaultTemplates, updateTemplate, updateTemplateStatus } from "@/services/template"; +import { + createTemplate, + deleteTemplate, + getTemplateVersions, + listTemplatesWithFilters, + publishTemplate, + rollbackTemplate, + seedDefaultTemplates, + unpublishTemplate, + updateTemplate, + updateTemplateStatus, +} from "@/services/template"; import { useAuthStore } from "@/stores/auth.store"; import { formatIndustryCategory, formatTemplateCategory, industryCategoryOptions, templateCategoryOptions } from "@/lib/template-taxonomy"; import type { TemplateItem, TemplateVersionItem } from "@/types/api"; @@ -101,6 +112,8 @@ export default function TemplatesPage() { const [seeding, setSeeding] = useState(false); const [deletingId, setDeletingId] = useState(null); const [statusChangingId, setStatusChangingId] = useState(null); + const [publishingId, setPublishingId] = useState(null); + const [rollbackingId, setRollbackingId] = useState(null); const [items, setItems] = useState([]); const [editingId, setEditingId] = useState(null); const [contractTypeFilter, setContractTypeFilter] = useState(undefined); @@ -307,6 +320,66 @@ export default function TemplatesPage() { } }; + const handlePublish = async (item: TemplateItem) => { + setPublishingId(item.id); + try { + const result = await publishTemplate(item.id); + if (result.code !== 0) { + messageApi.error(result.message || "模板发布失败"); + return; + } + messageApi.success("模板已发布到生产可用集合"); + await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "模板发布失败,请稍后重试"); + } finally { + setPublishingId(null); + } + }; + + const handleUnpublish = async (item: TemplateItem) => { + setPublishingId(item.id); + try { + const result = await unpublishTemplate(item.id); + if (result.code !== 0) { + messageApi.error(result.message || "模板下线失败"); + return; + } + messageApi.success("模板已从生产可用集合移除"); + await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "模板下线失败,请稍后重试"); + } finally { + setPublishingId(null); + } + }; + + const handleRollback = async (item: TemplateItem, versionLabel: string) => { + setRollbackingId(`${item.id}:${versionLabel}`); + try { + const result = await rollbackTemplate(item.id, { + target_version_label: versionLabel, + change_summary: `Rollback to ${versionLabel}`, + }); + if (result.code !== 0) { + messageApi.error(result.message || "模板回滚失败"); + return; + } + messageApi.success(`模板已回滚为新版本,来源 ${versionLabel}`); + setVersionHistoryMap((current) => { + const next = { ...current }; + delete next[item.id]; + return next; + }); + await loadTemplates(page, pageSize, contractTypeFilter, templateCategoryFilter, industryCategoryFilter); + await loadTemplateVersions(item.id); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : "模板回滚失败,请稍后重试"); + } finally { + setRollbackingId(null); + } + }; + const handleTableChange = (pagination: TablePaginationConfig) => { const nextPage = pagination.current ?? 1; const nextPageSize = pagination.pageSize ?? pageSize; @@ -322,7 +395,7 @@ export default function TemplatesPage() { } }; - const versionColumns: ColumnsType = [ + const buildVersionColumns = (templateId: string): ColumnsType => [ { title: "版本", dataIndex: "version_label", @@ -369,6 +442,26 @@ export default function TemplatesPage() { width: 180, render: (value: string) => formatDateTime(value), }, + { + title: "操作", + key: "actions", + width: 120, + render: (_, version, index) => + index > 0 ? ( + + ) : null, + }, ]; const columns: ColumnsType = [ @@ -394,6 +487,13 @@ export default function TemplatesPage() { width: 160, render: (value: string | null) => formatIndustryCategory(value), }, + { + title: "模板编号", + dataIndex: "template_code", + key: "template_code", + width: 170, + render: (value: string | null) => value ? {value} : "-", + }, { title: "当前版本", dataIndex: "current_version_label", @@ -414,6 +514,19 @@ export default function TemplatesPage() { key: "latest_change_summary", render: (value: string | null) => value || "-", }, + { + title: "发布状态", + key: "publish_status", + width: 180, + render: (_, item) => ( + + {item.published_at ? "已发布" : "未发布"} + + {item.published_at ? formatDateTime(item.published_at) : "需治理批准后发布"} + + + ), + }, { title: "状态", dataIndex: "enabled", @@ -449,6 +562,15 @@ export default function TemplatesPage() { + {item.published_at ? ( + + ) : ( + + )} void handleDelete(item.id)}>