feat: add template publish workflow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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": (
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [statusChangingId, setStatusChangingId] = useState<string | null>(null);
|
||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||
const [rollbackingId, setRollbackingId] = useState<string | null>(null);
|
||||
const [items, setItems] = useState<TemplateItem[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [contractTypeFilter, setContractTypeFilter] = useState<string | undefined>(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<TemplateVersionItem> = [
|
||||
const buildVersionColumns = (templateId: string): ColumnsType<TemplateVersionItem> => [
|
||||
{
|
||||
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 ? (
|
||||
<Button
|
||||
size="small"
|
||||
loading={rollbackingId === `${templateId}:${version.version_label}`}
|
||||
onClick={() => {
|
||||
const targetTemplate = items.find((entry) => entry.id === templateId);
|
||||
if (targetTemplate) {
|
||||
void handleRollback(targetTemplate, version.version_label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
回滚到此版本
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
const columns: ColumnsType<TemplateItem> = [
|
||||
@@ -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 ? <Text code>{value}</Text> : "-",
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Tag color={item.published_at ? "green" : "default"}>{item.published_at ? "已发布" : "未发布"}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.published_at ? formatDateTime(item.published_at) : "需治理批准后发布"}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "enabled",
|
||||
@@ -449,6 +562,15 @@ export default function TemplatesPage() {
|
||||
<Button size="small" onClick={() => setEditingId(item.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
{item.published_at ? (
|
||||
<Button size="small" loading={publishingId === item.id} onClick={() => void handleUnpublish(item)}>
|
||||
下线
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" type="primary" loading={publishingId === item.id} onClick={() => void handlePublish(item)}>
|
||||
发布
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm title="确认删除这个模板?" okText="删除" cancelText="取消" onConfirm={() => void handleDelete(item.id)}>
|
||||
<Button danger size="small" loading={deletingId === item.id}>
|
||||
删除
|
||||
@@ -681,7 +803,7 @@ export default function TemplatesPage() {
|
||||
{versionLoadingId === item.id && !versions ? (
|
||||
<Text type="secondary">版本历史加载中...</Text>
|
||||
) : versions && versions.length > 0 ? (
|
||||
<Table rowKey="id" size="small" pagination={false} columns={versionColumns} dataSource={versions} />
|
||||
<Table rowKey="id" size="small" pagination={false} columns={buildVersionColumns(item.id)} dataSource={versions} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无版本历史。" />
|
||||
)}
|
||||
@@ -690,7 +812,7 @@ export default function TemplatesPage() {
|
||||
);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1400 }}
|
||||
scroll={{ x: 1720 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PageResponse,
|
||||
TemplateCreatePayload,
|
||||
TemplateItem,
|
||||
TemplateRollbackPayload,
|
||||
TemplateVersionListResponse,
|
||||
TemplateUpdatePayload,
|
||||
} from "@/types/api";
|
||||
@@ -60,6 +61,12 @@ function buildTemplateFormData(payload: TemplateUpdatePayload) {
|
||||
if (payload.contract_type) {
|
||||
formData.append("contract_type", payload.contract_type);
|
||||
}
|
||||
if (payload.template_category) {
|
||||
formData.append("template_category", payload.template_category);
|
||||
}
|
||||
if (payload.industry_category) {
|
||||
formData.append("industry_category", payload.industry_category);
|
||||
}
|
||||
if (payload.description) {
|
||||
formData.append("description", payload.description);
|
||||
}
|
||||
@@ -90,6 +97,26 @@ export async function updateTemplateStatus(templateId: string, template: Templat
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishTemplate(templateId: string) {
|
||||
const response = await http.post<ApiResponse<TemplateItem>>(`/templates/${templateId}/publish`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function unpublishTemplate(templateId: string) {
|
||||
const response = await http.post<ApiResponse<TemplateItem>>(`/templates/${templateId}/unpublish`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function rollbackTemplate(templateId: string, payload: TemplateRollbackPayload) {
|
||||
const formData = new FormData();
|
||||
formData.append("target_version_label", payload.target_version_label);
|
||||
if (payload.change_summary) {
|
||||
formData.append("change_summary", payload.change_summary);
|
||||
}
|
||||
const response = await http.post<ApiResponse<TemplateItem>>(`/templates/${templateId}/rollback`, formData);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(templateId: string) {
|
||||
await http.delete(`/templates/${templateId}`);
|
||||
}
|
||||
|
||||
@@ -358,12 +358,16 @@ export type TemplateItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
contract_type: string | null;
|
||||
template_code: string | null;
|
||||
template_category: string | null;
|
||||
industry_category: string | null;
|
||||
description: string | null;
|
||||
content: string;
|
||||
variables: TemplateVariableItem[];
|
||||
enabled: boolean;
|
||||
published_at: string | null;
|
||||
published_by: string | null;
|
||||
latest_governance_asset_id: string | null;
|
||||
current_version_label: string;
|
||||
latest_change_summary: string | null;
|
||||
latest_version_created_at: string | null;
|
||||
@@ -405,6 +409,10 @@ export type TemplateCreatePayload = {
|
||||
};
|
||||
|
||||
export type TemplateUpdatePayload = TemplateCreatePayload;
|
||||
export type TemplateRollbackPayload = {
|
||||
target_version_label: string;
|
||||
change_summary?: string;
|
||||
};
|
||||
export type TemplateListResponse = PageResponse<TemplateItem>;
|
||||
export type TemplateVersionListResponse = {
|
||||
template_id: string;
|
||||
|
||||
@@ -923,3 +923,49 @@
|
||||
- reviewer 填写真实 `return-bundle.csv`
|
||||
- reviewer 将 evidence 从 `TEMPLATE` 改成 `READY`
|
||||
- 最终批准人填写真实批准日期并签署清单
|
||||
|
||||
## 2026-05-13 GOV-DB-006 完成与 GOV-API-007 模板发布链落地
|
||||
|
||||
### 本轮完成项
|
||||
- 已完成 `GOV-DB-006`:
|
||||
- `contract_templates` 增加 `template_code`、`published_at`、`published_by`、`latest_governance_asset_id`
|
||||
- 新增迁移:`backend/migrations/versions/20260513_0018_add_template_publish_fields.py`
|
||||
- 现有模板会按合同类型/名称自动补齐模板编号,用于和 formal-launch 治理资产映射
|
||||
- 已完成 `GOV-API-007` 的模板分支:
|
||||
- 新增模板发布接口:`POST /api/v1/templates/{template_id}/publish`
|
||||
- 新增模板下线接口:`POST /api/v1/templates/{template_id}/unpublish`
|
||||
- 新增模板回滚接口:`POST /api/v1/templates/{template_id}/rollback`
|
||||
- 模板发布前会校验 `template_code` 对应治理资产是否处于生产可发布状态
|
||||
- 模板回滚会生成新的模板版本,而不是直接覆盖旧版本
|
||||
- 已补齐模板页的基础操作:
|
||||
- 模板列表显示 `template_code` 和发布状态
|
||||
- 管理员可在模板页直接执行“发布 / 下线”
|
||||
- 在版本历史中可直接执行“回滚到此版本”
|
||||
- 已顺手修复模板前端表单一个老问题:
|
||||
- `template_category` 和 `industry_category` 现在会真正随表单提交,不再只停在前端显示层
|
||||
|
||||
### 本轮验证
|
||||
- 后端测试:
|
||||
- `python -m unittest tests.test_template_service tests.test_templates_api`
|
||||
- 结果:`21 tests OK`
|
||||
- 后端编译:
|
||||
- `python -m py_compile backend/app/services/template_service.py backend/app/api/templates.py`
|
||||
- 结果:通过
|
||||
- 前端检查:
|
||||
- `npm --prefix frontend run lint`
|
||||
- 结果:通过
|
||||
- `npm --prefix frontend run build`
|
||||
- 结果:通过,模板页构建正常
|
||||
|
||||
### 当前状态更新
|
||||
- `GOV-DB-006` 已从 `PLANNED` 更新为 `COMPLETED`
|
||||
- `GOV-API-007` 已进入 `IN_PROGRESS`
|
||||
- 模板发布链已具备
|
||||
- 规则发布链、知识库发布链仍未补齐
|
||||
- 现在模板层已具备“编辑 -> 版本化 -> 发布 -> 下线 -> 回滚”的正式治理动作
|
||||
|
||||
### 当前阻塞项
|
||||
- `GOV-API-007` 还未全量完成:
|
||||
- `review_rules` 仍缺 publish / unpublish / rollback
|
||||
- `knowledge_bases` 仍缺 publish / unpublish / rollback
|
||||
- 正式生产放行层面仍然继续阻塞在真实人工复核和最终批准签署
|
||||
|
||||
@@ -4,7 +4,7 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce
|
||||
5A,P0,database,GOV-DB-003,新增复核决议表,当前复核结论只存在CSV回填模板中,新增 governance_review_decisions 表支持 approved rejected changes_required 等结果,GOV-DB-001;GOV-DB-002,系统可记录多次复核历史和最新有效结论,COMPLETED
|
||||
5A,P0,database,GOV-DB-004,新增证据登记表,当前证据路径和证据状态只存在文档与脚本规则中,新增 governance_evidences 表支持 file_id url status checksum reviewer metadata,GOV-DB-001,系统可校验证据是否存在且不是模板占位,COMPLETED
|
||||
5A,P0,database,GOV-DB-005,新增发布批次与门禁结果表,当前上线批准状态只存在 PowerShell 产物中,新增 governance_release_batches 和 governance_gate_runs 表,GOV-DB-001;GOV-DB-003;GOV-DB-004,系统可追踪一次发布批次的门禁快照和结果,COMPLETED
|
||||
5A,P1,database,GOV-DB-006,模板表补治理字段,contract_templates 只有内容和启停没有版本与发布元数据,为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id,GOV-DB-001,模板可区分草稿版和生产发布版,PLANNED
|
||||
5A,P1,database,GOV-DB-006,模板表补治理字段,contract_templates 只有内容和启停没有版本与发布元数据,为 contract_templates 增加 template_code version published_at published_by latest_governance_asset_id,GOV-DB-001,模板可区分草稿版和生产发布版,COMPLETED
|
||||
5A,P1,database,GOV-DB-007,知识库文档表补法源元数据,知识库文档缺少效力级别生效日期发文机关和法源类型,为知识库相关表增加 source_type authority effective_date region tags,none,知识库可按法源维度治理和过滤,PLANNED
|
||||
5B,P0,backend_api,GOV-API-001,新增治理资产列表接口,当前没有统一治理视图接口,新增 /governance/assets 列表筛选汇总接口,GOV-DB-001,前端可按资产类型状态角色查看治理资产,COMPLETED
|
||||
5B,P0,backend_api,GOV-API-002,新增复核分配接口,当前无法在系统内分配真实复核人,新增 /governance/assignments 创建更新撤销接口,GOV-DB-002,管理员可在系统内完成复核任务分配,COMPLETED
|
||||
@@ -12,7 +12,7 @@ batch,priority,area,task_id,title,current_gap,target_deliverable,depends_on,acce
|
||||
5B,P0,backend_api,GOV-API-004,新增证据上传绑定接口,当前系统没有专门的治理证据绑定接口,新增 /governance/evidences 上传绑定替换校验接口,GOV-DB-004,系统可阻止模板证据进入已批准状态,COMPLETED
|
||||
5B,P0,backend_api,GOV-API-005,新增上线批准汇总接口,当前没有系统内 release approval summary,新增 /governance/releases/summary 和 /governance/releases/{id} 接口,GOV-DB-005,前端可直接展示当前上线阻断项与通过项,COMPLETED
|
||||
5B,P0,backend_api,GOV-API-006,新增CSV台账导入接口,当前正式上线台账只靠仓库脚本导入,新增 /governance/imports/formal-launch 接口读取既有 CSV 映射到治理表,GOV-DB-001;GOV-DB-002;GOV-DB-003;GOV-DB-004,旧台账可一次性导入数据库且保留来源信息,COMPLETED
|
||||
5B,P1,backend_api,GOV-API-007,模板规则知识库发布接口,当前内容模块没有生产发布动作,为 templates review-rules knowledge-bases 增加 publish/unpublish/rollback 接口,GOV-DB-005;GOV-DB-006,已批准资产才能被发布到生产可用集合,PLANNED
|
||||
5B,P1,backend_api,GOV-API-007,模板规则知识库发布接口,当前内容模块没有生产发布动作,为 templates review-rules knowledge-bases 增加 publish/unpublish/rollback 接口,GOV-DB-005;GOV-DB-006,已批准资产才能被发布到生产可用集合,IN_PROGRESS
|
||||
5C,P0,frontend,GOV-WEB-001,新增复核总控中心页面,当前没有统一治理控制台,新增 /governance 页面展示资产统计阻断项角色视图和发布批次,GOV-API-001;GOV-API-005,管理员可在单页看到当前正式上线治理全貌,COMPLETED
|
||||
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
|
||||
|
||||
|
Reference in New Issue
Block a user