diff --git a/backend-compliance/compliance/api/legal_document_routes.py b/backend-compliance/compliance/api/legal_document_routes.py index 3dbd63dc..6979a37d 100644 --- a/backend-compliance/compliance/api/legal_document_routes.py +++ b/backend-compliance/compliance/api/legal_document_routes.py @@ -198,6 +198,52 @@ async def publish_version( return service.publish(version_id, request) +# ============================================================================ +# 5-stage Workflow (draft → review_internal → review_client → approved → published) +# ============================================================================ + +@router.post( + "/versions/{version_id}/submit-internal-review", + response_model=VersionResponse, +) +async def submit_internal_review( + version_id: str, + request: ActionRequest, + service: LegalDocumentService = Depends(_get_doc_service), +) -> VersionResponse: + """draft (oder rejected) → review_internal: DSB-Phase beginnt.""" + with translate_domain_errors(): + return service.submit_internal_review(version_id, request) + + +@router.post( + "/versions/{version_id}/approve-internal", + response_model=VersionResponse, +) +async def approve_internal( + version_id: str, + request: ActionRequest, + service: LegalDocumentService = Depends(_get_doc_service), +) -> VersionResponse: + """review_internal → review_client: DSB hat freigegeben, Mandant ist dran.""" + with translate_domain_errors(): + return service.approve_internal(version_id, request) + + +@router.post( + "/versions/{version_id}/approve-client", + response_model=VersionResponse, +) +async def approve_client( + version_id: str, + request: ActionRequest, + service: LegalDocumentService = Depends(_get_doc_service), +) -> VersionResponse: + """review_client → approved: Mandant hat freigegeben.""" + with translate_domain_errors(): + return service.approve_client(version_id, request) + + # ============================================================================ # Approval History # ============================================================================ diff --git a/backend-compliance/compliance/db/legal_document_models.py b/backend-compliance/compliance/db/legal_document_models.py index 8c6032c8..1658e7f5 100644 --- a/backend-compliance/compliance/db/legal_document_models.py +++ b/backend-compliance/compliance/db/legal_document_models.py @@ -42,7 +42,14 @@ class LegalDocumentDB(Base): class LegalDocumentVersionDB(Base): - """Version of a legal document with Approval-Workflow status.""" + """Version of a legal document with 5-stage Approval-Workflow. + + Lifecycle: + draft → review_internal (DSB-Prüfung) + → review_client (Mandanten-Prüfung) + → approved → published + (Ablehnung: review_internal/review_client → rejected → zurück zu draft) + """ __tablename__ = 'compliance_legal_document_versions' @@ -53,10 +60,21 @@ class LegalDocumentVersionDB(Base): title = Column(String(300), nullable=False) content = Column(Text, nullable=False) summary = Column(Text) - status = Column(String(20), default='draft') # draft|review|approved|published|archived|rejected + status = Column(String(20), default='draft') # draft|review_internal|review_client|approved|published|archived|rejected (+ legacy 'review') created_by = Column(String(200)) + + # Backwards-compat single approval (legacy 4-stage Flow) approved_by = Column(String(200)) approved_at = Column(DateTime) + + # 5-stage Trail + submitted_by = Column(String(200)) + submitted_at = Column(DateTime) + approved_internal_by = Column(String(200)) + approved_internal_at = Column(DateTime) + approved_client_by = Column(String(200)) + approved_client_at = Column(DateTime) + rejection_reason = Column(Text) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend-compliance/compliance/schemas/legal_document.py b/backend-compliance/compliance/schemas/legal_document.py index 90e1800b..b8ff8aea 100644 --- a/backend-compliance/compliance/schemas/legal_document.py +++ b/backend-compliance/compliance/schemas/legal_document.py @@ -57,8 +57,16 @@ class VersionResponse(BaseModel): summary: Optional[str] status: str created_by: Optional[str] + # Legacy single-approval (4-stage) approved_by: Optional[str] approved_at: Optional[datetime] + # 5-stage Trail + submitted_by: Optional[str] = None + submitted_at: Optional[datetime] = None + approved_internal_by: Optional[str] = None + approved_internal_at: Optional[datetime] = None + approved_client_by: Optional[str] = None + approved_client_at: Optional[datetime] = None rejection_reason: Optional[str] created_at: datetime updated_at: Optional[datetime] diff --git a/backend-compliance/compliance/services/legal_document_service.py b/backend-compliance/compliance/services/legal_document_service.py index b6ff55e4..f82b8297 100644 --- a/backend-compliance/compliance/services/legal_document_service.py +++ b/backend-compliance/compliance/services/legal_document_service.py @@ -59,6 +59,12 @@ def _version_to_response(v: LegalDocumentVersionDB) -> VersionResponse: created_by=v.created_by, approved_by=v.approved_by, approved_at=v.approved_at, + submitted_by=getattr(v, "submitted_by", None), + submitted_at=getattr(v, "submitted_at", None), + approved_internal_by=getattr(v, "approved_internal_by", None), + approved_internal_at=getattr(v, "approved_internal_at", None), + approved_client_by=getattr(v, "approved_client_by", None), + approved_client_at=getattr(v, "approved_client_at", None), rejection_reason=v.rejection_reason, created_at=v.created_at, updated_at=v.updated_at, @@ -259,38 +265,92 @@ class LegalDocumentService: return {"html": html_content, "filename": filename} # ------------------------------------------------------------------ - # Workflow transitions + # Workflow transitions (5-stage: draft → review_internal → review_client → approved → published) # ------------------------------------------------------------------ - def submit_review(self, version_id: str, request: ActionRequest) -> VersionResponse: + def submit_internal_review( + self, version_id: str, request: ActionRequest + ) -> VersionResponse: + """draft (oder rejected) → review_internal: DSB-Pruefung beginnt.""" return _transition( - self.db, version_id, ["draft", "rejected"], "review", "submitted", - request.approver, request.comment, - ) - - def approve(self, version_id: str, request: ActionRequest) -> VersionResponse: - return _transition( - self.db, version_id, ["review"], "approved", "approved", + self.db, version_id, ["draft", "rejected"], "review_internal", "submitted_internal", request.approver, request.comment, extra_updates={ + "submitted_by": request.approver, + "submitted_at": datetime.now(timezone.utc), + }, + ) + + def approve_internal( + self, version_id: str, request: ActionRequest + ) -> VersionResponse: + """review_internal → review_client: DSB hat geprueft, Mandant ist dran.""" + return _transition( + self.db, version_id, ["review_internal"], "review_client", "approved_internal", + request.approver, request.comment, + extra_updates={ + "approved_internal_by": request.approver, + "approved_internal_at": datetime.now(timezone.utc), + }, + ) + + def approve_client( + self, version_id: str, request: ActionRequest + ) -> VersionResponse: + """review_client → approved: Mandant hat freigegeben.""" + now = datetime.now(timezone.utc) + return _transition( + self.db, version_id, ["review_client"], "approved", "approved_client", + request.approver, request.comment, + extra_updates={ + "approved_client_by": request.approver, + "approved_client_at": now, + # Legacy-Felder mitfuehren fuer alte Konsumenten "approved_by": request.approver, - "approved_at": datetime.now(timezone.utc), + "approved_at": now, }, ) def reject(self, version_id: str, request: ActionRequest) -> VersionResponse: + """Ablehnung aus review_internal oder review_client (oder legacy 'review') → rejected.""" + v = ( + self.db.query(LegalDocumentVersionDB) + .filter(LegalDocumentVersionDB.id == version_id) + .first() + ) + action = "rejected" + if v is not None: + if v.status == "review_internal": + action = "rejected_internal" + elif v.status == "review_client": + action = "rejected_client" return _transition( - self.db, version_id, ["review"], "rejected", "rejected", + self.db, version_id, + ["review", "review_internal", "review_client"], + "rejected", action, request.approver, request.comment, extra_updates={"rejection_reason": request.comment}, ) def publish(self, version_id: str, request: ActionRequest) -> VersionResponse: + """approved → published.""" return _transition( self.db, version_id, ["approved"], "published", "published", request.approver, request.comment, ) + # ------------------------------------------------------------------ + # Backwards-compat aliases for 4-stage callers + # ------------------------------------------------------------------ + + def submit_review(self, version_id: str, request: ActionRequest) -> VersionResponse: + """Backwards-compat alias — leitet auf submit_internal_review um.""" + return self.submit_internal_review(version_id, request) + + def approve(self, version_id: str, request: ActionRequest) -> VersionResponse: + """Backwards-compat alias — leitet auf approve_internal (DSB-Schritt).""" + return self.approve_internal(version_id, request) + def approval_history(self, version_id: str) -> list[ApprovalHistoryEntry]: version = ( self.db.query(LegalDocumentVersionDB) diff --git a/backend-compliance/migrations/148_legal_document_5stage_lifecycle.sql b/backend-compliance/migrations/148_legal_document_5stage_lifecycle.sql new file mode 100644 index 00000000..b8ea4041 --- /dev/null +++ b/backend-compliance/migrations/148_legal_document_5stage_lifecycle.sql @@ -0,0 +1,76 @@ +-- ========================================================= +-- Migration 148: 5-stufiger Lifecycle für Legal Documents +-- +-- Erweitert compliance_legal_document_versions.status um: +-- - review_internal (DSB-interne Pruefung, war vorher implizit 'review') +-- - review_client (Mandanten-Freigabe-Phase nach DSB-OK) +-- +-- Plus CHECK-Constraint, der bisher fehlte (status war freier VARCHAR(20)). +-- 'review' bleibt erlaubt für Rückwärts-Kompatibilität. +-- +-- Approvals-Tabelle bekommt neue action-Werte: +-- - submitted_internal | approved_internal +-- - submitted_client | approved_client +-- - rejected_internal | rejected_client (zur Differenzierung im Audit) +-- ========================================================= + +DO $$ +BEGIN + -- Status-CHECK falls noch nicht vorhanden + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'compliance.compliance_legal_document_versions'::regclass + AND conname = 'chk_legal_doc_version_status' + ) THEN + ALTER TABLE compliance.compliance_legal_document_versions + ADD CONSTRAINT chk_legal_doc_version_status + CHECK (status IN ( + 'draft', + 'review', -- backward-compat (deprecated; neuer Code nutzt review_internal/review_client) + 'review_internal', -- DSB-Phase (NEU) + 'review_client', -- Mandant-Phase (NEU) + 'approved', + 'published', + 'archived', + 'rejected' + )); + END IF; + + -- Approvals-Action-CHECK falls noch nicht vorhanden + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'compliance.compliance_legal_document_approvals'::regclass + AND conname = 'chk_legal_doc_approval_action' + ) THEN + ALTER TABLE compliance.compliance_legal_document_approvals + ADD CONSTRAINT chk_legal_doc_approval_action + CHECK (action IN ( + 'created', 'submitted', + 'submitted_internal', 'submitted_client', -- NEU + 'approved', + 'approved_internal', 'approved_client', -- NEU + 'rejected', + 'rejected_internal', 'rejected_client', -- NEU + 'published', 'archived' + )); + END IF; +END $$; + +-- Neue Felder für 5-stufige Audit-Trail-Anreicherung +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'compliance' + AND table_name = 'compliance_legal_document_versions' + AND column_name = 'approved_internal_by' + ) THEN + ALTER TABLE compliance.compliance_legal_document_versions + ADD COLUMN approved_internal_by VARCHAR(200), + ADD COLUMN approved_internal_at TIMESTAMPTZ, + ADD COLUMN approved_client_by VARCHAR(200), + ADD COLUMN approved_client_at TIMESTAMPTZ, + ADD COLUMN submitted_by VARCHAR(200), + ADD COLUMN submitted_at TIMESTAMPTZ; + END IF; +END $$;