From 7c8de6aec8530850a937336a2235543bdcc1d9a1 Mon Sep 17 00:00:00 2001 From: BFLOWS Date: Mon, 20 Apr 2026 18:47:03 +0200 Subject: [PATCH] feat(docs): link documenti dal repository company + gate submit su EXPIRED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa il riutilizzo dei documenti caricati in fase domanda (gepafin_schema.company_document): il benef puo selezionarli dal picker repository invece di caricarli dal PC, ereditando filename/expires_at/storage_path. Tracciato via source_company_document_id per lookup live dello stato (VALID/DUE/EXPIRED). Modifiche: - migrations.py: ALTER TABLE remission_document ADD source_company_document_id + index partial - models.py: aggiunto campo source_company_document_id su RemissionDocument - schemas.py: esposto source_company_document_id in DocumentUpsert + DocumentOut - routers/files.py: nuovo POST /document/{id}/link-from-repository — verifica ownership company, pulisce file PC precedente, copia metadati dal sorgente, ritorna source_status - routers/practices.py: nuovo check documents_not_expired in _compute_gate_check — JOIN live su gepafin_schema.company_document.status per doc linkati, controllo expires_at per upload diretti. Gate hard: documento EXPIRED blocca submit (422). Test E2E verificati via curl/JWT offline: - link VALID → metadati copiati, gate passed - link EXPIRED → gate overall FAIL con detail 'Scaduti: DURC' - re-link VALID → gate torna passed - submit bloccato solo su check non-doc (fatture/altri doc mancanti), docs_not_expired OK Seed sandbox: 4 document_category + 5 company_document su NAPOLI SAS (3 VALID / 1 DUE / 1 EXPIRED). --- app/migrations.py | 11 +++++ app/models.py | 5 +++ app/routers/files.py | 96 ++++++++++++++++++++++++++++++++++++++++ app/routers/practices.py | 24 ++++++++++ app/schemas.py | 2 + 5 files changed, 138 insertions(+) diff --git a/app/migrations.py b/app/migrations.py index 4ae4000..dd50778 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -68,6 +68,17 @@ MIGRATIONS = [ ON gepafin_rendic.remission_practice(assigned_instructor_id) WHERE assigned_instructor_id IS NULL; """, + # 2026-04-20: link documento a company_document del BE Gepafin (riutilizzo dal repository) + # Se source_company_document_id e valorizzato, il documento e selezionato dal repository + # company (gepafin_schema.company_document). Lo status/scadenza del sorgente governa + # semaforo UI e gate submit (documenti EXPIRED bloccano la trasmissione). + """ + ALTER TABLE gepafin_rendic.remission_document + ADD COLUMN IF NOT EXISTS source_company_document_id integer; + CREATE INDEX IF NOT EXISTS idx_remission_document_source + ON gepafin_rendic.remission_document(source_company_document_id) + WHERE source_company_document_id IS NOT NULL; + """, # 2026-04-18 v2: tabella custom checks # allineata allo storage adapter esistente (storage_path + mime + size + sha256) # NON segue le specs RAG p1 che usavano document_filename (v1 obsoleta) diff --git a/app/models.py b/app/models.py index d31253e..b8ee3b9 100644 --- a/app/models.py +++ b/app/models.py @@ -199,6 +199,11 @@ class RemissionDocument(Base): sha256 = Column(String(64), nullable=True) uploaded_by = Column(Integer, nullable=True) + # Link al repository documenti della company (gepafin_schema.company_document). + # Se valorizzato, il documento e stato selezionato dal picker repository invece + # che caricato dal PC. filename/expires_at vengono copiati al momento del link. + source_company_document_id = Column(Integer, nullable=True) + # Campi istruttoria verification_status = Column(String(16), nullable=False, default="PENDING") # PENDING | VALIDO | NON_VALIDO | SCADUTO diff --git a/app/routers/files.py b/app/routers/files.py index de01b80..e90de84 100644 --- a/app/routers/files.py +++ b/app/routers/files.py @@ -11,7 +11,9 @@ from uuid import UUID from typing import Literal from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request +from pydantic import BaseModel from fastapi.responses import FileResponse, Response +from sqlalchemy import text from sqlalchemy.orm import Session from ..db import get_db @@ -267,3 +269,97 @@ def delete_entity_file( db.commit() return ApiResponse(success=True, message="File eliminato") + + +# ---------- Link da repository company ---------- +# 2026-04-20: riutilizzo documenti caricati in fase domanda. +# Il benef seleziona un documento dal proprio repository company invece di caricarlo +# dal PC. Non c'e upload fisico: copiamo solo i metadati (filename, expires_at, +# storage_path per preview/download) e tracciamo source_company_document_id per +# permettere lookup live dello status sorgente (VALID/DUE/EXPIRED). +class LinkFromRepositoryRequest(BaseModel): + company_document_id: int + + +@router.post("/document/{entity_id}/link-from-repository", response_model=ApiResponse) +def link_document_from_repository( + entity_id: UUID, + body: LinkFromRepositoryRequest, + db: Session = Depends(get_db), + user: AuthUser = Depends(get_current_user), +): + """ + Associa un remission_document esistente a un company_document del repository + della fase domanda. Sostituisce eventuali file precedenti caricati dal PC + (elimina dallo storage, azzera storage_path). + """ + # 1) carica il remission_document e verifica permesso upload (benef owner o admin) + entity = db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first() + if not entity: + raise HTTPException(status_code=404, detail="Documento non trovato") + + practice = db.query(RemissionPractice).filter(RemissionPractice.id == entity.practice_id).first() + if not practice: + raise HTTPException(status_code=404, detail="Pratica non trovata") + if not _can_upload(user, practice): + raise HTTPException(status_code=403, detail="Non autorizzato") + + # pratica editabile solo in DRAFT (stessa regola dell'upload) + if practice.status != "DRAFT": + raise HTTPException(status_code=409, detail=f"Pratica in stato {practice.status}: non modificabile") + + # 2) leggi il company_document (deve esistere, stessa company della pratica, non eliminato) + row = db.execute(text(""" + SELECT id, file_name, file_path, type, status, expiration_date, company_id + FROM gepafin_schema.company_document + WHERE id = :cid AND is_deleted = false + """), {"cid": body.company_document_id}).mappings().first() + + if not row: + raise HTTPException(status_code=404, detail=f"company_document {body.company_document_id} non trovato") + + if row["company_id"] != practice.company_id: + raise HTTPException( + status_code=403, + detail="Documento repository non appartiene alla company di questa pratica" + ) + + # 3) se c'era un file fisico caricato dal PC in precedenza, lo rimuoviamo per pulizia + if entity.storage_path and not entity.source_company_document_id: + try: + delete_file(entity.storage_path) + except Exception: + pass # non bloccare se il file non c'e piu + + # 4) aggiorna metadati con quelli del repository + from datetime import datetime, timezone, date + entity.source_company_document_id = row["id"] + entity.filename = row["file_name"] + entity.storage_path = row["file_path"] # riuso del path fisico del BE per preview/download + entity.mime = None + entity.size_bytes = None + entity.sha256 = None + entity.uploaded_by = user.user_id + entity.uploaded_at = datetime.now(timezone.utc) + # scadenza dal sorgente (timestamp -> date) + exp = row["expiration_date"] + if exp is not None: + entity.expires_at = exp.date() if hasattr(exp, 'date') else exp + else: + entity.expires_at = None + + db.commit() + db.refresh(entity) + + return ApiResponse( + success=True, + message=f"Documento collegato dal repository (source_status={row['status']})", + data={ + "id": str(entity.id), + "doc_code": entity.doc_code, + "filename": entity.filename, + "source_company_document_id": entity.source_company_document_id, + "expires_at": entity.expires_at.isoformat() if entity.expires_at else None, + "source_status": row["status"], # VALID | DUE | EXPIRED — per UI semaforo + } + ) diff --git a/app/routers/practices.py b/app/routers/practices.py index 80e380b..6dd845f 100644 --- a/app/routers/practices.py +++ b/app/routers/practices.py @@ -216,6 +216,30 @@ def _compute_gate_check(db: Session, practice: RemissionPractice) -> GateCheckRe "detail": f"Mancanti: {', '.join(missing_docs)}" if missing_docs else "Tutti presenti" }) + # Check 5b: documenti non scaduti (gate hard su EXPIRED) + # 2026-04-20: documento EXPIRED blocca la submit. Status letto live via JOIN sul + # BE Gepafin per doc collegati dal repository; per upload diretto PC controlla expires_at. + from datetime import date as _date_today_cls + today = _date_today_cls.today() + expired_docs = [] + for doc in practice.documents: + if doc.source_company_document_id: + cd_status = db.execute(text(""" + SELECT status FROM gepafin_schema.company_document + WHERE id = :cid AND is_deleted = false + """), {"cid": doc.source_company_document_id}).scalar() + if cd_status == 'EXPIRED': + expired_docs.append(doc.doc_code) + elif doc.expires_at is not None and doc.expires_at < today: + expired_docs.append(doc.doc_code) + + checks.append({ + "id": "documents_not_expired", + "label": "Nessun documento scaduto", + "passed": len(expired_docs) == 0, + "detail": f"Scaduti: {', '.join(expired_docs)}" if expired_docs else "Tutti validi" + }) + # Check 6: importo range (cap erogato) amt_range = rules.get("amount_range", {}) min_e = Decimal(str(amt_range.get("min", 0))) diff --git a/app/schemas.py b/app/schemas.py index e4f72fd..b21d001 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -117,6 +117,7 @@ class DocumentUpsert(BaseModel): uploaded_at: Optional[datetime] = None expires_at: Optional[date] = None notes: Optional[str] = None + source_company_document_id: Optional[int] = None class DocumentOut(BaseModel): @@ -127,6 +128,7 @@ class DocumentOut(BaseModel): uploaded_at: Optional[datetime] = None expires_at: Optional[date] = None notes: Optional[str] = None + source_company_document_id: Optional[int] = None # istruttoria verification_status: str = "PENDING" verification_notes: Optional[str] = None