feat(istruttoria): verifica riga-per-riga con dual declared/verified

Replica il workflow del foglio Excel originale (REMISSIONE_DEL_DEBITO_5888.xlsm).
Istruttore ora verifica ogni fattura, ogni dipendente ULA, ogni documento singolarmente
invece di accettare/respingere la pratica intera.

Modello dati - nuove colonne su 3 tabelle:
- remission_invoice: taxable_verified, vat_verified, total_verified,
  verification_status (PENDING/AMMESSA/PARZIALE/RESPINTA), verification_notes,
  date_checks (JSONB con invoice_in_period/payment_in_period), verified_by, verified_at
- remission_ula_employee: fte_pct_verified, verification_status, verification_notes,
  verified_by, verified_at
- remission_document: verification_status (PENDING/VALIDO/NON_VALIDO/SCADUTO),
  verification_notes, verified_by, verified_at
- remission_practice: instructor_final_notes, instructor_checklist (JSONB 3 gate SI/NO),
  verbale_date

Nuovi endpoint:
- PUT /instructor/{id}/invoices/{inv_id}/verify (status + rettifica importi + note)
- PUT /instructor/{id}/ula-employees/{emp_id}/verify (rettifica FTE + note)
- PUT /instructor/{id}/documents/{doc_code}/verify (VALIDO/NON_VALIDO/SCADUTO + note)
- PUT /instructor/{id}/final-notes (note sintetiche + checklist)

Ricalcolo gate_check dual track:
- grand_total_declared: sempre (importo richiesto dal beneficiario)
- grand_total_verified: somma solo fatture AMMESSA/PARZIALE (se PARZIALE usa verified)
- remission_due: usa verified se any_verified=True, altrimenti declared (backward compat)
- residuo_da_restituire: amount_erogato - remission_due
- flag any_verified e all_verified per gating decisione finale

_auto_check_dates: fattura in periodo? pagamento in periodo?
Legge period_start e period_end da schema.gate_rules (superadmin editor).

Template: aggiunto period_start/period_end_date come campi 'editable_by superadmin'
nella sezione general static_fields.

Schema editor FE (BandoRendicontazioneSchemaEdit): aggiunto Calendar period_start
accanto a period_end in section gate rules. period_start_rule dropdown per logica
(erogato_date|fixed) resta; period_start data fissa usata dal check.
This commit is contained in:
BFLOWS
2026-04-18 11:03:15 +02:00
parent 26fbc03871
commit f9f543b008
5 changed files with 312 additions and 12 deletions

View File

