feat(docs): link documenti dal repository company + gate submit su EXPIRED

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).
This commit is contained in:
BFLOWS
2026-04-20 18:47:03 +02:00
parent a3f863ecdb
commit 7c8de6aec8
5 changed files with 138 additions and 0 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}
)

View File

@@ -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)))

View File

@@ -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