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

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