Files
gepafin-rendicontazione-api/app/models.py
BFLOWS 25215f388b feat(v2): multi-tranche DB schema + gate cumulativo 5 voci Cecilia
A1 migrations.py:
- remission_practice DROP uq_application + ADD sequence_number/period_label/suggested_instructor_id
- UNIQUE composita (application_id, sequence_number)
- partial index idx_remission_practice_unassigned su assigned_instructor_id NULL
- nuova tabella remission_custom_check_value (storage_path/mime/size/sha256 allineata adapter)

A2 models.py + templates.py:
- RemissionPractice: UniqueConstraint composita, campi multi-tranche, relationship custom_checks
- classe RemissionCustomCheckValue
- RESTART_TEMPLATE schema_version=2, max_tranches=2, custom_checks esempio
  (antiriciclaggio required no-doc, polizza_fidejussoria optional con-doc)
- upgrade_schema_to_v2 idempotente per snapshot v1 esistenti

A3 _compute_gate_check(db, practice) CUMULATIVO:
- max_remission_global = min(cap_pct * erogato, cap_abs)
- already_approved = func.sum(approved_remission) su tranche APPROVED precedenti
  dello stesso application_id con sequence_number < corrente
- max_remission_this_tranche = max(0, global - already_approved)
- pre_check_admissible = min(grand_total_declared, this_tranche)  [voce 2 Cecilia]
- remission_due = min(effective_total, this_tranche)
- residuo_da_restituire = erogato - already_approved - remission_due (cumulativo)
- output totals esteso: sequence_number, tranches_count, tranches_max
- signature (db, practice) - aggiornati 6 call site in practices/instructor/verbale

Test su NAPOLI SAS: erogato 17K, cap 8500, tranche 1 approvata 467.14EUR,
tranche 2 vuota -> residuo disponibile 8032.86EUR, residuo_da_restituire 16532.86EUR.
2026-04-18 17:35:56 +02:00

281 lines
13 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, BigInteger
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", "sequence_number",
name="uq_remission_practice_app_seq"),
{"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 (application_id, sequence_number)
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)
# Multi-tranche v2 (2026-04-18)
sequence_number = Column(Integer, nullable=False, default=1)
period_label = Column(String(100), nullable=True) # libero, es "I trimestre 2021"
suggested_instructor_id = Column(Integer, nullable=True) # letto da BE assigned_applications
# 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")
custom_checks = relationship("RemissionCustomCheckValue", 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) # nome originale
# File upload (bind mount /var/uploads dentro container)
storage_path = Column(String(1024), nullable=True) # relativo a /var/uploads
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
uploaded_at = Column(DateTime(timezone=True), nullable=True)
# 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) # nome originale
# File upload
storage_path = Column(String(1024), nullable=True)
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, nullable=True)
uploaded_at = Column(DateTime(timezone=True), 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) # nome originale
uploaded_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(Date, nullable=True)
notes = Column(Text, nullable=True)
# File upload
storage_path = Column(String(1024), nullable=True)
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
uploaded_by = Column(Integer, 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")
class RemissionCustomCheckValue(Base):
"""Valore di un controllo custom configurato dallo schema del bando.
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.
Qui salviamo dichiarazione beneficiario + eventuale documento + verifica istruttore.
"""
__tablename__ = "remission_custom_check_value"
__table_args__ = (
UniqueConstraint("practice_id", "check_code", name="uq_custom_check_practice_code"),
{"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)
check_code = Column(String(64), nullable=False) # es "antiriciclaggio", "polizza_fidejussoria"
# Dichiarazione beneficiario
beneficiary_declared = Column(Boolean, nullable=False, default=False)
declared_at = Column(DateTime(timezone=True), nullable=True)
# Documento allegato (se requires_document)
storage_path = Column(String(1024), nullable=True)
mime = Column(String(128), nullable=True)
size_bytes = Column(BigInteger, nullable=True)
sha256 = Column(String(64), nullable=True)
document_uploaded_at = Column(DateTime(timezone=True), nullable=True)
uploaded_by = Column(Integer, nullable=True)
# Verifica istruttore
verification_status = Column(String(20), nullable=False, default="PENDING")
# PENDING | VALIDO | NON_VALIDO
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())
updated_at = Column(DateTime(timezone=True), nullable=False,
server_default=func.now(), onupdate=func.now())
practice = relationship("RemissionPractice", back_populates="custom_checks")