Files
gepafin-rendicontazione-api/app/models.py
BFLOWS f9f543b008 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.
2026-04-18 11:03:15 +02:00

207 lines
9.6 KiB
Python

"""
ORM models per rendicontazione-api.
Schema: gepafin_rendic (stesso DB del BE Gepafin sandbox).
"""
import uuid
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from .db import Base
class CallRemissionSchema(Base):
"""
Schema di rendicontazione per un bando. Uno per call_id.
status: DRAFT (modificabile) -> PUBLISHED (visibile ai beneficiari).
"""
__tablename__ = "call_remission_schema"
__table_args__ = (
UniqueConstraint("call_id", name="uq_call_remission_schema_call_id"),
{"schema": "gepafin_rendic"},
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
call_id = Column(Integer, nullable=False, unique=True)
schema_version = Column(Integer, nullable=False, default=1)
status = Column(String(32), nullable=False, default="DRAFT")
schema_json = Column(JSONB, nullable=False)
created_by = Column(Integer, nullable=False)
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())
published_at = Column(DateTime(timezone=True), nullable=True)
published_by = Column(Integer, nullable=True)
class RemissionPractice(Base):
"""
Pratica di rendicontazione di un beneficiario per una specifica application in CONTRACT_SIGNED.
Uno schema_snapshot congelato alla creazione: se il superadmin modifica lo schema
del bando dopo, la pratica continua a usare la versione snapshot.
"""
__tablename__ = "remission_practice"
__table_args__ = (
UniqueConstraint("application_id", name="uq_remission_practice_application"),
{"schema": "gepafin_rendic"},
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
call_id = Column(Integer, nullable=False)
application_id = Column(Integer, nullable=False, unique=True)
company_id = Column(Integer, nullable=False)
user_id = Column(Integer, nullable=False) # beneficiario che compila
status = Column(String(32), nullable=False, default="DRAFT")
# DRAFT -> SUBMITTED -> UNDER_REVIEW -> APPROVED | REJECTED | AWAITING_AMENDMENT
schema_snapshot = Column(JSONB, nullable=False) # copia schema al momento start
iva_regime = Column(String(32), nullable=True) # ORDINARIO | FORFETTARIO | ESENTE
amount_erogato = Column(Numeric(14, 2), nullable=False) # copiato da application.amount_accepted
notes_beneficiario = Column(Text, nullable=True)
# colonne istruttoria
assigned_instructor_id = Column(Integer, nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
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())
submitted_at = Column(DateTime(timezone=True), nullable=True)
# relazioni
invoices = relationship("RemissionInvoice", back_populates="practice", cascade="all, delete-orphan")
ula_employees = relationship("RemissionUlaEmployee", back_populates="practice", cascade="all, delete-orphan")
documents = relationship("RemissionDocument", back_populates="practice", cascade="all, delete-orphan")
amendment_requests = relationship("RemissionAmendmentRequest", back_populates="practice", cascade="all, delete-orphan")
class RemissionInvoice(Base):
"""Fattura rendicontata dentro una pratica, assegnata a una categoria."""
__tablename__ = "remission_invoice"
__table_args__ = ({"schema": "gepafin_rendic"},)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
practice_id = Column(UUID(as_uuid=True),
ForeignKey("gepafin_rendic.remission_practice.id", ondelete="CASCADE"),
nullable=False)
category_code = Column(String(16), nullable=False) # B1 / B2 / B3 / custom
invoice_number = Column(String(128), nullable=False)
invoice_date = Column(Date, nullable=False)
payment_date = Column(Date, nullable=False)
supplier_name = Column(String(255), nullable=False)
supplier_vat = Column(String(32), nullable=False)
description = Column(Text, nullable=False)
taxable = Column(Numeric(14, 2), nullable=False) # imponibile
vat = Column(Numeric(14, 2), nullable=False, default=0)
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")
class RemissionUlaEmployee(Base):
"""Dipendente conteggiato nel calcolo ULA."""
__tablename__ = "remission_ula_employee"
__table_args__ = ({"schema": "gepafin_rendic"},)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
practice_id = Column(UUID(as_uuid=True),
ForeignKey("gepafin_rendic.remission_practice.id", ondelete="CASCADE"),
nullable=False)
codice_fiscale = Column(String(16), nullable=False)
full_name = Column(String(255), nullable=False)
contract_type = Column(String(64), nullable=False) # T_IND / T_DET / APPR / ...
role_description = Column(String(255), nullable=True)
fte_pct = Column(Numeric(5, 4), nullable=False, default=1) # 0..1
period_start_date = Column(Date, nullable=False)
period_end_date = Column(Date, nullable=False)
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")
class RemissionDocument(Base):
"""Documento associato alla pratica (DURC, visura, ecc.)."""
__tablename__ = "remission_document"
__table_args__ = ({"schema": "gepafin_rendic"},)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
practice_id = Column(UUID(as_uuid=True),
ForeignKey("gepafin_rendic.remission_practice.id", ondelete="CASCADE"),
nullable=False)
doc_code = Column(String(64), nullable=False) # DURC / VISURA_CAMERALE / ...
filename = Column(String(512), nullable=True)
uploaded_at = Column(DateTime(timezone=True), nullable=True)
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")
class RemissionAmendmentRequest(Base):
"""Richiesta di soccorso istruttorio: istruttore chiede integrazioni al beneficiario."""
__tablename__ = "remission_amendment_request"
__table_args__ = ({"schema": "gepafin_rendic"},)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
practice_id = Column(UUID(as_uuid=True),
ForeignKey("gepafin_rendic.remission_practice.id", ondelete="CASCADE"),
nullable=False)
requested_by = Column(Integer, nullable=False)
request_text = Column(Text, nullable=False)
scope = Column(JSONB, nullable=True, default=dict)
deadline = Column(Date, nullable=False)
status = Column(String(32), nullable=False, default="AWAITING")
# AWAITING -> RESPONSE_RECEIVED -> CLOSED | EXPIRED | REJECTED
response_text = Column(Text, nullable=True)
response_at = Column(DateTime(timezone=True), nullable=True)
closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by = Column(Integer, 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())
practice = relationship("RemissionPractice", back_populates="amendment_requests")