@@ -67,6 +67,9 @@ class RemissionPractice(Base):
reviewed_by = Column(Integer, nullable=True)
rejection_reason = Column(Text, nullable=True)
approved_remission = Column(Numeric(14, 2), nullable=True)
instructor_final_notes = Column(Text, nullable=True)
instructor_checklist = Column(JSONB, nullable=True, default=dict)
verbale_date = Column(Date, nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
@@ -101,6 +104,17 @@ class RemissionInvoice(Base):
total = Column(Numeric(14, 2), nullable=False)
pdf_filename = Column(String(512), nullable=True) # per ora solo nome, upload vero dopo
# Campi istruttoria (dual declared/verified)
taxable_verified = Column(Numeric(14, 2), nullable=True)
vat_verified = Column(Numeric(14, 2), nullable=True)
total_verified = Column(Numeric(14, 2), nullable=True)
verification_status = Column(String(16), nullable=False, default="PENDING")
# PENDING | AMMESSA | PARZIALE | RESPINTA
verification_notes = Column(Text, nullable=True)
date_checks = Column(JSONB, nullable=True, default=dict)
verified_by = Column(Integer, nullable=True)
verified_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
practice = relationship("RemissionPractice", back_populates="invoices")
@@ -127,6 +141,13 @@ class RemissionUlaEmployee(Base):
supporting_doc_type = Column(String(64), nullable=True)
supporting_doc_filename = Column(String(512), nullable=True)
# Campi istruttoria
fte_pct_verified = Column(Numeric(5, 4), nullable=True)
verification_status = Column(String(16), nullable=False, default="PENDING")
verification_notes = Column(Text, nullable=True)
verified_by = Column(Integer, nullable=True)
verified_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
practice = relationship("RemissionPractice", back_populates="ula_employees")
@@ -148,6 +169,13 @@ class RemissionDocument(Base):
expires_at = Column(Date, nullable=True)
notes = Column(Text, nullable=True)
# Campi istruttoria
verification_status = Column(String(16), nullable=False, default="PENDING")
# PENDING | VALIDO | NON_VALIDO | SCADUTO
verification_notes = Column(Text, nullable=True)
verified_by = Column(Integer, nullable=True)
verified_at = Column(DateTime(timezone=True), nullable=True)
practice = relationship("RemissionPractice", back_populates="documents")

View File

@@ -16,8 +16,13 @@ from ..models import RemissionPractice, RemissionAmendmentRequest
from ..schemas import (
AmendmentRequestCreate, AmendmentRequestOut, AmendmentResponseSubmit,
ReviewApproveBody, ReviewRejectBody,
InstructorQueueItem, PracticeOut, ApiResponse
InstructorQueueItem, PracticeOut, ApiResponse,
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
InstructorFinalNotesBody,
InvoiceOut, UlaEmployeeOut, DocumentOut
)
from ..models import RemissionInvoice, RemissionUlaEmployee, RemissionDocument
from datetime import date
from .practices import _compute_gate_check
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["instructor"])
@@ -274,3 +279,153 @@ def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
db.refresh(ar)
return ApiResponse(message="Risposta registrata. L'istruttore verrà notificato.",
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
# ========== VERIFICA SINGOLA RIGA ==========
def _auto_check_dates(inv, practice) -> dict:
"""Verifica automatica: fattura emessa e pagata dentro il periodo ammissibilita.
Legge period_start/period_end dal gate_rules dello schema_snapshot."""
rules = practice.schema_snapshot.get("gate_rules", {}) or {}
pstart = rules.get("period_start")
pend = rules.get("period_end")
try:
from datetime import date as _date
pstart_d = _date.fromisoformat(pstart) if pstart else None
pend_d = _date.fromisoformat(pend) if pend else None
except Exception:
pstart_d = pend_d = None
def _in_range(d):
if not d: return None
ok = True
if pstart_d: ok = ok and d >= pstart_d
if pend_d: ok = ok and d <= pend_d
return ok
return {
"period_start": pstart,
"period_end": pend,
"invoice_in_period": _in_range(inv.invoice_date),
"payment_in_period": _in_range(inv.payment_date)
}
@router.put("/{practice_id}/invoices/{invoice_id}/verify", response_model=ApiResponse)
def verify_invoice(practice_id: UUID, invoice_id: UUID, body: InvoiceVerifyBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
"""Rettifica/verifica una singola fattura."""
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}")
if body.verification_status not in ("AMMESSA", "PARZIALE", "RESPINTA", "PENDING"):
raise HTTPException(status_code=422, detail="verification_status non valido")
inv = db.query(RemissionInvoice).filter(
RemissionInvoice.id == invoice_id,
RemissionInvoice.practice_id == practice_id
).first()
if not inv:
raise HTTPException(status_code=404, detail="Fattura non trovata")
inv.verification_status = body.verification_status
inv.verification_notes = body.verification_notes
if body.verification_status in ("AMMESSA", "PARZIALE"):
# Se AMMESSA e nessuna rettifica: copio i dichiarati come verificati
if body.verification_status == "AMMESSA" and body.taxable_verified is None and body.total_verified is None:
inv.taxable_verified = inv.taxable
inv.vat_verified = inv.vat
inv.total_verified = inv.total
else:
if body.taxable_verified is not None: inv.taxable_verified = body.taxable_verified
if body.vat_verified is not None: inv.vat_verified = body.vat_verified
if body.total_verified is not None: inv.total_verified = body.total_verified
else: # RESPINTA | PENDING
inv.taxable_verified = inv.vat_verified = inv.total_verified = None
inv.date_checks = _auto_check_dates(inv, p)
inv.verified_by = user.user_id
inv.verified_at = datetime.now(timezone.utc)
db.commit()
db.refresh(inv)
return ApiResponse(message=f"Fattura {body.verification_status}",
data=InvoiceOut.model_validate(inv).model_dump(mode="json"))
@router.put("/{practice_id}/ula-employees/{employee_id}/verify", response_model=ApiResponse)
def verify_ula_employee(practice_id: UUID, employee_id: UUID, body: UlaVerifyBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}")
if body.verification_status not in ("AMMESSA", "PARZIALE", "RESPINTA", "PENDING"):
raise HTTPException(status_code=422, detail="verification_status non valido")
e = db.query(RemissionUlaEmployee).filter(
RemissionUlaEmployee.id == employee_id,
RemissionUlaEmployee.practice_id == practice_id
).first()
if not e:
raise HTTPException(status_code=404, detail="Dipendente non trovato")
e.verification_status = body.verification_status
e.verification_notes = body.verification_notes
if body.verification_status in ("AMMESSA", "PARZIALE"):
if body.verification_status == "AMMESSA" and body.fte_pct_verified is None:
e.fte_pct_verified = e.fte_pct
elif body.fte_pct_verified is not None:
e.fte_pct_verified = body.fte_pct_verified
else:
e.fte_pct_verified = None
e.verified_by = user.user_id
e.verified_at = datetime.now(timezone.utc)
db.commit()
db.refresh(e)
return ApiResponse(message=f"Dipendente {body.verification_status}",
data=UlaEmployeeOut.model_validate(e).model_dump(mode="json"))
@router.put("/{practice_id}/documents/{doc_code}/verify", response_model=ApiResponse)
def verify_document(practice_id: UUID, doc_code: str, body: DocumentVerifyBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}")
if body.verification_status not in ("VALIDO", "NON_VALIDO", "SCADUTO", "PENDING"):
raise HTTPException(status_code=422, detail="verification_status non valido")
d = db.query(RemissionDocument).filter(
RemissionDocument.practice_id == practice_id,
RemissionDocument.doc_code == doc_code
).first()
if not d:
raise HTTPException(status_code=404, detail="Documento non trovato")
d.verification_status = body.verification_status
d.verification_notes = body.verification_notes
d.verified_by = user.user_id
d.verified_at = datetime.now(timezone.utc)
db.commit()
db.refresh(d)
return ApiResponse(message=f"Documento {body.verification_status}",
data=DocumentOut.model_validate(d).model_dump(mode="json"))
@router.put("/{practice_id}/final-notes", response_model=ApiResponse)
def set_instructor_final_notes(practice_id: UUID, body: InstructorFinalNotesBody,
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
p = _get_practice_or_404(db, practice_id)
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
raise HTTPException(status_code=409, detail=f"Pratica in stato {p.status}")
if body.instructor_final_notes is not None:
p.instructor_final_notes = body.instructor_final_notes
if body.instructor_checklist is not None:
p.instructor_checklist = body.instructor_checklist
db.commit()
db.refresh(p)
return ApiResponse(message="Verbale aggiornato",
data=PracticeOut.model_validate(p).model_dump(mode="json"))

View File

@@ -52,20 +52,49 @@ def _ensure_editable(practice: RemissionPractice):
def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
"""Valuta le gate_rules dello schema snapshot contro il contenuto della pratica."""
"""Valuta le gate_rules dello schema snapshot contro il contenuto della pratica.
Calcola:
- per_category_declared: totali dichiarati dal beneficiario (sempre)
- per_category_verified: totali verificati dall istruttore (solo AMMESSA/PARZIALE)
- grand_total: totale dichiarato (rif. beneficiario)
- grand_total_verified: totale verificato (rif. remissione finale)
"""
rules = practice.schema_snapshot.get("gate_rules", {}) or {}
sections = practice.schema_snapshot.get("sections", []) or []
# totali per categoria
per_category = {}
grand_total = Decimal("0")
iva_ordinario_only_taxable = rules.get("iva_ordinario_imponibile_only", True)
use_taxable_only = (practice.iva_regime == "ORDINARIO" and iva_ordinario_only_taxable)
per_category_declared = {}
per_category_verified = {}
grand_total = Decimal("0")
grand_total_verified = Decimal("0")
any_verified = False
all_verified = len(practice.invoices) > 0
for inv in practice.invoices:
amt = inv.taxable if use_taxable_only else inv.total
per_category[inv.category_code] = per_category.get(inv.category_code, Decimal("0")) + amt
grand_total += amt
# Dichiarato (sempre)
amt_decl = inv.taxable if use_taxable_only else inv.total
per_category_declared[inv.category_code] = per_category_declared.get(inv.category_code, Decimal("0")) + amt_decl
grand_total += amt_decl
# Verificato (solo se stato AMMESSA o PARZIALE)
if inv.verification_status in ("AMMESSA", "PARZIALE"):
any_verified = True
# se PARZIALE usa i valori verified; se AMMESSA ma verified sono null, usa dichiarato
if inv.verification_status == "PARZIALE":
tax_v = inv.taxable_verified if inv.taxable_verified is not None else inv.taxable
tot_v = inv.total_verified if inv.total_verified is not None else inv.total
else: # AMMESSA
tax_v = inv.taxable_verified if inv.taxable_verified is not None else inv.taxable
tot_v = inv.total_verified if inv.total_verified is not None else inv.total
amt_ver = tax_v if use_taxable_only else tot_v
per_category_verified[inv.category_code] = per_category_verified.get(inv.category_code, Decimal("0")) + amt_ver
grand_total_verified += amt_ver
elif inv.verification_status == "PENDING":
all_verified = False
# RESPINTA: non contribuisce ai verified
# cap remissione
amt_erogato = practice.amount_erogato
@@ -73,6 +102,14 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
max_remission = min(cap_pct * amt_erogato, cap_abs)
# Se almeno 1 verifica fatta -> uso grand_total_verified per remission_due
# altrimenti uso grand_total (dichiarato) per preview pre-istruttoria
effective_total = grand_total_verified if any_verified else grand_total
remission_due = min(effective_total, max_remission)
# Per compatibilità: per_category e grand_total restano "dichiarato"
per_category = per_category_declared
checks = []
# Check 1: regime IVA scelto
@@ -140,17 +177,22 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
"detail": f"Erogato: {amt_erogato} EUR"
})
remission_due = min(grand_total, max_remission)
return GateCheckResult(
passed=all(c["passed"] for c in checks),
checks=checks,
totals={
"per_category": {k: float(v) for k, v in per_category.items()},
"per_category_declared": {k: float(v) for k, v in per_category_declared.items()},
"per_category_verified": {k: float(v) for k, v in per_category_verified.items()},
"grand_total": float(grand_total),
"grand_total_declared": float(grand_total),
"grand_total_verified": float(grand_total_verified),
"max_remission": float(max_remission),
"remission_due": float(remission_due),
"amount_erogato": float(amt_erogato)
"amount_erogato": float(amt_erogato),
"any_verified": any_verified,
"all_verified": all_verified,
"residuo_da_restituire": float(max(amt_erogato - Decimal(str(remission_due)), Decimal("0")))
}
)

