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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user