diff --git a/app/models.py b/app/models.py index 05a6834..e3c1965 100644 --- a/app/models.py +++ b/app/models.py @@ -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") diff --git a/app/routers/instructor.py b/app/routers/instructor.py index 1435d6d..d7bec59 100644 --- a/app/routers/instructor.py +++ b/app/routers/instructor.py @@ -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")) diff --git a/app/routers/practices.py b/app/routers/practices.py index 5edd69c..1baa2b7 100644 --- a/app/routers/practices.py +++ b/app/routers/practices.py @@ -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"))) } ) diff --git a/app/schemas.py b/app/schemas.py index da1b27b..9b606e9 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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): diff --git a/app/templates.py b/app/templates.py index 3afed95..d1cf507 100644 --- a/app/templates.py +++ b/app/templates.py @@ -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",