View File

@@ -68,6 +68,15 @@ class InvoiceOut(InvoiceCreate):
id: UUID
practice_id: UUID
created_at: datetime
# istruttoria
taxable_verified: Optional[Decimal] = None
vat_verified: Optional[Decimal] = None
total_verified: Optional[Decimal] = None
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
date_checks: Optional[dict] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
model_config = {"from_attributes": True}
@@ -89,6 +98,12 @@ class UlaEmployeeOut(UlaEmployeeCreate):
id: UUID
practice_id: UUID
created_at: datetime
# istruttoria
fte_pct_verified: Optional[Decimal] = None
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
model_config = {"from_attributes": True}
@@ -110,6 +125,11 @@ class DocumentOut(BaseModel):
uploaded_at: Optional[datetime] = None
expires_at: Optional[date] = None
notes: Optional[str] = None
# istruttoria
verification_status: str = "PENDING"
verification_notes: Optional[str] = None
verified_by: Optional[int] = None
verified_at: Optional[datetime] = None
model_config = {"from_attributes": True}
@@ -130,6 +150,16 @@ class PracticeOut(BaseModel):
updated_at: datetime
submitted_at: Optional[datetime] = None
# istruttoria
assigned_instructor_id: Optional[int] = None
reviewed_at: Optional[datetime] = None
reviewed_by: Optional[int] = None
rejection_reason: Optional[str] = None
approved_remission: Optional[Decimal] = None
instructor_final_notes: Optional[str] = None
instructor_checklist: Optional[dict] = None
verbale_date: Optional[date] = None
invoices: List[InvoiceOut] = []
ula_employees: List[UlaEmployeeOut] = []
documents: List[DocumentOut] = []
@@ -223,6 +253,35 @@ class InstructorQueueItem(BaseModel):
model_config = {"from_attributes": True}
# Verifica singola fattura
class InvoiceVerifyBody(BaseModel):
verification_status: str # AMMESSA | PARZIALE | RESPINTA
taxable_verified: Optional[Decimal] = None
vat_verified: Optional[Decimal] = None
total_verified: Optional[Decimal] = None
verification_notes: Optional[str] = None
# Verifica singolo dipendente ULA
class UlaVerifyBody(BaseModel):
verification_status: str # AMMESSA | PARZIALE | RESPINTA
fte_pct_verified: Optional[Decimal] = None
verification_notes: Optional[str] = None
# Verifica singolo documento
class DocumentVerifyBody(BaseModel):
verification_status: str # VALIDO | NON_VALIDO | SCADUTO
verification_notes: Optional[str] = None
# Note finali istruttore + checklist
class InstructorFinalNotesBody(BaseModel):
instructor_final_notes: Optional[str] = None
instructor_checklist: Optional[dict] = None
# ====================== Wrapper ======================
class ApiResponse(BaseModel):

View File

@@ -12,8 +12,24 @@ RESTART_TEMPLATE = {
"type": "static_fields",
"id": "general",
"label": "Dati generali",
"description": "Regime IVA e dati base del beneficiario. ATECO e importo erogato sono pre-compilati dalla domanda approvata.",
"description": "Regime IVA, dati base del beneficiario, periodo di ammissibilità delle spese.",
"fields": [
{
"id": "period_start_date",
"type": "date",
"label": "Periodo ammissibilità — Data inizio",
"description": "Data minima di emissione/pagamento fatture ammissibili. Di norma: data erogazione del finanziamento.",
"editable_by": "superadmin",
"required": True
},
{
"id": "period_end_date",
"type": "date",
"label": "Periodo ammissibilità — Data fine",
"description": "Data massima di emissione/pagamento fatture ammissibili.",
"editable_by": "superadmin",
"required": True
},
{
"id": "iva_regime",
"type": "select",