Compare commits
16 Commits
7fd56175ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83bb0a29ec | ||
|
|
1dbf542104 | ||
|
|
34c4a47a1c | ||
|
|
da13ca7478 | ||
|
|
7c8de6aec8 | ||
|
|
a3f863ecdb | ||
|
|
8950633481 | ||
|
|
345856f55c | ||
|
|
aeab399afa | ||
|
|
3021792c31 | ||
|
|
c19b2aa0b1 | ||
|
|
86681678c4 | ||
|
|
25215f388b | ||
|
|
6c089fb7b2 | ||
|
|
23a2b525a4 | ||
|
|
9a0a401ffa |
@@ -3,6 +3,15 @@ FROM python:3.12-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV TZ=Europe/Rome PYTHONUNBUFFERED=1
|
ENV TZ=Europe/Rome PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Dipendenze sistema: weasyprint serve libpango, libgdk-pixbuf, libcairo,
|
||||||
|
# shared-mime-info per MIME detection, libffi per cffi, fonts-dejavu come fallback.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 libpangoft2-1.0-0 \
|
||||||
|
libgdk-pixbuf-2.0-0 libcairo2 \
|
||||||
|
libffi-dev shared-mime-info \
|
||||||
|
fonts-dejavu fonts-dejavu-core \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
11
app/auth.py
11
app/auth.py
@@ -3,7 +3,7 @@ JWT validation compatibile con GEPAFIN-BE.
|
|||||||
Il BE Spring emette token HS512 con payload:
|
Il BE Spring emette token HS512 con payload:
|
||||||
sub: "email:userId:hubId"
|
sub: "email:userId:hubId"
|
||||||
userId: int
|
userId: int
|
||||||
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | ...
|
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | "ROLE_CONFIDI" | ...
|
||||||
exp: unix timestamp
|
exp: unix timestamp
|
||||||
loginAttemptId: int
|
loginAttemptId: int
|
||||||
"""
|
"""
|
||||||
@@ -29,6 +29,15 @@ class AuthUser:
|
|||||||
def is_beneficiary(self) -> bool:
|
def is_beneficiary(self) -> bool:
|
||||||
return self.role == "ROLE_BENEFICIARY"
|
return self.role == "ROLE_BENEFICIARY"
|
||||||
|
|
||||||
|
def is_confidi(self) -> bool:
|
||||||
|
return self.role == "ROLE_CONFIDI"
|
||||||
|
|
||||||
|
def is_owner_role(self) -> bool:
|
||||||
|
"""Ruoli che possono essere proprietari di una pratica (user_id match):
|
||||||
|
BENEFICIARY (azienda diretta) o CONFIDI (delegato per conto azienda).
|
||||||
|
Pattern allineato al BE Gepafin (DashboardDao, CompanyDocumentDao)."""
|
||||||
|
return self.role in ("ROLE_BENEFICIARY", "ROLE_CONFIDI")
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class Settings(BaseSettings):
|
|||||||
# CORS
|
# CORS
|
||||||
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072"
|
cors_origins: str = "http://78.46.41.91:18072,http://localhost:18072"
|
||||||
|
|
||||||
|
# Shared secret per endpoint /internal chiamati dal BE Gepafin
|
||||||
|
# In PROD va cambiato via env var RENDIC_INTERNAL_SECRET
|
||||||
|
internal_secret: str = "sandbox-internal-secret-ChangeMeInProd-AtLeast32Chars"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_prefix = "RENDIC_"
|
env_prefix = "RENDIC_"
|
||||||
|
|||||||
23
app/main.py
23
app/main.py
@@ -1,9 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
rendicontazione-api — microservizio sviluppato da BFLOWS per Gepafin.
|
rendicontazione-api — microservizio sviluppato da BFLOWS per Gepafin.
|
||||||
Gestisce schemi di rendicontazione per bando, pratiche di rendicontazione,
|
Gestisce schemi di rendicontazione per bando, pratiche di rendicontazione,
|
||||||
fatture, ULA, soccorso istruttorio.
|
fatture, ULA, soccorso istruttorio, upload file, verbale istruttoria.
|
||||||
|
|
||||||
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic).
|
Stack: FastAPI + SQLAlchemy + PostgreSQL (schema gepafin_rendic) + weasyprint.
|
||||||
Auth: JWT condiviso con GEPAFIN-BE.
|
Auth: JWT condiviso con GEPAFIN-BE.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
@@ -14,7 +14,9 @@ from sqlalchemy import text
|
|||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .db import engine, Base
|
from .db import engine, Base
|
||||||
from .routers import health, schemas, practices, debug, instructor
|
from .migrations import run_migrations
|
||||||
|
from .scheduler import start_scheduler, stop_scheduler
|
||||||
|
from .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment, internal
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
log = logging.getLogger("rendicontazione-api")
|
log = logging.getLogger("rendicontazione-api")
|
||||||
@@ -25,23 +27,25 @@ settings = get_settings()
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
log.info("Avvio rendicontazione-api")
|
log.info("Avvio rendicontazione-api")
|
||||||
# Crea schema e tabelle se non esistono (bootstrap sandbox)
|
|
||||||
try:
|
try:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.db_schema}'))
|
conn.execute(text(f'CREATE SCHEMA IF NOT EXISTS {settings.db_schema}'))
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
log.info(f"Schema '{settings.db_schema}' e tabelle inizializzate")
|
run_migrations(engine)
|
||||||
|
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
|
||||||
|
start_scheduler()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Errore bootstrap DB: {e}")
|
log.error(f"Errore bootstrap DB: {e}")
|
||||||
raise
|
raise
|
||||||
yield
|
yield
|
||||||
|
stop_scheduler()
|
||||||
log.info("Shutdown rendicontazione-api")
|
log.info("Shutdown rendicontazione-api")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="rendicontazione-api",
|
title="rendicontazione-api",
|
||||||
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
|
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
|
||||||
version="0.1.0",
|
version="0.4.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,13 +62,18 @@ app.include_router(schemas.router)
|
|||||||
app.include_router(practices.router)
|
app.include_router(practices.router)
|
||||||
app.include_router(debug.router)
|
app.include_router(debug.router)
|
||||||
app.include_router(instructor.router)
|
app.include_router(instructor.router)
|
||||||
|
app.include_router(files.router)
|
||||||
|
app.include_router(verbale.router)
|
||||||
|
app.include_router(custom_checks.router)
|
||||||
|
app.include_router(assignment.router)
|
||||||
|
app.include_router(internal.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", tags=["root"])
|
@app.get("/", tags=["root"])
|
||||||
def root():
|
def root():
|
||||||
return {
|
return {
|
||||||
"service": "rendicontazione-api",
|
"service": "rendicontazione-api",
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"health": "/health",
|
"health": "/health",
|
||||||
}
|
}
|
||||||
|
|||||||
194
app/migrations.py
Normal file
194
app/migrations.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
Migration idempotente per sandbox.
|
||||||
|
Base.metadata.create_all() crea solo tabelle mancanti, non colonne aggiuntive.
|
||||||
|
Qui gestiamo ALTER TABLE ADD COLUMN IF NOT EXISTS per l'evoluzione dello schema.
|
||||||
|
|
||||||
|
Ogni migration è una stringa SQL che puo essere eseguita piu volte senza errori.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("rendicontazione-api.migrations")
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATIONS = [
|
||||||
|
# 2026-04-18: colonne file upload su remission_invoice
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_invoice
|
||||||
|
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS mime varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS size_bytes bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_by integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
|
||||||
|
""",
|
||||||
|
# 2026-04-18: colonne file upload su remission_ula_employee
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_ula_employee
|
||||||
|
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS mime varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS size_bytes bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_by integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_at timestamptz;
|
||||||
|
""",
|
||||||
|
# 2026-04-18: colonne file upload su remission_document
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_document
|
||||||
|
ADD COLUMN IF NOT EXISTS storage_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS mime varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS size_bytes bigint,
|
||||||
|
ADD COLUMN IF NOT EXISTS sha256 varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS uploaded_by integer;
|
||||||
|
""",
|
||||||
|
# 2026-04-18 v2: multi-tranche su remission_practice
|
||||||
|
# DROP UNIQUE su application_id (permette piu tranche per stessa domanda)
|
||||||
|
# aggiunge sequence_number, period_label, suggested_instructor_id
|
||||||
|
# nuova UNIQUE (application_id, sequence_number)
|
||||||
|
# partial index su assigned_instructor_id IS NULL per coda "da assegnare"
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_practice
|
||||||
|
DROP CONSTRAINT IF EXISTS uq_remission_practice_application;
|
||||||
|
ALTER TABLE gepafin_rendic.remission_practice
|
||||||
|
ADD COLUMN IF NOT EXISTS sequence_number integer NOT NULL DEFAULT 1,
|
||||||
|
ADD COLUMN IF NOT EXISTS period_label varchar(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS suggested_instructor_id integer;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'uq_remission_practice_app_seq'
|
||||||
|
AND conrelid = 'gepafin_rendic.remission_practice'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE gepafin_rendic.remission_practice
|
||||||
|
ADD CONSTRAINT uq_remission_practice_app_seq UNIQUE (application_id, sequence_number);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_remission_practice_unassigned
|
||||||
|
ON gepafin_rendic.remission_practice(assigned_instructor_id)
|
||||||
|
WHERE assigned_instructor_id IS NULL;
|
||||||
|
""",
|
||||||
|
# 2026-04-20: link documento a company_document del BE Gepafin (riutilizzo dal repository)
|
||||||
|
# Se source_company_document_id e valorizzato, il documento e selezionato dal repository
|
||||||
|
# company (gepafin_schema.company_document). Lo status/scadenza del sorgente governa
|
||||||
|
# semaforo UI e gate submit (documenti EXPIRED bloccano la trasmissione).
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_document
|
||||||
|
ADD COLUMN IF NOT EXISTS source_company_document_id integer;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_remission_document_source
|
||||||
|
ON gepafin_rendic.remission_document(source_company_document_id)
|
||||||
|
WHERE source_company_document_id IS NOT NULL;
|
||||||
|
""",
|
||||||
|
# 2026-04-18 v2: tabella custom checks
|
||||||
|
# allineata allo storage adapter esistente (storage_path + mime + size + sha256)
|
||||||
|
# NON segue le specs RAG p1 che usavano document_filename (v1 obsoleta)
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS gepafin_rendic.remission_custom_check_value (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
practice_id uuid NOT NULL REFERENCES gepafin_rendic.remission_practice(id) ON DELETE CASCADE,
|
||||||
|
check_code varchar(64) NOT NULL,
|
||||||
|
beneficiary_declared boolean NOT NULL DEFAULT false,
|
||||||
|
declared_at timestamptz,
|
||||||
|
storage_path varchar(1024),
|
||||||
|
mime varchar(128),
|
||||||
|
size_bytes bigint,
|
||||||
|
sha256 varchar(64),
|
||||||
|
document_uploaded_at timestamptz,
|
||||||
|
uploaded_by integer,
|
||||||
|
verification_status varchar(20) NOT NULL DEFAULT 'PENDING',
|
||||||
|
verification_notes text,
|
||||||
|
verified_by integer,
|
||||||
|
verified_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_custom_check_practice_code UNIQUE (practice_id, check_code)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_check_practice
|
||||||
|
ON gepafin_rendic.remission_custom_check_value(practice_id);
|
||||||
|
""",
|
||||||
|
# 2026-04-20 v3: soccorso istruttorio speculare al BE Gepafin
|
||||||
|
# - stato DRAFT (istruttore prepara, non ancora inviato)
|
||||||
|
# - response_days + extended_days + extension_date (prolunghe)
|
||||||
|
# - internal_note (visibile solo istruttore, separata da request_text)
|
||||||
|
# - amendment_document_* (allegato istruttore al soccorso, firmato e no)
|
||||||
|
# - response_document_* (upload risposta beneficiario)
|
||||||
|
# - protocol_id + email_log_id + user_action_id (popolati dal BE via mark-pec-sent)
|
||||||
|
# - pec_sent_at + pec_failed_reason + pec_retry_after (tracking PEC asincrono)
|
||||||
|
# Lato microservizio NON gestiamo PEC ne protocollo: il BE multi-tenant
|
||||||
|
# (gepafin_schema.hub id=1 PEC_SERVICE, id=2 MAILGUN_SERVICE) fa polling
|
||||||
|
# su endpoint /internal/remission-amendments e notifica via mark-pec-sent/failed.
|
||||||
|
"""
|
||||||
|
ALTER TABLE gepafin_rendic.remission_amendment_request
|
||||||
|
ADD COLUMN IF NOT EXISTS response_days integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS extended_days integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS extension_date timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS internal_note text,
|
||||||
|
ADD COLUMN IF NOT EXISTS amendment_document_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS amendment_document_type varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS amendment_initial_document_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS response_document_path varchar(1024),
|
||||||
|
ADD COLUMN IF NOT EXISTS response_document_type varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS protocol_id varchar(128),
|
||||||
|
ADD COLUMN IF NOT EXISTS email_log_id integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS user_action_id integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS pec_sent_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS pec_failed_reason text,
|
||||||
|
ADD COLUMN IF NOT EXISTS pec_retry_after timestamptz;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_amendment_status_pec
|
||||||
|
ON gepafin_rendic.remission_amendment_request(status)
|
||||||
|
WHERE status IN ('DRAFT','AWAITING');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_amendment_deadline
|
||||||
|
ON gepafin_rendic.remission_amendment_request(deadline)
|
||||||
|
WHERE status = 'AWAITING';
|
||||||
|
""",
|
||||||
|
# 2026-04-20 v4: tabella config reminder data-driven, speculare al BE
|
||||||
|
# (expiration_config type='AMENDMENT' interval_days=N). Permette righe multiple
|
||||||
|
# per triggerare reminder a N gg diversi dalla scadenza (es. 7gg + 2gg).
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS gepafin_rendic.remission_expiration_config (
|
||||||
|
id serial PRIMARY KEY,
|
||||||
|
type varchar(50) NOT NULL,
|
||||||
|
interval_days integer NOT NULL CHECK (interval_days > 0),
|
||||||
|
is_deleted boolean NOT NULL DEFAULT false,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expiration_config_type
|
||||||
|
ON gepafin_rendic.remission_expiration_config(type)
|
||||||
|
WHERE is_deleted = false;
|
||||||
|
""",
|
||||||
|
# 2026-04-20 v5: dedup duplicati (ON CONFLICT DO NOTHING non funzionava senza UNIQUE)
|
||||||
|
# + aggiungo UNIQUE constraint per prevenire futuri duplicati
|
||||||
|
"""
|
||||||
|
DELETE FROM gepafin_rendic.remission_expiration_config ec
|
||||||
|
USING gepafin_rendic.remission_expiration_config ec2
|
||||||
|
WHERE ec.id > ec2.id
|
||||||
|
AND ec.type = ec2.type
|
||||||
|
AND ec.interval_days = ec2.interval_days;
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'uq_expiration_config_type_days'
|
||||||
|
AND conrelid = 'gepafin_rendic.remission_expiration_config'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE gepafin_rendic.remission_expiration_config
|
||||||
|
ADD CONSTRAINT uq_expiration_config_type_days UNIQUE (type, interval_days);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
INSERT INTO gepafin_rendic.remission_expiration_config (type, interval_days)
|
||||||
|
VALUES ('AMENDMENT', 7), ('AMENDMENT', 2)
|
||||||
|
ON CONFLICT (type, interval_days) DO NOTHING;
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations(engine) -> None:
|
||||||
|
"""Esegue tutte le migration in transazione. Log su ciascuna."""
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for i, sql in enumerate(MIGRATIONS, 1):
|
||||||
|
try:
|
||||||
|
conn.execute(text(sql))
|
||||||
|
log.info(f"migration {i}/{len(MIGRATIONS)} OK")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"migration {i} FAILED: {e}")
|
||||||
|
raise
|
||||||
126
app/models.py
126
app/models.py
@@ -4,7 +4,7 @@ Schema: gepafin_rendic (stesso DB del BE Gepafin sandbox).
|
|||||||
"""
|
"""
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, UniqueConstraint, Numeric, Boolean, Date, BigInteger
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
@@ -43,13 +43,14 @@ class RemissionPractice(Base):
|
|||||||
"""
|
"""
|
||||||
__tablename__ = "remission_practice"
|
__tablename__ = "remission_practice"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("application_id", name="uq_remission_practice_application"),
|
UniqueConstraint("application_id", "sequence_number",
|
||||||
|
name="uq_remission_practice_app_seq"),
|
||||||
{"schema": "gepafin_rendic"},
|
{"schema": "gepafin_rendic"},
|
||||||
)
|
)
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
call_id = Column(Integer, nullable=False)
|
call_id = Column(Integer, nullable=False)
|
||||||
application_id = Column(Integer, nullable=False, unique=True)
|
application_id = Column(Integer, nullable=False) # unique (application_id, sequence_number)
|
||||||
company_id = Column(Integer, nullable=False)
|
company_id = Column(Integer, nullable=False)
|
||||||
user_id = Column(Integer, nullable=False) # beneficiario che compila
|
user_id = Column(Integer, nullable=False) # beneficiario che compila
|
||||||
|
|
||||||
@@ -61,6 +62,11 @@ class RemissionPractice(Base):
|
|||||||
amount_erogato = Column(Numeric(14, 2), nullable=False) # copiato da application.amount_accepted
|
amount_erogato = Column(Numeric(14, 2), nullable=False) # copiato da application.amount_accepted
|
||||||
notes_beneficiario = Column(Text, nullable=True)
|
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
|
# colonne istruttoria
|
||||||
assigned_instructor_id = Column(Integer, nullable=True)
|
assigned_instructor_id = Column(Integer, nullable=True)
|
||||||
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -80,6 +86,7 @@ class RemissionPractice(Base):
|
|||||||
ula_employees = relationship("RemissionUlaEmployee", 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")
|
documents = relationship("RemissionDocument", back_populates="practice", cascade="all, delete-orphan")
|
||||||
amendment_requests = relationship("RemissionAmendmentRequest", 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):
|
class RemissionInvoice(Base):
|
||||||
@@ -102,7 +109,15 @@ class RemissionInvoice(Base):
|
|||||||
taxable = Column(Numeric(14, 2), nullable=False) # imponibile
|
taxable = Column(Numeric(14, 2), nullable=False) # imponibile
|
||||||
vat = Column(Numeric(14, 2), nullable=False, default=0)
|
vat = Column(Numeric(14, 2), nullable=False, default=0)
|
||||||
total = Column(Numeric(14, 2), nullable=False)
|
total = Column(Numeric(14, 2), nullable=False)
|
||||||
pdf_filename = Column(String(512), nullable=True) # per ora solo nome, upload vero dopo
|
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)
|
# Campi istruttoria (dual declared/verified)
|
||||||
taxable_verified = Column(Numeric(14, 2), nullable=True)
|
taxable_verified = Column(Numeric(14, 2), nullable=True)
|
||||||
@@ -139,7 +154,15 @@ class RemissionUlaEmployee(Base):
|
|||||||
period_end_date = Column(Date, nullable=False)
|
period_end_date = Column(Date, nullable=False)
|
||||||
|
|
||||||
supporting_doc_type = Column(String(64), nullable=True)
|
supporting_doc_type = Column(String(64), nullable=True)
|
||||||
supporting_doc_filename = Column(String(512), 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
|
# Campi istruttoria
|
||||||
fte_pct_verified = Column(Numeric(5, 4), nullable=True)
|
fte_pct_verified = Column(Numeric(5, 4), nullable=True)
|
||||||
@@ -164,11 +187,23 @@ class RemissionDocument(Base):
|
|||||||
nullable=False)
|
nullable=False)
|
||||||
|
|
||||||
doc_code = Column(String(64), nullable=False) # DURC / VISURA_CAMERALE / ...
|
doc_code = Column(String(64), nullable=False) # DURC / VISURA_CAMERALE / ...
|
||||||
filename = Column(String(512), nullable=True)
|
filename = Column(String(512), nullable=True) # nome originale
|
||||||
uploaded_at = Column(DateTime(timezone=True), nullable=True)
|
uploaded_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
expires_at = Column(Date, nullable=True)
|
expires_at = Column(Date, nullable=True)
|
||||||
notes = Column(Text, 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)
|
||||||
|
|
||||||
|
# Link al repository documenti della company (gepafin_schema.company_document).
|
||||||
|
# Se valorizzato, il documento e stato selezionato dal picker repository invece
|
||||||
|
# che caricato dal PC. filename/expires_at vengono copiati al momento del link.
|
||||||
|
source_company_document_id = Column(Integer, nullable=True)
|
||||||
|
|
||||||
# Campi istruttoria
|
# Campi istruttoria
|
||||||
verification_status = Column(String(16), nullable=False, default="PENDING")
|
verification_status = Column(String(16), nullable=False, default="PENDING")
|
||||||
# PENDING | VALIDO | NON_VALIDO | SCADUTO
|
# PENDING | VALIDO | NON_VALIDO | SCADUTO
|
||||||
@@ -200,7 +235,86 @@ class RemissionAmendmentRequest(Base):
|
|||||||
closed_at = Column(DateTime(timezone=True), nullable=True)
|
closed_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
closed_by = Column(Integer, nullable=True)
|
closed_by = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# soccorso v3: extended/document/PEC tracking
|
||||||
|
response_days = Column(Integer, nullable=True)
|
||||||
|
extended_days = Column(Integer, nullable=True)
|
||||||
|
extension_date = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
internal_note = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
amendment_document_path = Column(String(1024), nullable=True)
|
||||||
|
amendment_document_type = Column(String(128), nullable=True)
|
||||||
|
amendment_initial_document_path = Column(String(1024), nullable=True)
|
||||||
|
|
||||||
|
response_document_path = Column(String(1024), nullable=True)
|
||||||
|
response_document_type = Column(String(128), nullable=True)
|
||||||
|
|
||||||
|
# popolati dal BE via endpoint interni mark-pec-sent / mark-pec-failed
|
||||||
|
protocol_id = Column(String(128), nullable=True)
|
||||||
|
email_log_id = Column(Integer, nullable=True)
|
||||||
|
user_action_id = Column(Integer, nullable=True)
|
||||||
|
pec_sent_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
pec_failed_reason = Column(Text, nullable=True)
|
||||||
|
pec_retry_after = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
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())
|
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
practice = relationship("RemissionPractice", back_populates="amendment_requests")
|
practice = relationship("RemissionPractice", back_populates="amendment_requests")
|
||||||
|
|
||||||
|
|
||||||
|
class RemissionExpirationConfig(Base):
|
||||||
|
"""Config data-driven per reminder scadenze amendment (speculare a BE Gepafin
|
||||||
|
expiration_config). Ogni riga con type='AMENDMENT' e interval_days=N triggera
|
||||||
|
un reminder esattamente N giorni prima della scadenza. Multipli row = multipli reminder."""
|
||||||
|
__tablename__ = "remission_expiration_config"
|
||||||
|
__table_args__ = ({"schema": "gepafin_rendic"},)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
type = Column(String(50), nullable=False)
|
||||||
|
interval_days = Column(Integer, nullable=False)
|
||||||
|
is_deleted = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|||||||
184
app/routers/assignment.py
Normal file
184
app/routers/assignment.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Endpoint v2 per gestione assegnazione istruttori (capo istruttore / manager).
|
||||||
|
Solo ROLE_INSTRUCTOR_MANAGER + ROLE_SUPER_ADMIN.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..auth import AuthUser, get_current_user
|
||||||
|
from ..models import RemissionPractice
|
||||||
|
from ..schemas import ApiResponse, PracticeReassignBody
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/remission-practices",
|
||||||
|
tags=["assignment-manager"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_manager(user: AuthUser = Depends(get_current_user)) -> AuthUser:
|
||||||
|
if user.role not in ("ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Richiesto ruolo manager o superadmin"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instructor-manager/assignments", response_model=ApiResponse)
|
||||||
|
def assignments_overview(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
manager: AuthUser = Depends(_require_manager),
|
||||||
|
):
|
||||||
|
"""Vista capo istruttore: pratiche con suggested + assigned + flag 'da assegnare'."""
|
||||||
|
# Vista manager: tutte le pratiche (incluso DRAFT in compilazione dal benef e
|
||||||
|
# APPROVED/REJECTED chiuse) perche il capo istruttore deve vedere tutto per
|
||||||
|
# riassegnare, monitorare carico, verificare storici.
|
||||||
|
practices = db.query(RemissionPractice).order_by(
|
||||||
|
RemissionPractice.application_id,
|
||||||
|
RemissionPractice.sequence_number
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Enrichment: nome istruttori + company
|
||||||
|
items = []
|
||||||
|
user_cache: dict = {}
|
||||||
|
|
||||||
|
def _user_name(uid: Optional[int]) -> Optional[str]:
|
||||||
|
if uid is None:
|
||||||
|
return None
|
||||||
|
if uid in user_cache:
|
||||||
|
return user_cache[uid]
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT first_name || ' ' || last_name as name, email
|
||||||
|
FROM gepafin_schema.gepafin_user WHERE id = :uid
|
||||||
|
"""), {"uid": uid}).mappings().first()
|
||||||
|
name = (row["name"] if row else None) or (row["email"] if row else None)
|
||||||
|
user_cache[uid] = name
|
||||||
|
return name
|
||||||
|
|
||||||
|
for p in practices:
|
||||||
|
company_row = db.execute(text("""
|
||||||
|
SELECT company_name, vat_number FROM gepafin_schema.company WHERE id = :cid
|
||||||
|
"""), {"cid": p.company_id}).mappings().first()
|
||||||
|
call_row = db.execute(text("""
|
||||||
|
SELECT name FROM gepafin_schema.call WHERE id = :cid
|
||||||
|
"""), {"cid": p.call_id}).mappings().first()
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": str(p.id),
|
||||||
|
"application_id": p.application_id,
|
||||||
|
"sequence_number": p.sequence_number,
|
||||||
|
"period_label": p.period_label,
|
||||||
|
"call_id": p.call_id,
|
||||||
|
"call_name": call_row["name"] if call_row else None,
|
||||||
|
"company_id": p.company_id,
|
||||||
|
"company_name": company_row["company_name"] if company_row else None,
|
||||||
|
"status": p.status,
|
||||||
|
"submitted_at": p.submitted_at.isoformat() if p.submitted_at else None,
|
||||||
|
"amount_erogato": float(p.amount_erogato or 0),
|
||||||
|
"suggested_instructor_id": p.suggested_instructor_id,
|
||||||
|
"suggested_instructor_name": _user_name(p.suggested_instructor_id),
|
||||||
|
"assigned_instructor_id": p.assigned_instructor_id,
|
||||||
|
"assigned_instructor_name": _user_name(p.assigned_instructor_id),
|
||||||
|
"is_unassigned": p.assigned_instructor_id is None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return ApiResponse(data={"assignments": items})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instructor-manager/instructors", response_model=ApiResponse)
|
||||||
|
def list_available_instructors(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
manager: AuthUser = Depends(_require_manager),
|
||||||
|
):
|
||||||
|
"""Elenco istruttori disponibili per riassegnazione (pre_instructor + manager ACTIVE)."""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT u.id, u.email, u.first_name, u.last_name, r.role_type
|
||||||
|
FROM gepafin_schema.gepafin_user u
|
||||||
|
JOIN gepafin_schema.role r ON r.id = u.role_id
|
||||||
|
WHERE u.is_deleted = false
|
||||||
|
AND r.role_type IN ('ROLE_PRE_INSTRUCTOR', 'ROLE_INSTRUCTOR_MANAGER')
|
||||||
|
ORDER BY u.last_name, u.first_name
|
||||||
|
""")).mappings().all()
|
||||||
|
return ApiResponse(data={"instructors": [
|
||||||
|
{
|
||||||
|
"user_id": r["id"],
|
||||||
|
"email": r["email"],
|
||||||
|
"first_name": r["first_name"],
|
||||||
|
"last_name": r["last_name"],
|
||||||
|
"role_type": r["role_type"],
|
||||||
|
"display_name": f"{r['first_name'] or ''} {r['last_name'] or ''}".strip() or r["email"],
|
||||||
|
} for r in rows
|
||||||
|
]})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instructor/{practice_id}/reassign", response_model=ApiResponse)
|
||||||
|
def reassign_instructor(
|
||||||
|
practice_id: UUID,
|
||||||
|
body: PracticeReassignBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
manager: AuthUser = Depends(_require_manager),
|
||||||
|
):
|
||||||
|
"""Manager assegna/riassegna la pratica a un istruttore diverso (o unassign se new_instructor_id=None).
|
||||||
|
Scrive audit entry in instructor_checklist.reassignment_log.
|
||||||
|
"""
|
||||||
|
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
|
||||||
|
old_instructor_id = p.assigned_instructor_id
|
||||||
|
|
||||||
|
# Verifica nuovo istruttore se specificato
|
||||||
|
if body.new_instructor_id is not None:
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT u.id, r.role_type
|
||||||
|
FROM gepafin_schema.gepafin_user u
|
||||||
|
JOIN gepafin_schema.role r ON r.id = u.role_id
|
||||||
|
WHERE u.id = :uid AND u.is_deleted = false
|
||||||
|
"""), {"uid": body.new_instructor_id}).mappings().first()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404,
|
||||||
|
detail=f"Istruttore {body.new_instructor_id} non trovato")
|
||||||
|
if row["role_type"] not in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER"):
|
||||||
|
raise HTTPException(status_code=422,
|
||||||
|
detail="Utente non ha ruolo istruttore")
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
checklist = dict(p.instructor_checklist or {})
|
||||||
|
log = list(checklist.get("reassignment_log") or [])
|
||||||
|
log.append({
|
||||||
|
"at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"by_user_id": manager.user_id,
|
||||||
|
"by_email": manager.email,
|
||||||
|
"from_instructor_id": old_instructor_id,
|
||||||
|
"to_instructor_id": body.new_instructor_id,
|
||||||
|
"reason": body.reassignment_reason,
|
||||||
|
})
|
||||||
|
checklist["reassignment_log"] = log
|
||||||
|
|
||||||
|
p.assigned_instructor_id = body.new_instructor_id
|
||||||
|
p.instructor_checklist = checklist
|
||||||
|
# Se passo da SUBMITTED + assegnato -> UNDER_REVIEW
|
||||||
|
# Altrimenti lascio status invariato (manager puo riassegnare anche durante review)
|
||||||
|
if p.status == "SUBMITTED" and body.new_instructor_id is not None:
|
||||||
|
p.status = "UNDER_REVIEW"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
|
||||||
|
action = "unassigned" if body.new_instructor_id is None else f"assigned to {body.new_instructor_id}"
|
||||||
|
return ApiResponse(
|
||||||
|
message=f"Pratica {action}",
|
||||||
|
data={
|
||||||
|
"id": str(p.id),
|
||||||
|
"status": p.status,
|
||||||
|
"assigned_instructor_id": p.assigned_instructor_id,
|
||||||
|
"suggested_instructor_id": p.suggested_instructor_id,
|
||||||
|
"reassignment_log": log,
|
||||||
|
}
|
||||||
|
)
|
||||||
319
app/routers/custom_checks.py
Normal file
319
app/routers/custom_checks.py
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
"""
|
||||||
|
Endpoint custom_checks v2: dichiarazione beneficiario + documento opzionale + verifica istruttore.
|
||||||
|
|
||||||
|
Merge definition (da schema_snapshot.custom_checks[]) + value (RemissionCustomCheckValue).
|
||||||
|
Path storage custom_checks: /var/uploads/custom_checks/{practice_id}/{code}/<sha12>-file.pdf
|
||||||
|
(fuori dal pattern invoice/ula/document per isolarli — non confondibili con allegati fattura/LUL).
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import List, Optional, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..auth import AuthUser, get_current_user
|
||||||
|
from ..models import RemissionPractice, RemissionCustomCheckValue
|
||||||
|
from ..schemas import ApiResponse, CustomCheckOut, CustomCheckVerifyBody
|
||||||
|
from ..storage import (
|
||||||
|
save_upload, delete_file, open_file,
|
||||||
|
FileTooLargeError, MimeNotAllowedError, StorageError, BASE_PATH,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/remission-practices", tags=["custom-checks"])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_instructor(user: AuthUser) -> bool:
|
||||||
|
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_practice(db: Session, practice_id: UUID, user: AuthUser) -> RemissionPractice:
|
||||||
|
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
||||||
|
# Autorizzazione base: beneficiario owner o istruttore
|
||||||
|
if user.is_owner_role() and p.user_id != user.user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Accesso negato")
|
||||||
|
if not user.is_owner_role() and not _is_instructor(user):
|
||||||
|
raise HTTPException(status_code=403, detail="Ruolo non autorizzato")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
|
"""Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT."""
|
||||||
|
if not user.is_owner_role():
|
||||||
|
return False
|
||||||
|
if practice.user_id != user.user_id:
|
||||||
|
return False
|
||||||
|
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_verify(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
|
if not _is_instructor(user):
|
||||||
|
return False
|
||||||
|
return practice.status in ("UNDER_REVIEW", "AWAITING_AMENDMENT")
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_check_defs(practice: RemissionPractice) -> List[dict]:
|
||||||
|
return practice.schema_snapshot.get("custom_checks") or []
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_check(definition: dict, value: Optional[RemissionCustomCheckValue]) -> dict:
|
||||||
|
out = {
|
||||||
|
"code": definition.get("code"),
|
||||||
|
"label": definition.get("label"),
|
||||||
|
"description": definition.get("description"),
|
||||||
|
"requires_document": bool(definition.get("requires_document", False)),
|
||||||
|
"required": bool(definition.get("required", False)),
|
||||||
|
# valori default
|
||||||
|
"beneficiary_declared": False,
|
||||||
|
"declared_at": None,
|
||||||
|
"filename_original": None,
|
||||||
|
"storage_path": None,
|
||||||
|
"size_bytes": None,
|
||||||
|
"document_uploaded_at": None,
|
||||||
|
"verification_status": "PENDING",
|
||||||
|
"verification_notes": None,
|
||||||
|
"verified_by": None,
|
||||||
|
"verified_at": None,
|
||||||
|
}
|
||||||
|
if value is not None:
|
||||||
|
out.update({
|
||||||
|
"beneficiary_declared": value.beneficiary_declared,
|
||||||
|
"declared_at": value.declared_at,
|
||||||
|
"storage_path": value.storage_path,
|
||||||
|
"size_bytes": value.size_bytes,
|
||||||
|
"document_uploaded_at": value.document_uploaded_at,
|
||||||
|
"verification_status": value.verification_status,
|
||||||
|
"verification_notes": value.verification_notes,
|
||||||
|
"verified_by": value.verified_by,
|
||||||
|
"verified_at": value.verified_at,
|
||||||
|
})
|
||||||
|
# filename originale ricostruito dal path (dopo il sha12-)
|
||||||
|
if value.storage_path:
|
||||||
|
basename = Path(value.storage_path).name
|
||||||
|
# formato: <sha12>-<original>
|
||||||
|
parts = basename.split("-", 1)
|
||||||
|
out["filename_original"] = parts[1] if len(parts) == 2 else basename
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_value(db: Session, practice_id: UUID, code: str) -> RemissionCustomCheckValue:
|
||||||
|
v = db.query(RemissionCustomCheckValue).filter(
|
||||||
|
RemissionCustomCheckValue.practice_id == practice_id,
|
||||||
|
RemissionCustomCheckValue.check_code == code,
|
||||||
|
).first()
|
||||||
|
if not v:
|
||||||
|
v = RemissionCustomCheckValue(practice_id=practice_id, check_code=code)
|
||||||
|
db.add(v)
|
||||||
|
db.flush()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- endpoints ----------
|
||||||
|
|
||||||
|
@router.get("/{practice_id}/custom-checks", response_model=ApiResponse)
|
||||||
|
def list_custom_checks(practice_id: UUID, db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user)):
|
||||||
|
"""Ritorna i custom_checks della pratica: schema definition + valori correnti."""
|
||||||
|
p = _get_practice(db, practice_id, user)
|
||||||
|
defs = _schema_check_defs(p)
|
||||||
|
values_by_code = {v.check_code: v for v in p.custom_checks}
|
||||||
|
out = [_merge_check(d, values_by_code.get(d.get("code"))) for d in defs]
|
||||||
|
return ApiResponse(data={"custom_checks": out})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{practice_id}/custom-checks/{code}/declare", response_model=ApiResponse)
|
||||||
|
async def declare_custom_check(
|
||||||
|
practice_id: UUID,
|
||||||
|
code: str,
|
||||||
|
beneficiary_declared: bool = Form(...),
|
||||||
|
file: Optional[UploadFile] = File(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Beneficiario dichiara il check (bool) e opzionalmente allega un documento.
|
||||||
|
Se requires_document=true nello schema, l'upload e raccomandato ma non imposto
|
||||||
|
lato server (la required-ness e un gate su /submit)."""
|
||||||
|
p = _get_practice(db, practice_id, user)
|
||||||
|
if not _can_declare(user, p):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Solo beneficiario owner su pratica DRAFT o AWAITING_AMENDMENT puo dichiarare"
|
||||||
|
)
|
||||||
|
|
||||||
|
defs = {d["code"]: d for d in _schema_check_defs(p)}
|
||||||
|
if code not in defs:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Custom check '{code}' non definito nello schema")
|
||||||
|
|
||||||
|
v = _get_or_create_value(db, p.id, code)
|
||||||
|
v.beneficiary_declared = bool(beneficiary_declared)
|
||||||
|
v.declared_at = datetime.now(timezone.utc) if beneficiary_declared else None
|
||||||
|
|
||||||
|
# Se arriva un file sostituisce l'eventuale esistente
|
||||||
|
if file is not None and file.filename:
|
||||||
|
try:
|
||||||
|
# path custom_checks/<practice_id>/<code>/<sha12>-<name> — sfrutto storage_adapter
|
||||||
|
# con entity_type "document" fittizio e un app_id = practice_id (sfrutto la dir)
|
||||||
|
# In alternativa faccio path custom scrivendolo direttamente qui.
|
||||||
|
# Scelgo via diretta per evitare collisione con document reale.
|
||||||
|
from hashlib import sha256
|
||||||
|
size = 0
|
||||||
|
hasher = sha256()
|
||||||
|
content = b""
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
content += chunk
|
||||||
|
size += len(chunk)
|
||||||
|
if size > 15 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=413, detail="File troppo grande (max 15 MB)")
|
||||||
|
hasher.update(chunk)
|
||||||
|
|
||||||
|
mime = (file.content_type or "").lower().split(";")[0].strip()
|
||||||
|
if mime not in ("application/pdf", "image/jpeg", "image/png"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail=f"MIME non consentito: {mime}. Accettati: pdf, jpeg, png"
|
||||||
|
)
|
||||||
|
|
||||||
|
digest = hasher.hexdigest()
|
||||||
|
# sanitize filename
|
||||||
|
safe = "".join(c if (c.isalnum() or c in "-_.() ") else "_" for c in file.filename).strip().replace(" ", "_")
|
||||||
|
if len(safe) > 120:
|
||||||
|
root, ext = os.path.splitext(safe)
|
||||||
|
safe = root[:120 - len(ext)] + ext
|
||||||
|
|
||||||
|
target_dir = BASE_PATH / "custom_checks" / str(p.id) / code
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
final_path = target_dir / f"{digest[:12]}-{safe}"
|
||||||
|
final_path.write_bytes(content)
|
||||||
|
|
||||||
|
# Rimuovi eventuale file precedente (path diverso)
|
||||||
|
if v.storage_path and Path(BASE_PATH / v.storage_path) != final_path:
|
||||||
|
try:
|
||||||
|
delete_file(v.storage_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
v.storage_path = str(final_path.relative_to(BASE_PATH))
|
||||||
|
v.mime = mime
|
||||||
|
v.size_bytes = size
|
||||||
|
v.sha256 = digest
|
||||||
|
v.document_uploaded_at = datetime.now(timezone.utc)
|
||||||
|
v.uploaded_by = user.user_id
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Errore upload: {e}")
|
||||||
|
|
||||||
|
# Reset eventuale validazione precedente (beneficiario ha cambiato qualcosa)
|
||||||
|
v.verification_status = "PENDING"
|
||||||
|
v.verification_notes = None
|
||||||
|
v.verified_by = None
|
||||||
|
v.verified_at = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(v)
|
||||||
|
|
||||||
|
defs_by_code = {d["code"]: d for d in _schema_check_defs(p)}
|
||||||
|
return ApiResponse(message="Check aggiornato", data=_merge_check(defs_by_code[code], v))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{practice_id}/custom-checks/{code}/document", response_model=ApiResponse)
|
||||||
|
def delete_custom_check_document(
|
||||||
|
practice_id: UUID, code: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Beneficiario rimuove il documento allegato (dichiarazione resta)."""
|
||||||
|
p = _get_practice(db, practice_id, user)
|
||||||
|
if not _can_declare(user, p):
|
||||||
|
raise HTTPException(status_code=403, detail="Non autorizzato")
|
||||||
|
v = db.query(RemissionCustomCheckValue).filter(
|
||||||
|
RemissionCustomCheckValue.practice_id == practice_id,
|
||||||
|
RemissionCustomCheckValue.check_code == code,
|
||||||
|
).first()
|
||||||
|
if not v or not v.storage_path:
|
||||||
|
return ApiResponse(message="Nessun documento da rimuovere")
|
||||||
|
|
||||||
|
try:
|
||||||
|
delete_file(v.storage_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
v.storage_path = None
|
||||||
|
v.mime = None
|
||||||
|
v.size_bytes = None
|
||||||
|
v.sha256 = None
|
||||||
|
v.document_uploaded_at = None
|
||||||
|
v.verification_status = "PENDING" # reset verify
|
||||||
|
v.verification_notes = None
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(message="Documento rimosso")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{practice_id}/custom-checks/{code}/verify", response_model=ApiResponse)
|
||||||
|
def verify_custom_check(
|
||||||
|
practice_id: UUID, code: str,
|
||||||
|
body: CustomCheckVerifyBody,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Istruttore valida il check (VALIDO | NON_VALIDO | PENDING)."""
|
||||||
|
p = _get_practice(db, practice_id, user)
|
||||||
|
if not _can_verify(user, p):
|
||||||
|
raise HTTPException(status_code=403, detail="Solo istruttore su pratica in lavorazione")
|
||||||
|
if body.verification_status not in ("PENDING", "VALIDO", "NON_VALIDO"):
|
||||||
|
raise HTTPException(status_code=422, detail="verification_status non valido")
|
||||||
|
|
||||||
|
defs = {d["code"]: d for d in _schema_check_defs(p)}
|
||||||
|
if code not in defs:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Check '{code}' non nello schema")
|
||||||
|
|
||||||
|
v = _get_or_create_value(db, p.id, code)
|
||||||
|
v.verification_status = body.verification_status
|
||||||
|
v.verification_notes = body.verification_notes
|
||||||
|
v.verified_by = user.user_id
|
||||||
|
v.verified_at = datetime.now(timezone.utc) if body.verification_status != "PENDING" else None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(v)
|
||||||
|
return ApiResponse(message="Check verificato", data=_merge_check(defs[code], v))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{practice_id}/custom-checks/{code}/document")
|
||||||
|
def download_custom_check_document(
|
||||||
|
practice_id: UUID, code: str,
|
||||||
|
inline: int = 0,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Download del documento allegato (stream con Content-Disposition)."""
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
p = _get_practice(db, practice_id, user)
|
||||||
|
v = db.query(RemissionCustomCheckValue).filter(
|
||||||
|
RemissionCustomCheckValue.practice_id == practice_id,
|
||||||
|
RemissionCustomCheckValue.check_code == code,
|
||||||
|
).first()
|
||||||
|
if not v or not v.storage_path:
|
||||||
|
raise HTTPException(status_code=404, detail="Nessun documento allegato")
|
||||||
|
|
||||||
|
try:
|
||||||
|
abs_path = open_file(v.storage_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=410, detail="File non piu disponibile su storage")
|
||||||
|
|
||||||
|
basename = Path(v.storage_path).name
|
||||||
|
parts = basename.split("-", 1)
|
||||||
|
filename = parts[1] if len(parts) == 2 else basename
|
||||||
|
|
||||||
|
disp = "inline" if inline else "attachment"
|
||||||
|
return FileResponse(
|
||||||
|
path=str(abs_path),
|
||||||
|
media_type=v.mime or "application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'{disp}; filename="{filename}"'},
|
||||||
|
)
|
||||||
365
app/routers/files.py
Normal file
365
app/routers/files.py
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
"""
|
||||||
|
Endpoint file upload/download/delete per fatture, ULA, documenti.
|
||||||
|
Storage: FS locale via app.storage.
|
||||||
|
Autorizzazione:
|
||||||
|
- UPLOAD: beneficiario su sua pratica in DRAFT/AWAITING_AMENDMENT, o istruttore
|
||||||
|
- DOWNLOAD: beneficiario su sua pratica, istruttore, superadmin
|
||||||
|
- DELETE: solo beneficiario su DRAFT/AWAITING_AMENDMENT
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from fastapi.responses import FileResponse, Response
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..auth import AuthUser, get_current_user
|
||||||
|
from ..models import (
|
||||||
|
RemissionPractice, RemissionInvoice, RemissionUlaEmployee, RemissionDocument
|
||||||
|
)
|
||||||
|
from ..storage import (
|
||||||
|
save_upload, delete_file, open_file,
|
||||||
|
FileTooLargeError, MimeNotAllowedError, StorageError
|
||||||
|
)
|
||||||
|
from ..schemas import ApiResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/remission-files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_instructor(user: AuthUser) -> bool:
|
||||||
|
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
|
"""Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore."""
|
||||||
|
if _is_instructor(user):
|
||||||
|
return True
|
||||||
|
if user.is_owner_role() and practice.user_id == user.user_id:
|
||||||
|
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
|
if _is_instructor(user):
|
||||||
|
return True
|
||||||
|
if user.is_owner_role() and practice.user_id == user.user_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||||
|
"""Solo beneficiario su pratica modificabile. Istruttore non elimina file."""
|
||||||
|
if user.is_owner_role() and practice.user_id == user.user_id:
|
||||||
|
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||||
|
if user.is_superadmin():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _load_entity(db: Session, entity_type: str, entity_id: UUID):
|
||||||
|
if entity_type == "invoice":
|
||||||
|
return db.query(RemissionInvoice).filter(RemissionInvoice.id == entity_id).first()
|
||||||
|
if entity_type == "ula":
|
||||||
|
return db.query(RemissionUlaEmployee).filter(RemissionUlaEmployee.id == entity_id).first()
|
||||||
|
if entity_type == "document":
|
||||||
|
return db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first()
|
||||||
|
raise HTTPException(status_code=400, detail=f"entity_type non valido: {entity_type}")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_file_meta(entity, entity_type: str) -> dict:
|
||||||
|
"""Estrae metadata file dall'entita (invoice/ula/document)."""
|
||||||
|
filename_field = {
|
||||||
|
"invoice": "pdf_filename",
|
||||||
|
"ula": "supporting_doc_filename",
|
||||||
|
"document": "filename",
|
||||||
|
}[entity_type]
|
||||||
|
|
||||||
|
has_file = bool(getattr(entity, "storage_path", None))
|
||||||
|
return {
|
||||||
|
"has_file": has_file,
|
||||||
|
"filename_original": getattr(entity, filename_field, None),
|
||||||
|
"storage_path": entity.storage_path if has_file else None,
|
||||||
|
"mime": entity.mime,
|
||||||
|
"size_bytes": entity.size_bytes,
|
||||||
|
"sha256": entity.sha256,
|
||||||
|
"uploaded_at": entity.uploaded_at.isoformat() if getattr(entity, "uploaded_at", None) else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{entity_type}/{entity_id}/upload", response_model=ApiResponse)
|
||||||
|
async def upload_entity_file(
|
||||||
|
entity_type: Literal["invoice", "ula", "document"],
|
||||||
|
entity_id: UUID,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload file per una entita (invoice/ula/document).
|
||||||
|
Se l'entita ha gia un file, lo sostituisce (vecchio file eliminato da FS).
|
||||||
|
"""
|
||||||
|
entity = _load_entity(db, entity_type, entity_id)
|
||||||
|
if not entity:
|
||||||
|
raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata")
|
||||||
|
|
||||||
|
# Pratica di riferimento
|
||||||
|
practice = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.id == entity.practice_id
|
||||||
|
).first()
|
||||||
|
if not practice:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
|
||||||
|
if not _can_upload(user, practice):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Non autorizzato a caricare file su questa pratica"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Salva su FS
|
||||||
|
try:
|
||||||
|
rel_path, size, digest, mime, safe_name = save_upload(
|
||||||
|
application_id=practice.application_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity.id,
|
||||||
|
file_obj=file.file,
|
||||||
|
original_filename=file.filename or "file.bin",
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
except FileTooLargeError as e:
|
||||||
|
raise HTTPException(status_code=413, detail=str(e))
|
||||||
|
except MimeNotAllowedError as e:
|
||||||
|
raise HTTPException(status_code=415, detail=str(e))
|
||||||
|
except StorageError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Errore storage: {e}")
|
||||||
|
|
||||||
|
# Rimuovi eventuale file precedente su FS se diverso
|
||||||
|
old_path = entity.storage_path
|
||||||
|
if old_path and old_path != rel_path:
|
||||||
|
try:
|
||||||
|
delete_file(old_path)
|
||||||
|
except Exception:
|
||||||
|
pass # non blocchiamo l'upload per cleanup fallito
|
||||||
|
|
||||||
|
# Aggiorna metadata
|
||||||
|
entity.storage_path = rel_path
|
||||||
|
entity.mime = mime
|
||||||
|
entity.size_bytes = size
|
||||||
|
entity.sha256 = digest
|
||||||
|
entity.uploaded_by = user.user_id
|
||||||
|
if hasattr(entity, "uploaded_at"):
|
||||||
|
entity.uploaded_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Aggiorna filename originale a seconda del tipo
|
||||||
|
if entity_type == "invoice":
|
||||||
|
entity.pdf_filename = safe_name
|
||||||
|
elif entity_type == "ula":
|
||||||
|
entity.supporting_doc_filename = safe_name
|
||||||
|
elif entity_type == "document":
|
||||||
|
entity.filename = safe_name
|
||||||
|
# document ha gia uploaded_at proprio campo
|
||||||
|
entity.uploaded_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entity)
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
success=True,
|
||||||
|
data=_serialize_file_meta(entity, entity_type),
|
||||||
|
message="File caricato",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{entity_type}/{entity_id}")
|
||||||
|
def download_entity_file(
|
||||||
|
entity_type: Literal["invoice", "ula", "document"],
|
||||||
|
entity_id: UUID,
|
||||||
|
request: Request,
|
||||||
|
inline: int = 0,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Download (inline=0) o preview (inline=1) del file.
|
||||||
|
Ritorna il file con header appropriati.
|
||||||
|
"""
|
||||||
|
entity = _load_entity(db, entity_type, entity_id)
|
||||||
|
if not entity or not entity.storage_path:
|
||||||
|
raise HTTPException(status_code=404, detail="File non presente")
|
||||||
|
|
||||||
|
practice = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.id == entity.practice_id
|
||||||
|
).first()
|
||||||
|
if not practice:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
|
||||||
|
if not _can_download(user, practice):
|
||||||
|
raise HTTPException(status_code=403, detail="Non autorizzato")
|
||||||
|
|
||||||
|
try:
|
||||||
|
abs_path = open_file(entity.storage_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=410, detail="File non piu disponibile su storage")
|
||||||
|
|
||||||
|
filename = None
|
||||||
|
if entity_type == "invoice":
|
||||||
|
filename = entity.pdf_filename
|
||||||
|
elif entity_type == "ula":
|
||||||
|
filename = entity.supporting_doc_filename
|
||||||
|
elif entity_type == "document":
|
||||||
|
filename = entity.filename
|
||||||
|
|
||||||
|
disposition = "inline" if inline else "attachment"
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f'{disposition}; filename="{filename or "file"}"',
|
||||||
|
}
|
||||||
|
return FileResponse(
|
||||||
|
path=str(abs_path),
|
||||||
|
media_type=entity.mime or "application/octet-stream",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{entity_type}/{entity_id}", response_model=ApiResponse)
|
||||||
|
def delete_entity_file(
|
||||||
|
entity_type: Literal["invoice", "ula", "document"],
|
||||||
|
entity_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Elimina il file allegato. Entita resta, metadata file azzerati."""
|
||||||
|
entity = _load_entity(db, entity_type, entity_id)
|
||||||
|
if not entity:
|
||||||
|
raise HTTPException(status_code=404, detail=f"{entity_type} {entity_id} non trovata")
|
||||||
|
|
||||||
|
practice = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.id == entity.practice_id
|
||||||
|
).first()
|
||||||
|
if not practice:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
|
||||||
|
if not _can_delete(user, practice):
|
||||||
|
raise HTTPException(status_code=403, detail="Non autorizzato")
|
||||||
|
|
||||||
|
if not entity.storage_path:
|
||||||
|
return ApiResponse(success=True, message="Nessun file da eliminare")
|
||||||
|
|
||||||
|
try:
|
||||||
|
delete_file(entity.storage_path)
|
||||||
|
except Exception:
|
||||||
|
pass # rimuoviamo comunque i metadati
|
||||||
|
|
||||||
|
entity.storage_path = None
|
||||||
|
entity.mime = None
|
||||||
|
entity.size_bytes = None
|
||||||
|
entity.sha256 = None
|
||||||
|
entity.uploaded_by = None
|
||||||
|
if hasattr(entity, "uploaded_at"):
|
||||||
|
entity.uploaded_at = None
|
||||||
|
|
||||||
|
if entity_type == "invoice":
|
||||||
|
entity.pdf_filename = None
|
||||||
|
elif entity_type == "ula":
|
||||||
|
entity.supporting_doc_filename = None
|
||||||
|
elif entity_type == "document":
|
||||||
|
entity.filename = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(success=True, message="File eliminato")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Link da repository company ----------
|
||||||
|
# 2026-04-20: riutilizzo documenti caricati in fase domanda.
|
||||||
|
# Il benef seleziona un documento dal proprio repository company invece di caricarlo
|
||||||
|
# dal PC. Non c'e upload fisico: copiamo solo i metadati (filename, expires_at,
|
||||||
|
# storage_path per preview/download) e tracciamo source_company_document_id per
|
||||||
|
# permettere lookup live dello status sorgente (VALID/DUE/EXPIRED).
|
||||||
|
class LinkFromRepositoryRequest(BaseModel):
|
||||||
|
company_document_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/document/{entity_id}/link-from-repository", response_model=ApiResponse)
|
||||||
|
def link_document_from_repository(
|
||||||
|
entity_id: UUID,
|
||||||
|
body: LinkFromRepositoryRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Associa un remission_document esistente a un company_document del repository
|
||||||
|
della fase domanda. Sostituisce eventuali file precedenti caricati dal PC
|
||||||
|
(elimina dallo storage, azzera storage_path).
|
||||||
|
"""
|
||||||
|
# 1) carica il remission_document e verifica permesso upload (benef owner o admin)
|
||||||
|
entity = db.query(RemissionDocument).filter(RemissionDocument.id == entity_id).first()
|
||||||
|
if not entity:
|
||||||
|
raise HTTPException(status_code=404, detail="Documento non trovato")
|
||||||
|
|
||||||
|
practice = db.query(RemissionPractice).filter(RemissionPractice.id == entity.practice_id).first()
|
||||||
|
if not practice:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
if not _can_upload(user, practice):
|
||||||
|
raise HTTPException(status_code=403, detail="Non autorizzato")
|
||||||
|
|
||||||
|
# pratica editabile solo in DRAFT (stessa regola dell'upload)
|
||||||
|
if practice.status != "DRAFT":
|
||||||
|
raise HTTPException(status_code=409, detail=f"Pratica in stato {practice.status}: non modificabile")
|
||||||
|
|
||||||
|
# 2) leggi il company_document (deve esistere, stessa company della pratica, non eliminato)
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT id, file_name, file_path, type, status, expiration_date, company_id
|
||||||
|
FROM gepafin_schema.company_document
|
||||||
|
WHERE id = :cid AND is_deleted = false
|
||||||
|
"""), {"cid": body.company_document_id}).mappings().first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail=f"company_document {body.company_document_id} non trovato")
|
||||||
|
|
||||||
|
if row["company_id"] != practice.company_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Documento repository non appartiene alla company di questa pratica"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) se c'era un file fisico caricato dal PC in precedenza, lo rimuoviamo per pulizia
|
||||||
|
if entity.storage_path and not entity.source_company_document_id:
|
||||||
|
try:
|
||||||
|
delete_file(entity.storage_path)
|
||||||
|
except Exception:
|
||||||
|
pass # non bloccare se il file non c'e piu
|
||||||
|
|
||||||
|
# 4) aggiorna metadati con quelli del repository
|
||||||
|
from datetime import datetime, timezone, date
|
||||||
|
entity.source_company_document_id = row["id"]
|
||||||
|
entity.filename = row["file_name"]
|
||||||
|
entity.storage_path = row["file_path"] # riuso del path fisico del BE per preview/download
|
||||||
|
entity.mime = None
|
||||||
|
entity.size_bytes = None
|
||||||
|
entity.sha256 = None
|
||||||
|
entity.uploaded_by = user.user_id
|
||||||
|
entity.uploaded_at = datetime.now(timezone.utc)
|
||||||
|
# scadenza dal sorgente (timestamp -> date)
|
||||||
|
exp = row["expiration_date"]
|
||||||
|
if exp is not None:
|
||||||
|
entity.expires_at = exp.date() if hasattr(exp, 'date') else exp
|
||||||
|
else:
|
||||||
|
entity.expires_at = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(entity)
|
||||||
|
|
||||||
|
return ApiResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"Documento collegato dal repository (source_status={row['status']})",
|
||||||
|
data={
|
||||||
|
"id": str(entity.id),
|
||||||
|
"doc_code": entity.doc_code,
|
||||||
|
"filename": entity.filename,
|
||||||
|
"source_company_document_id": entity.source_company_document_id,
|
||||||
|
"expires_at": entity.expires_at.isoformat() if entity.expires_at else None,
|
||||||
|
"source_status": row["status"], # VALID | DUE | EXPIRED — per UI semaforo
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -6,15 +6,16 @@ from decimal import Decimal
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text, or_, and_
|
from sqlalchemy import text, or_, and_
|
||||||
|
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..auth import AuthUser, get_current_user
|
from ..auth import AuthUser, get_current_user
|
||||||
|
from ..storage import save_upload, FileTooLargeError, MimeNotAllowedError, StorageError
|
||||||
from ..models import RemissionPractice, RemissionAmendmentRequest
|
from ..models import RemissionPractice, RemissionAmendmentRequest
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
AmendmentRequestCreate, AmendmentRequestOut, AmendmentResponseSubmit,
|
AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus,
|
||||||
ReviewApproveBody, ReviewRejectBody,
|
ReviewApproveBody, ReviewRejectBody,
|
||||||
InstructorQueueItem, PracticeOut, ApiResponse,
|
InstructorQueueItem, PracticeOut, ApiResponse,
|
||||||
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
|
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
|
||||||
@@ -64,7 +65,7 @@ def _enrich_queue_item(db: Session, p: RemissionPractice) -> InstructorQueueItem
|
|||||||
|
|
||||||
# calcolo remissione due dalla schema_snapshot
|
# calcolo remissione due dalla schema_snapshot
|
||||||
try:
|
try:
|
||||||
check = _compute_gate_check(p)
|
check = _compute_gate_check(db, p)
|
||||||
item.remission_due = check.totals.get("remission_due", 0)
|
item.remission_due = check.totals.get("remission_due", 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
item.remission_due = None
|
item.remission_due = None
|
||||||
@@ -88,8 +89,14 @@ def instructor_queue(db: Session = Depends(get_db), user: AuthUser = Depends(_re
|
|||||||
)
|
)
|
||||||
if not manager:
|
if not manager:
|
||||||
# solo: SUBMITTED non assegnate OR UNDER_REVIEW assegnate a me OR AWAITING_AMENDMENT assegnate a me
|
# solo: SUBMITTED non assegnate OR UNDER_REVIEW assegnate a me OR AWAITING_AMENDMENT assegnate a me
|
||||||
|
# Un istruttore vede in coda:
|
||||||
|
# - SUBMITTED non assegnate (pool da prendere in carico)
|
||||||
|
# - SUBMITTED pre-assegnate a lui (suggested da gepafin_schema.assigned_applications)
|
||||||
|
# - UNDER_REVIEW in lavorazione a lui
|
||||||
|
# - AWAITING_AMENDMENT in attesa di risposta beneficiario
|
||||||
q = q.filter(or_(
|
q = q.filter(or_(
|
||||||
and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id.is_(None)),
|
and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id.is_(None)),
|
||||||
|
and_(RemissionPractice.status == "SUBMITTED", RemissionPractice.assigned_instructor_id == user.user_id),
|
||||||
and_(RemissionPractice.status == "UNDER_REVIEW", RemissionPractice.assigned_instructor_id == user.user_id),
|
and_(RemissionPractice.status == "UNDER_REVIEW", RemissionPractice.assigned_instructor_id == user.user_id),
|
||||||
and_(RemissionPractice.status == "AWAITING_AMENDMENT", RemissionPractice.assigned_instructor_id == user.user_id),
|
and_(RemissionPractice.status == "AWAITING_AMENDMENT", RemissionPractice.assigned_instructor_id == user.user_id),
|
||||||
))
|
))
|
||||||
@@ -109,7 +116,7 @@ def instructor_view_practice(practice_id: UUID, db: Session = Depends(get_db),
|
|||||||
"""Vista completa della pratica per istruttore (readonly + gate check + amendments)."""
|
"""Vista completa della pratica per istruttore (readonly + gate check + amendments)."""
|
||||||
p = _get_practice_or_404(db, practice_id)
|
p = _get_practice_or_404(db, practice_id)
|
||||||
|
|
||||||
check = _compute_gate_check(p)
|
check = _compute_gate_check(db, p)
|
||||||
amendments = [AmendmentRequestOut.model_validate(a).model_dump(mode="json") for a in p.amendment_requests]
|
amendments = [AmendmentRequestOut.model_validate(a).model_dump(mode="json") for a in p.amendment_requests]
|
||||||
|
|
||||||
return ApiResponse(data={
|
return ApiResponse(data={
|
||||||
@@ -152,7 +159,7 @@ def approve_practice(practice_id: UUID, body: ReviewApproveBody,
|
|||||||
if body.approved_remission is not None:
|
if body.approved_remission is not None:
|
||||||
p.approved_remission = body.approved_remission
|
p.approved_remission = body.approved_remission
|
||||||
else:
|
else:
|
||||||
check = _compute_gate_check(p)
|
check = _compute_gate_check(db, p)
|
||||||
p.approved_remission = Decimal(str(check.totals.get("remission_due", 0)))
|
p.approved_remission = Decimal(str(check.totals.get("remission_due", 0)))
|
||||||
|
|
||||||
p.status = "APPROVED"
|
p.status = "APPROVED"
|
||||||
@@ -189,10 +196,29 @@ def reject_practice(practice_id: UUID, body: ReviewRejectBody,
|
|||||||
|
|
||||||
# ========== SOCCORSO ISTRUTTORIO ==========
|
# ========== SOCCORSO ISTRUTTORIO ==========
|
||||||
|
|
||||||
|
DEFAULT_RESPONSE_DAYS = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _amendment_or_404(db: Session, practice_id: UUID, amendment_id: UUID) -> RemissionAmendmentRequest:
|
||||||
|
ar = db.query(RemissionAmendmentRequest).filter(
|
||||||
|
RemissionAmendmentRequest.id == amendment_id,
|
||||||
|
RemissionAmendmentRequest.practice_id == practice_id
|
||||||
|
).first()
|
||||||
|
if not ar:
|
||||||
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
||||||
|
return ar
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{practice_id}/amendment", response_model=ApiResponse)
|
@router.post("/{practice_id}/amendment", response_model=ApiResponse)
|
||||||
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
||||||
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
"""Crea una richiesta di soccorso istruttorio."""
|
"""Crea una richiesta di soccorso istruttorio in stato DRAFT.
|
||||||
|
|
||||||
|
La PEC parte solo quando l'istruttore chiama esplicitamente /send. Finche e DRAFT:
|
||||||
|
- l'istruttore puo modificare/eliminare
|
||||||
|
- la pratica resta UNDER_REVIEW (nessun impatto sul benef)
|
||||||
|
- nessuna notifica PEC
|
||||||
|
"""
|
||||||
p = _get_practice_or_404(db, practice_id)
|
p = _get_practice_or_404(db, practice_id)
|
||||||
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
|
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
@@ -200,11 +226,13 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
|||||||
if not body.request_text or len(body.request_text.strip()) < 10:
|
if not body.request_text or len(body.request_text.strip()) < 10:
|
||||||
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
||||||
|
|
||||||
# controllo: non ci deve essere già una amendment AWAITING aperta
|
# controllo: non ci deve essere gia una amendment non-CLOSED/non-EXPIRED aperta
|
||||||
open_ar = [a for a in p.amendment_requests if a.status == "AWAITING"]
|
open_ar = [a for a in p.amendment_requests
|
||||||
|
if a.status in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value,
|
||||||
|
AmendmentStatus.RESPONSE_RECEIVED.value)]
|
||||||
if open_ar:
|
if open_ar:
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
detail="C'è già una richiesta di soccorso aperta su questa pratica")
|
detail="C'e gia una richiesta di soccorso aperta su questa pratica")
|
||||||
|
|
||||||
ar = RemissionAmendmentRequest(
|
ar = RemissionAmendmentRequest(
|
||||||
practice_id=p.id,
|
practice_id=p.id,
|
||||||
@@ -212,37 +240,142 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
|||||||
request_text=body.request_text,
|
request_text=body.request_text,
|
||||||
deadline=body.deadline,
|
deadline=body.deadline,
|
||||||
scope=body.scope or {},
|
scope=body.scope or {},
|
||||||
status="AWAITING"
|
response_days=body.response_days if body.response_days is not None else DEFAULT_RESPONSE_DAYS,
|
||||||
|
internal_note=body.internal_note,
|
||||||
|
status=AmendmentStatus.DRAFT.value
|
||||||
)
|
)
|
||||||
db.add(ar)
|
db.add(ar)
|
||||||
|
# pratica resta UNDER_REVIEW in DRAFT (passa a AWAITING_AMENDMENT solo allo /send)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ar)
|
||||||
|
return ApiResponse(message="Bozza soccorso istruttorio creata",
|
||||||
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{practice_id}/amendment/{amendment_id}", response_model=ApiResponse)
|
||||||
|
def update_amendment(practice_id: UUID, amendment_id: UUID, body: AmendmentRequestUpdate,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Modifica una bozza di soccorso. Consentito solo in stato DRAFT.
|
||||||
|
Dopo invio (AWAITING) il contenuto PEC e immutabile; si puo solo chiudere o prorogare."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.DRAFT.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Modifica consentita solo in stato DRAFT (attuale: {ar.status})")
|
||||||
|
if body.request_text is not None:
|
||||||
|
if len(body.request_text.strip()) < 10:
|
||||||
|
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
||||||
|
ar.request_text = body.request_text
|
||||||
|
if body.deadline is not None:
|
||||||
|
ar.deadline = body.deadline
|
||||||
|
if body.scope is not None:
|
||||||
|
ar.scope = body.scope
|
||||||
|
if body.response_days is not None:
|
||||||
|
if body.response_days < 1 or body.response_days > 120:
|
||||||
|
raise HTTPException(status_code=422, detail="response_days deve essere 1-120")
|
||||||
|
ar.response_days = body.response_days
|
||||||
|
if body.internal_note is not None:
|
||||||
|
ar.internal_note = body.internal_note
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ar)
|
||||||
|
return ApiResponse(message="Bozza aggiornata",
|
||||||
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{practice_id}/amendment/{amendment_id}", response_model=ApiResponse)
|
||||||
|
def delete_amendment(practice_id: UUID, amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Elimina una bozza di soccorso. Consentito solo in stato DRAFT.
|
||||||
|
Una volta inviata (AWAITING) si puo solo chiudere o scadere."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.DRAFT.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Eliminazione consentita solo in stato DRAFT (attuale: {ar.status})")
|
||||||
|
db.delete(ar)
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(message="Bozza eliminata", data={"id": str(amendment_id)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/send", response_model=ApiResponse)
|
||||||
|
def send_amendment(practice_id: UUID, amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Invia il soccorso: DRAFT -> AWAITING.
|
||||||
|
|
||||||
|
Da questo momento il BE Gepafin (poller interno) vedra l'amendment come pending-pec
|
||||||
|
e si occupera di PEC/protocollo tenant-aware. La pratica passa a AWAITING_AMENDMENT
|
||||||
|
(benef puo modificare) e il benef ricevera notifica quando la PEC arriva davvero."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.DRAFT.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Send consentito solo in stato DRAFT (attuale: {ar.status})")
|
||||||
|
if not ar.request_text or len(ar.request_text.strip()) < 10:
|
||||||
|
raise HTTPException(status_code=422, detail="Testo richiesta troppo breve")
|
||||||
|
|
||||||
|
ar.status = AmendmentStatus.AWAITING.value
|
||||||
|
p = _get_practice_or_404(db, practice_id)
|
||||||
p.status = "AWAITING_AMENDMENT"
|
p.status = "AWAITING_AMENDMENT"
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(ar)
|
db.refresh(ar)
|
||||||
return ApiResponse(message="Soccorso istruttorio avviato",
|
return ApiResponse(message="Soccorso inviato. In attesa di invio PEC da backend.",
|
||||||
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/extend", response_model=ApiResponse)
|
||||||
|
def extend_amendment(practice_id: UUID, amendment_id: UUID, body: AmendmentExtend,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Proroga la deadline di un soccorso AWAITING.
|
||||||
|
Somma extended_days alla deadline attuale. Traccia extension_date."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.AWAITING.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Proroga consentita solo in stato AWAITING (attuale: {ar.status})")
|
||||||
|
from datetime import timedelta
|
||||||
|
ar.deadline = ar.deadline + timedelta(days=body.extended_days)
|
||||||
|
ar.extended_days = (ar.extended_days or 0) + body.extended_days
|
||||||
|
ar.extension_date = datetime.now(timezone.utc)
|
||||||
|
if body.motivation:
|
||||||
|
ar.internal_note = ((ar.internal_note or "") + f"\n[Proroga {body.extended_days}gg {datetime.now(timezone.utc):%Y-%m-%d}]: {body.motivation}").strip()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ar)
|
||||||
|
return ApiResponse(message=f"Deadline prorogata di {body.extended_days} giorni",
|
||||||
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/reminder", response_model=ApiResponse)
|
||||||
|
def send_reminder(practice_id: UUID, amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Reminder manuale on-demand dell'istruttore al benef.
|
||||||
|
Accoda un secondo invio PEC (stesso contenuto richiesta) via flag interno.
|
||||||
|
Il BE vedra l'amendment come pending-pec=reminder e inviera email di reminder."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.AWAITING.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail="Reminder consentito solo su soccorsi AWAITING")
|
||||||
|
# flag minimo: segnala via campo separato. Per ora usiamo pec_retry_after come "serve reminder"
|
||||||
|
# (il BE poller distinguera pec_sent_at IS NULL vs pec_sent_at IS NOT NULL + pec_retry_after)
|
||||||
|
ar.pec_retry_after = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(message="Reminder accodato. Il backend invierà l'email di sollecito.",
|
||||||
|
data={"amendment_id": str(amendment_id), "queued_at": ar.pec_retry_after.isoformat()})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{practice_id}/amendment/{amendment_id}/close", response_model=ApiResponse)
|
@router.post("/{practice_id}/amendment/{amendment_id}/close", response_model=ApiResponse)
|
||||||
def close_amendment(practice_id: UUID, amendment_id: UUID,
|
def close_amendment(practice_id: UUID, amendment_id: UUID,
|
||||||
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||||
"""Istruttore chiude il soccorso (dopo aver visto la risposta beneficiario).
|
"""Istruttore chiude il soccorso. La pratica torna in UNDER_REVIEW
|
||||||
La pratica torna in UNDER_REVIEW."""
|
se non ci sono altri amendment aperti su di essa."""
|
||||||
ar = db.query(RemissionAmendmentRequest).filter(
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
RemissionAmendmentRequest.id == amendment_id,
|
if ar.status == AmendmentStatus.CLOSED.value:
|
||||||
RemissionAmendmentRequest.practice_id == practice_id
|
raise HTTPException(status_code=409, detail="Amendment gia chiusa")
|
||||||
).first()
|
|
||||||
if not ar:
|
|
||||||
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
|
||||||
if ar.status == "CLOSED":
|
|
||||||
raise HTTPException(status_code=409, detail="Amendment già chiusa")
|
|
||||||
|
|
||||||
ar.status = "CLOSED"
|
ar.status = AmendmentStatus.CLOSED.value
|
||||||
ar.closed_at = datetime.now(timezone.utc)
|
ar.closed_at = datetime.now(timezone.utc)
|
||||||
ar.closed_by = user.user_id
|
ar.closed_by = user.user_id
|
||||||
|
|
||||||
# rimetto la pratica in UNDER_REVIEW se non ci sono altre amendment aperte
|
|
||||||
p = _get_practice_or_404(db, practice_id)
|
p = _get_practice_or_404(db, practice_id)
|
||||||
others_open = [a for a in p.amendment_requests if a.id != ar.id and a.status == "AWAITING"]
|
others_open = [a for a in p.amendment_requests
|
||||||
|
if a.id != ar.id and a.status in (AmendmentStatus.DRAFT.value,
|
||||||
|
AmendmentStatus.AWAITING.value,
|
||||||
|
AmendmentStatus.RESPONSE_RECEIVED.value)]
|
||||||
if not others_open:
|
if not others_open:
|
||||||
p.status = "UNDER_REVIEW"
|
p.status = "UNDER_REVIEW"
|
||||||
|
|
||||||
@@ -252,6 +385,59 @@ def close_amendment(practice_id: UUID, amendment_id: UUID,
|
|||||||
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
data=AmendmentRequestOut.model_validate(ar).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/upload-document", response_model=ApiResponse)
|
||||||
|
async def upload_amendment_document(practice_id: UUID, amendment_id: UUID,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Allega documento dell'istruttore al soccorso (motivazione, scheda tecnica, ecc.).
|
||||||
|
Consentito in DRAFT o AWAITING. Sostituisce il precedente se esiste."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status not in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value):
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Upload consentito in DRAFT/AWAITING (attuale: {ar.status})")
|
||||||
|
p = _get_practice_or_404(db, practice_id)
|
||||||
|
try:
|
||||||
|
rel_path, size, digest, mime, safe_name = save_upload(
|
||||||
|
application_id=p.application_id,
|
||||||
|
entity_type="amendment-instructor-doc",
|
||||||
|
entity_id=ar.id,
|
||||||
|
file_obj=file.file,
|
||||||
|
original_filename=file.filename or "amendment.pdf",
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
except FileTooLargeError as e:
|
||||||
|
raise HTTPException(status_code=413, detail=str(e))
|
||||||
|
except MimeNotAllowedError as e:
|
||||||
|
raise HTTPException(status_code=415, detail=str(e))
|
||||||
|
except StorageError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Errore storage: {e}")
|
||||||
|
|
||||||
|
ar.amendment_document_path = rel_path
|
||||||
|
ar.amendment_document_type = mime
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ar)
|
||||||
|
return ApiResponse(message="Documento allegato al soccorso",
|
||||||
|
data={"amendment_id": str(ar.id), "path": rel_path,
|
||||||
|
"filename": safe_name, "size_bytes": size, "mime": mime})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{practice_id}/amendment/{amendment_id}/upload-document", response_model=ApiResponse)
|
||||||
|
def delete_amendment_document(practice_id: UUID, amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(_require_instructor)):
|
||||||
|
"""Rimuove il documento istruttore allegato al soccorso (consentito solo in DRAFT)."""
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status != AmendmentStatus.DRAFT.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Rimozione allegato consentita solo in DRAFT (attuale: {ar.status})")
|
||||||
|
ar.amendment_document_path = None
|
||||||
|
ar.amendment_document_type = None
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(message="Documento allegato rimosso",
|
||||||
|
data={"amendment_id": str(ar.id)})
|
||||||
|
|
||||||
|
|
||||||
# Endpoint beneficiario: visualizza amendments sulla sua pratica + risponde
|
# Endpoint beneficiario: visualizza amendments sulla sua pratica + risponde
|
||||||
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
|
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
|
||||||
def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
|
def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
|
||||||
@@ -260,15 +446,10 @@ def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
|
|||||||
user: AuthUser = Depends(get_current_user)):
|
user: AuthUser = Depends(get_current_user)):
|
||||||
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
|
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
|
||||||
p = _get_practice_or_404(db, practice_id)
|
p = _get_practice_or_404(db, practice_id)
|
||||||
if user.is_beneficiary() and p.user_id != user.user_id:
|
if user.is_owner_role() and p.user_id != user.user_id:
|
||||||
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
|
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
|
||||||
|
|
||||||
ar = db.query(RemissionAmendmentRequest).filter(
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
RemissionAmendmentRequest.id == amendment_id,
|
|
||||||
RemissionAmendmentRequest.practice_id == practice_id
|
|
||||||
).first()
|
|
||||||
if not ar:
|
|
||||||
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
|
||||||
if ar.status != "AWAITING":
|
if ar.status != "AWAITING":
|
||||||
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile")
|
raise HTTPException(status_code=409, detail=f"Amendment in stato {ar.status}, non rispondibile")
|
||||||
|
|
||||||
@@ -429,3 +610,44 @@ def set_instructor_final_notes(practice_id: UUID, body: InstructorFinalNotesBody
|
|||||||
db.refresh(p)
|
db.refresh(p)
|
||||||
return ApiResponse(message="Verbale aggiornato",
|
return ApiResponse(message="Verbale aggiornato",
|
||||||
data=PracticeOut.model_validate(p).model_dump(mode="json"))
|
data=PracticeOut.model_validate(p).model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{practice_id}/amendment/{amendment_id}/upload-response-document", response_model=ApiResponse)
|
||||||
|
async def upload_response_document(practice_id: UUID, amendment_id: UUID,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user)):
|
||||||
|
"""Beneficiario allega un documento come supporto alla sua risposta al soccorso.
|
||||||
|
Consentito su amendment in stato AWAITING, solo dal proprietario pratica."""
|
||||||
|
p = _get_practice_or_404(db, practice_id)
|
||||||
|
if user.is_owner_role() and p.user_id != user.user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
|
||||||
|
|
||||||
|
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||||
|
if ar.status not in (AmendmentStatus.AWAITING.value, AmendmentStatus.RESPONSE_RECEIVED.value):
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"Upload risposta consentito solo in AWAITING/RESPONSE_RECEIVED (attuale: {ar.status})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel_path, size, digest, mime, safe_name = save_upload(
|
||||||
|
application_id=p.application_id,
|
||||||
|
entity_type="amendment-response-doc",
|
||||||
|
entity_id=ar.id,
|
||||||
|
file_obj=file.file,
|
||||||
|
original_filename=file.filename or "response.pdf",
|
||||||
|
content_type=file.content_type,
|
||||||
|
)
|
||||||
|
except FileTooLargeError as e:
|
||||||
|
raise HTTPException(status_code=413, detail=str(e))
|
||||||
|
except MimeNotAllowedError as e:
|
||||||
|
raise HTTPException(status_code=415, detail=str(e))
|
||||||
|
except StorageError as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Errore storage: {e}")
|
||||||
|
|
||||||
|
ar.response_document_path = rel_path
|
||||||
|
ar.response_document_type = mime
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ar)
|
||||||
|
return ApiResponse(message="Documento risposta allegato",
|
||||||
|
data={"amendment_id": str(ar.id), "path": rel_path,
|
||||||
|
"filename": safe_name, "size_bytes": size, "mime": mime})
|
||||||
|
|||||||
267
app/routers/internal.py
Normal file
267
app/routers/internal.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"""Endpoint /internal/* chiamati dal BE Gepafin (polling + callback).
|
||||||
|
|
||||||
|
Auth: header X-Internal-Secret con valore settings.internal_secret.
|
||||||
|
Non passa per JWT utente — e comunicazione M2M tra servizi.
|
||||||
|
|
||||||
|
Flusso:
|
||||||
|
1. BE poller chiama GET /internal/remission-amendments?status=pending-pec
|
||||||
|
2. Per ogni item chiama GET /internal/remission-amendments/{id} per dettagli
|
||||||
|
3. BE compone PEC (template per-hub), chiama PEC Massiva / Mailgun
|
||||||
|
4. BE callback POST /internal/remission-amendments/{id}/mark-pec-sent (o failed)
|
||||||
|
|
||||||
|
Il microservizio resta tenant-agnostic: non conosce hub_id, non tocca PEC.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Header, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_, text
|
||||||
|
|
||||||
|
from ..storage import BASE_PATH, StorageError
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..config import get_settings, Settings
|
||||||
|
from ..models import RemissionAmendmentRequest, RemissionPractice
|
||||||
|
from ..schemas import (
|
||||||
|
ApiResponse, AmendmentPendingPecOut, AmendmentPecDetail,
|
||||||
|
MarkPecSent, MarkPecFailed, AmendmentStatus
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/internal/remission-amendments", tags=["internal"])
|
||||||
|
|
||||||
|
|
||||||
|
def _check_internal_auth(
|
||||||
|
x_internal_secret: Optional[str] = Header(None, alias="X-Internal-Secret"),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
):
|
||||||
|
"""Valida shared secret. In PROD aggiungere anche IP allowlist via middleware."""
|
||||||
|
if not x_internal_secret or x_internal_secret != settings.internal_secret:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid internal secret")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_application_id(db: Session, practice_id: UUID) -> int:
|
||||||
|
"""Recupera application_id dalla pratica. Il BE lo userà per risolvere hub/tenant."""
|
||||||
|
p = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica non trovata")
|
||||||
|
return p.application_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse)
|
||||||
|
def list_pending_pec(
|
||||||
|
status_filter: str = Query("pending-pec", alias="status",
|
||||||
|
description="pending-pec (nuove AWAITING senza PEC), pending-reminder (retry richiesto)"),
|
||||||
|
since: Optional[datetime] = Query(None, description="ISO datetime, filtra updated_at >= since"),
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: bool = Depends(_check_internal_auth),
|
||||||
|
):
|
||||||
|
"""Lista amendment da processare (polling BE). Due filtri:
|
||||||
|
- pending-pec: status=AWAITING AND pec_sent_at IS NULL (prime invio)
|
||||||
|
- pending-reminder: status=AWAITING AND pec_sent_at IS NOT NULL AND pec_retry_after IS NOT NULL
|
||||||
|
"""
|
||||||
|
q = db.query(RemissionAmendmentRequest)
|
||||||
|
|
||||||
|
if status_filter == "pending-pec":
|
||||||
|
q = q.filter(
|
||||||
|
RemissionAmendmentRequest.status == AmendmentStatus.AWAITING.value,
|
||||||
|
RemissionAmendmentRequest.pec_sent_at.is_(None),
|
||||||
|
)
|
||||||
|
elif status_filter == "pending-reminder":
|
||||||
|
q = q.filter(
|
||||||
|
RemissionAmendmentRequest.status == AmendmentStatus.AWAITING.value,
|
||||||
|
RemissionAmendmentRequest.pec_sent_at.isnot(None),
|
||||||
|
RemissionAmendmentRequest.pec_retry_after.isnot(None),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=422, detail="status deve essere pending-pec o pending-reminder")
|
||||||
|
|
||||||
|
if since is not None:
|
||||||
|
q = q.filter(RemissionAmendmentRequest.updated_at >= since)
|
||||||
|
|
||||||
|
q = q.order_by(RemissionAmendmentRequest.created_at.asc()).limit(limit)
|
||||||
|
results = q.all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for ar in results:
|
||||||
|
application_id = _fetch_application_id(db, ar.practice_id)
|
||||||
|
items.append(AmendmentPendingPecOut(
|
||||||
|
id=ar.id, practice_id=ar.practice_id, application_id=application_id,
|
||||||
|
request_text=ar.request_text, deadline=ar.deadline,
|
||||||
|
response_days=ar.response_days,
|
||||||
|
amendment_document_path=ar.amendment_document_path,
|
||||||
|
created_at=ar.created_at,
|
||||||
|
).model_dump(mode="json"))
|
||||||
|
return ApiResponse(message=f"{len(items)} amendment pending",
|
||||||
|
data={"items": items, "count": len(items)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{amendment_id}", response_model=ApiResponse)
|
||||||
|
def get_amendment_detail(
|
||||||
|
amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: bool = Depends(_check_internal_auth),
|
||||||
|
):
|
||||||
|
"""Dettaglio completo per comporre PEC lato BE. Include application_id, company_id,
|
||||||
|
call_id, sequence_number (per il titolo 'II fase 2021', ecc.)."""
|
||||||
|
ar = db.query(RemissionAmendmentRequest).filter(
|
||||||
|
RemissionAmendmentRequest.id == amendment_id
|
||||||
|
).first()
|
||||||
|
if not ar:
|
||||||
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
||||||
|
|
||||||
|
p = db.query(RemissionPractice).filter(RemissionPractice.id == ar.practice_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="Pratica collegata non trovata")
|
||||||
|
|
||||||
|
# serve company_id + call_id: il BE li dovrebbe gia sapere da application_id,
|
||||||
|
# ma glieli restituiamo pure qui per evitare join extra lato loro.
|
||||||
|
# Non avendo accesso a application/call nel microservizio (sono su gepafin_schema),
|
||||||
|
# facciamo una SELECT diretta.
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT a.company_id, a.call_id
|
||||||
|
FROM gepafin_schema.application a
|
||||||
|
WHERE a.id = :app_id
|
||||||
|
"""), {"app_id": p.application_id}).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Application {p.application_id} non trovata")
|
||||||
|
company_id, call_id = row
|
||||||
|
|
||||||
|
detail = AmendmentPecDetail(
|
||||||
|
id=ar.id, practice_id=p.id, application_id=p.application_id,
|
||||||
|
company_id=company_id, call_id=call_id,
|
||||||
|
sequence_number=p.sequence_number, period_label=p.period_label,
|
||||||
|
request_text=ar.request_text, deadline=ar.deadline,
|
||||||
|
response_days=ar.response_days,
|
||||||
|
amendment_document_path=ar.amendment_document_path,
|
||||||
|
)
|
||||||
|
return ApiResponse(message="ok", data=detail.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{amendment_id}/mark-pec-sent", response_model=ApiResponse)
|
||||||
|
def mark_pec_sent(
|
||||||
|
amendment_id: UUID, body: MarkPecSent,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: bool = Depends(_check_internal_auth),
|
||||||
|
):
|
||||||
|
"""Callback dal BE: PEC inviata con successo. Salva protocol_id + email_log_id + ts."""
|
||||||
|
ar = db.query(RemissionAmendmentRequest).filter(
|
||||||
|
RemissionAmendmentRequest.id == amendment_id
|
||||||
|
).first()
|
||||||
|
if not ar:
|
||||||
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
||||||
|
if ar.status != AmendmentStatus.AWAITING.value:
|
||||||
|
raise HTTPException(status_code=409,
|
||||||
|
detail=f"mark-pec-sent atteso solo su AWAITING (attuale: {ar.status})")
|
||||||
|
|
||||||
|
ar.protocol_id = body.protocol_id
|
||||||
|
ar.email_log_id = body.email_log_id
|
||||||
|
ar.user_action_id = body.user_action_id
|
||||||
|
ar.pec_sent_at = body.pec_sent_at or datetime.now(timezone.utc)
|
||||||
|
ar.pec_failed_reason = None
|
||||||
|
ar.pec_retry_after = None # reset retry flag (era usato come "send reminder")
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(message="PEC marcata come inviata",
|
||||||
|
data={"id": str(amendment_id), "protocol_id": body.protocol_id,
|
||||||
|
"pec_sent_at": ar.pec_sent_at.isoformat()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{amendment_id}/mark-pec-failed", response_model=ApiResponse)
|
||||||
|
def mark_pec_failed(
|
||||||
|
amendment_id: UUID, body: MarkPecFailed,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: bool = Depends(_check_internal_auth),
|
||||||
|
):
|
||||||
|
"""Callback dal BE: PEC fallita. Salva motivazione + eventuale retry_after."""
|
||||||
|
ar = db.query(RemissionAmendmentRequest).filter(
|
||||||
|
RemissionAmendmentRequest.id == amendment_id
|
||||||
|
).first()
|
||||||
|
if not ar:
|
||||||
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
||||||
|
|
||||||
|
ar.pec_failed_reason = body.reason[:2000] # limite safety
|
||||||
|
ar.pec_retry_after = body.retry_after
|
||||||
|
db.commit()
|
||||||
|
return ApiResponse(message="PEC marcata come fallita",
|
||||||
|
data={"id": str(amendment_id), "reason": body.reason[:200]})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_amendment_file(amendment_id: UUID, db: Session,
|
||||||
|
kind: str) -> tuple:
|
||||||
|
"""Risolve il path fisico di un allegato amendment.
|
||||||
|
|
||||||
|
kind: 'instructor' (amendment_document_path) | 'response' (response_document_path).
|
||||||
|
Ritorna tuple (abs_path, mime, safe_filename_hint).
|
||||||
|
Solleva HTTPException(404) se amendment non esiste o non ha il file.
|
||||||
|
Hardening: path deve restare dentro BASE_PATH (no traversal).
|
||||||
|
"""
|
||||||
|
ar = db.query(RemissionAmendmentRequest).filter(
|
||||||
|
RemissionAmendmentRequest.id == amendment_id
|
||||||
|
).first()
|
||||||
|
if not ar:
|
||||||
|
raise HTTPException(status_code=404, detail="Amendment non trovata")
|
||||||
|
|
||||||
|
if kind == "instructor":
|
||||||
|
rel_path, mime = ar.amendment_document_path, ar.amendment_document_type
|
||||||
|
elif kind == "response":
|
||||||
|
rel_path, mime = ar.response_document_path, ar.response_document_type
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=422, detail=f"kind invalido: {kind}")
|
||||||
|
|
||||||
|
if not rel_path:
|
||||||
|
raise HTTPException(status_code=404,
|
||||||
|
detail=f"Nessun documento {kind} per amendment {amendment_id}")
|
||||||
|
|
||||||
|
abs_path = BASE_PATH / rel_path
|
||||||
|
try:
|
||||||
|
abs_path.resolve().relative_to(BASE_PATH.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=500, detail="Path non valido (hardening)")
|
||||||
|
|
||||||
|
if not abs_path.is_file():
|
||||||
|
raise HTTPException(status_code=404,
|
||||||
|
detail=f"File non presente su storage: {rel_path}")
|
||||||
|
|
||||||
|
# filename hint = tail dopo l'ultimo "-" (struttura {sha}-{nome.ext})
|
||||||
|
base = abs_path.name
|
||||||
|
if "-" in base:
|
||||||
|
safe_name = base.split("-", 1)[1]
|
||||||
|
else:
|
||||||
|
safe_name = base
|
||||||
|
return abs_path, (mime or "application/pdf"), safe_name
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{amendment_id}/document")
|
||||||
|
def get_amendment_document(
|
||||||
|
amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: bool = Depends(_check_internal_auth),
|
||||||
|
):
|
||||||
|
"""Scarica il PDF allegato dall'istruttore al soccorso (binary stream).
|
||||||
|
Usato dal poller BE per archiviare su S3 nel folder pratica.
|
||||||
|
"""
|
||||||
|
abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "instructor")
|
||||||
|
return FileResponse(
|
||||||
|
path=str(abs_path), media_type=mime,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{amendment_id}/response-document")
|
||||||
|
def get_response_document(
|
||||||
|
amendment_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: bool = Depends(_check_internal_auth),
|
||||||
|
):
|
||||||
|
"""Scarica il PDF allegato dal beneficiario come risposta (binary stream).
|
||||||
|
Usato dal poller BE per archiviare su S3 nel folder pratica.
|
||||||
|
"""
|
||||||
|
abs_path, mime, safe_name = _resolve_amendment_file(amendment_id, db, "response")
|
||||||
|
return FileResponse(
|
||||||
|
path=str(abs_path), media_type=mime,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe_name}"'},
|
||||||
|
)
|
||||||
@@ -4,12 +4,12 @@ Endpoint pratiche di rendicontazione (lato beneficiario).
|
|||||||
import copy
|
import copy
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text, func
|
||||||
|
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..auth import AuthUser, get_current_user
|
from ..auth import AuthUser, get_current_user
|
||||||
@@ -23,8 +23,10 @@ from ..schemas import (
|
|||||||
UlaEmployeeCreate, UlaEmployeeOut,
|
UlaEmployeeCreate, UlaEmployeeOut,
|
||||||
DocumentUpsert, DocumentOut,
|
DocumentUpsert, DocumentOut,
|
||||||
GateCheckResult,
|
GateCheckResult,
|
||||||
|
ApplicationTranchesSummary, CopyUlaOption,
|
||||||
ApiResponse
|
ApiResponse
|
||||||
)
|
)
|
||||||
|
from ..templates import upgrade_schema_to_v2
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/remission-practices", tags=["remission-practices"])
|
router = APIRouter(prefix="/api/remission-practices", tags=["remission-practices"])
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ def _get_practice_or_404(db: Session, practice_id: UUID, user: AuthUser) -> Remi
|
|||||||
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
||||||
|
|
||||||
# Solo il beneficiario owner o un superadmin può accedere
|
# Solo il beneficiario owner o un superadmin può accedere
|
||||||
if user.is_beneficiary() and practice.user_id != user.user_id:
|
if user.is_owner_role() and practice.user_id != user.user_id:
|
||||||
raise HTTPException(status_code=403, detail="Accesso negato a questa pratica")
|
raise HTTPException(status_code=403, detail="Accesso negato a questa pratica")
|
||||||
|
|
||||||
return practice
|
return practice
|
||||||
@@ -51,7 +53,7 @@ def _ensure_editable(practice: RemissionPractice):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
|
def _compute_gate_check(db: Session, 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:
|
Calcola:
|
||||||
- per_category_declared: totali dichiarati dal beneficiario (sempre)
|
- per_category_declared: totali dichiarati dal beneficiario (sempre)
|
||||||
@@ -117,12 +119,43 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
|
|||||||
amt_erogato = practice.amount_erogato
|
amt_erogato = practice.amount_erogato
|
||||||
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
|
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
|
||||||
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
|
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
|
||||||
max_remission = min(cap_pct * amt_erogato, cap_abs)
|
# Cap assoluto per l'application (somma di tutte le tranche ammissibili)
|
||||||
|
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
|
||||||
|
|
||||||
|
# Cumulativo multi-tranche v2: sommo remission approvate delle tranche precedenti
|
||||||
|
# della stessa application per calcolare il residuo disponibile.
|
||||||
|
already_approved = db.query(
|
||||||
|
func.coalesce(func.sum(RemissionPractice.approved_remission), 0)
|
||||||
|
).filter(
|
||||||
|
RemissionPractice.application_id == practice.application_id,
|
||||||
|
RemissionPractice.sequence_number < practice.sequence_number,
|
||||||
|
RemissionPractice.status == 'APPROVED'
|
||||||
|
).scalar() or 0
|
||||||
|
already_approved = Decimal(str(already_approved))
|
||||||
|
|
||||||
|
max_remission_this_tranche = max(Decimal("0"), max_remission_global - already_approved)
|
||||||
|
|
||||||
|
# Legacy: max_remission = questo tranche (usato dai check sotto).
|
||||||
|
max_remission = max_remission_this_tranche
|
||||||
|
|
||||||
|
# 5 VOCI CECILIA:
|
||||||
|
# (1) max_remission_global
|
||||||
|
# (2) pre_check_admissible = min(grand_total_declared, max_remission_this_tranche)
|
||||||
|
# (3) remission_due = min(effective_total, max_remission_this_tranche)
|
||||||
|
# (4) amount_erogato
|
||||||
|
# (5) residuo_da_restituire = amt_erogato - SUM(approvata) (post-controllo su tutte le tranche)
|
||||||
|
pre_check_admissible = min(grand_total, max_remission_this_tranche)
|
||||||
|
|
||||||
# Se almeno 1 verifica fatta -> uso grand_total_verified per remission_due
|
# Se almeno 1 verifica fatta -> uso grand_total_verified per remission_due
|
||||||
# altrimenti uso grand_total (dichiarato) per preview pre-istruttoria
|
# altrimenti uso grand_total (dichiarato) per preview pre-istruttoria
|
||||||
effective_total = grand_total_verified if any_verified else grand_total
|
effective_total = grand_total_verified if any_verified else grand_total
|
||||||
remission_due = min(effective_total, max_remission)
|
remission_due = min(effective_total, max_remission_this_tranche)
|
||||||
|
|
||||||
|
# Conteggio tranche totali per questa application (per info UI/PDF)
|
||||||
|
tranches_count = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.application_id == practice.application_id
|
||||||
|
).count()
|
||||||
|
tranches_max = int(rules.get("max_tranches", 1))
|
||||||
|
|
||||||
# Per compatibilità: per_category e grand_total restano "dichiarato"
|
# Per compatibilità: per_category e grand_total restano "dichiarato"
|
||||||
per_category = per_category_declared
|
per_category = per_category_declared
|
||||||
@@ -183,6 +216,30 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
|
|||||||
"detail": f"Mancanti: {', '.join(missing_docs)}" if missing_docs else "Tutti presenti"
|
"detail": f"Mancanti: {', '.join(missing_docs)}" if missing_docs else "Tutti presenti"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Check 5b: documenti non scaduti (gate hard su EXPIRED)
|
||||||
|
# 2026-04-20: documento EXPIRED blocca la submit. Status letto live via JOIN sul
|
||||||
|
# BE Gepafin per doc collegati dal repository; per upload diretto PC controlla expires_at.
|
||||||
|
from datetime import date as _date_today_cls
|
||||||
|
today = _date_today_cls.today()
|
||||||
|
expired_docs = []
|
||||||
|
for doc in practice.documents:
|
||||||
|
if doc.source_company_document_id:
|
||||||
|
cd_status = db.execute(text("""
|
||||||
|
SELECT status FROM gepafin_schema.company_document
|
||||||
|
WHERE id = :cid AND is_deleted = false
|
||||||
|
"""), {"cid": doc.source_company_document_id}).scalar()
|
||||||
|
if cd_status == 'EXPIRED':
|
||||||
|
expired_docs.append(doc.doc_code)
|
||||||
|
elif doc.expires_at is not None and doc.expires_at < today:
|
||||||
|
expired_docs.append(doc.doc_code)
|
||||||
|
|
||||||
|
checks.append({
|
||||||
|
"id": "documents_not_expired",
|
||||||
|
"label": "Nessun documento scaduto",
|
||||||
|
"passed": len(expired_docs) == 0,
|
||||||
|
"detail": f"Scaduti: {', '.join(expired_docs)}" if expired_docs else "Tutti validi"
|
||||||
|
})
|
||||||
|
|
||||||
# Check 6: importo range (cap erogato)
|
# Check 6: importo range (cap erogato)
|
||||||
amt_range = rules.get("amount_range", {})
|
amt_range = rules.get("amount_range", {})
|
||||||
min_e = Decimal(str(amt_range.get("min", 0)))
|
min_e = Decimal(str(amt_range.get("min", 0)))
|
||||||
@@ -209,9 +266,17 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
|
|||||||
"amount_erogato": float(amt_erogato),
|
"amount_erogato": float(amt_erogato),
|
||||||
"any_verified": any_verified,
|
"any_verified": any_verified,
|
||||||
"all_verified": all_verified,
|
"all_verified": all_verified,
|
||||||
"residuo_da_restituire": float(max(amt_erogato - Decimal(str(remission_due)), Decimal("0"))),
|
"residuo_da_restituire": float(max(amt_erogato - already_approved - Decimal(str(remission_due)), Decimal("0"))),
|
||||||
"amount_basis": amount_basis,
|
"amount_basis": amount_basis,
|
||||||
"use_taxable_only": use_taxable_only
|
"use_taxable_only": use_taxable_only,
|
||||||
|
# multi-tranche v2
|
||||||
|
"max_remission_global": float(max_remission_global),
|
||||||
|
"already_approved_previous_tranches": float(already_approved),
|
||||||
|
"max_remission_this_tranche": float(max_remission_this_tranche),
|
||||||
|
"pre_check_admissible": float(pre_check_admissible),
|
||||||
|
"sequence_number": practice.sequence_number,
|
||||||
|
"tranches_count": tranches_count,
|
||||||
|
"tranches_max": tranches_max
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,48 +300,132 @@ def _enrich_list_item(db: Session, p: RemissionPractice) -> PracticeListItem:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _read_original_instructor(db: Session, application_id: int) -> Optional[int]:
|
||||||
|
"""Legge l'istruttore originariamente assegnato alla domanda nel BE Gepafin.
|
||||||
|
Restituisce user_id solo se l'utente e ancora attivo con ruolo PRE_INSTRUCTOR o INSTRUCTOR_MANAGER.
|
||||||
|
Altrimenti None (finira in coda 'da assegnare' per il manager).
|
||||||
|
"""
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT aa.user_id, r.role_type, u.is_deleted
|
||||||
|
FROM gepafin_schema.assigned_applications aa
|
||||||
|
JOIN gepafin_schema.gepafin_user u ON u.id = aa.user_id
|
||||||
|
JOIN gepafin_schema.role r ON r.id = u.role_id
|
||||||
|
WHERE aa.application_id = :aid
|
||||||
|
AND aa.is_deleted = false
|
||||||
|
AND u.is_deleted = false
|
||||||
|
ORDER BY aa.assigned_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""), {"aid": application_id}).mappings().first()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if row["role_type"] not in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER"):
|
||||||
|
return None
|
||||||
|
return row["user_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_schema_published(db: Session, call_id: int) -> Optional[CallRemissionSchema]:
|
||||||
|
return db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||||
|
|
||||||
|
|
||||||
# ---------- endpoints ----------
|
# ---------- endpoints ----------
|
||||||
|
|
||||||
@router.get("/mine", response_model=ApiResponse)
|
@router.get("/mine", response_model=ApiResponse)
|
||||||
def list_my_practices(db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
|
def list_my_practices(db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
|
||||||
"""Lista pratiche del beneficiario corrente + applications CONTRACT_SIGNED pronte per start."""
|
"""Lista pratiche del beneficiario raggruppate per application_id (v2 multi-tranche).
|
||||||
# pratiche esistenti
|
Ogni application ha il riepilogo cumulativo + elenco tranche esistenti + stato apertura nuova tranche.
|
||||||
practices = db.query(RemissionPractice).filter(RemissionPractice.user_id == user.user_id).all()
|
"""
|
||||||
existing_app_ids = {p.application_id for p in practices}
|
# Tutte le pratiche del beneficiario ordinate per application+sequence
|
||||||
|
practices = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.user_id == user.user_id
|
||||||
|
).order_by(
|
||||||
|
RemissionPractice.application_id, RemissionPractice.sequence_number
|
||||||
|
).all()
|
||||||
|
|
||||||
# applications CONTRACT_SIGNED del beneficiario che non hanno ancora una pratica
|
# Raggruppo per application_id
|
||||||
|
by_app = {}
|
||||||
|
for p in practices:
|
||||||
|
by_app.setdefault(p.application_id, []).append(p)
|
||||||
|
|
||||||
|
# Applications CONTRACT_SIGNED del beneficiario
|
||||||
rows = db.execute(text("""
|
rows = db.execute(text("""
|
||||||
SELECT a.id as application_id, a.call_id, a.company_id, a.amount_accepted,
|
SELECT a.id as application_id, a.call_id, a.company_id, a.amount_accepted, a.status,
|
||||||
a.status, c.name as call_name, comp.company_name as company_name
|
c.name as call_name, comp.company_name as company_name
|
||||||
FROM gepafin_schema.application a
|
FROM gepafin_schema.application a
|
||||||
JOIN gepafin_schema.call c ON c.id = a.call_id
|
JOIN gepafin_schema.call c ON c.id = a.call_id
|
||||||
LEFT JOIN gepafin_schema.company comp ON comp.id = a.company_id
|
LEFT JOIN gepafin_schema.company comp ON comp.id = a.company_id
|
||||||
WHERE a.user_id = :uid AND a.status = 'CONTRACT_SIGNED' AND a.is_deleted = false
|
WHERE a.user_id = :uid AND a.status = 'CONTRACT_SIGNED' AND a.is_deleted = false
|
||||||
|
ORDER BY a.id
|
||||||
"""), {"uid": user.user_id}).mappings().all()
|
"""), {"uid": user.user_id}).mappings().all()
|
||||||
|
|
||||||
pending = []
|
applications = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if r["application_id"] not in existing_app_ids:
|
app_id = r["application_id"]
|
||||||
pending.append({
|
trs = by_app.get(app_id, [])
|
||||||
"application_id": r["application_id"],
|
|
||||||
"call_id": r["call_id"],
|
|
||||||
"company_id": r["company_id"],
|
|
||||||
"amount_erogato": float(r["amount_accepted"] or 0),
|
|
||||||
"call_name": r["call_name"],
|
|
||||||
"company_name": r["company_name"],
|
|
||||||
"status": "NOT_STARTED"
|
|
||||||
})
|
|
||||||
|
|
||||||
return ApiResponse(data={
|
# leggo schema del bando per max_tranches e cap
|
||||||
"practices": [_enrich_list_item(db, p).model_dump(mode="json") for p in practices],
|
schema = _get_schema_published(db, r["call_id"])
|
||||||
"ready_to_start": pending
|
rules = (schema.schema_json.get("gate_rules", {}) if schema else {}) or {}
|
||||||
})
|
max_tranches = int(rules.get("max_tranches", 1))
|
||||||
|
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
|
||||||
|
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
|
||||||
|
amt_erogato = Decimal(str(r["amount_accepted"] or 0))
|
||||||
|
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
|
||||||
|
|
||||||
|
already_approved_sum = sum(
|
||||||
|
(t.approved_remission or Decimal("0")) for t in trs if t.status == "APPROVED"
|
||||||
|
)
|
||||||
|
max_remission_next = max(Decimal("0"), max_remission_global - already_approved_sum)
|
||||||
|
|
||||||
|
# Stato apertura nuova tranche
|
||||||
|
can_start = True
|
||||||
|
reason = None
|
||||||
|
if len(trs) >= max_tranches:
|
||||||
|
can_start = False
|
||||||
|
reason = f"Limite tranches raggiunto ({max_tranches})"
|
||||||
|
elif len(trs) > 0 and trs[-1].status not in ("APPROVED", "REJECTED"):
|
||||||
|
can_start = False
|
||||||
|
reason = "Completa prima la rendicontazione in corso"
|
||||||
|
elif max_remission_next <= 0:
|
||||||
|
can_start = False
|
||||||
|
reason = f"Remissione massima gia raggiunta (euro {float(already_approved_sum):.2f})"
|
||||||
|
|
||||||
|
# Summary tranche (serialize with enriched fields)
|
||||||
|
tranche_items = []
|
||||||
|
for t in trs:
|
||||||
|
item = _enrich_list_item(db, t).model_dump(mode="json")
|
||||||
|
tranche_items.append(item)
|
||||||
|
|
||||||
|
applications.append({
|
||||||
|
"application_id": app_id,
|
||||||
|
"call_id": r["call_id"],
|
||||||
|
"call_name": r["call_name"],
|
||||||
|
"company_id": r["company_id"],
|
||||||
|
"company_name": r["company_name"],
|
||||||
|
"amount_erogato": float(amt_erogato),
|
||||||
|
"max_tranches": max_tranches,
|
||||||
|
"tranches": tranche_items,
|
||||||
|
"can_start_new": can_start,
|
||||||
|
"start_blocked_reason": reason,
|
||||||
|
"already_approved_sum": float(already_approved_sum),
|
||||||
|
"max_remission_global": float(max_remission_global),
|
||||||
|
"max_remission_next_tranche": float(max_remission_next),
|
||||||
|
})
|
||||||
|
|
||||||
|
return ApiResponse(data={"applications": applications})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/start", response_model=ApiResponse)
|
@router.post("/start", response_model=ApiResponse)
|
||||||
def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
|
def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(get_current_user)):
|
user: AuthUser = Depends(get_current_user)):
|
||||||
"""Avvia una pratica di rendicontazione per una application CONTRACT_SIGNED."""
|
"""Avvia una nuova pratica o tranche N+1 per una application CONTRACT_SIGNED.
|
||||||
|
Validazioni server-side v2:
|
||||||
|
- count(tranches) < max_tranches
|
||||||
|
- last tranche in {APPROVED, REJECTED} oppure count==0
|
||||||
|
- max_remission_this_tranche > 0
|
||||||
|
Se sequence_number > 1 e copy_ula_from_previous=True: bulk copy ULA dalla tranche N-1
|
||||||
|
con reset verification_*.
|
||||||
|
"""
|
||||||
# Verifica application
|
# Verifica application
|
||||||
app_row = db.execute(text("""
|
app_row = db.execute(text("""
|
||||||
SELECT id, call_id, company_id, user_id, status, amount_accepted
|
SELECT id, call_id, company_id, user_id, status, amount_accepted
|
||||||
@@ -286,28 +435,69 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
|
|||||||
|
|
||||||
if not app_row:
|
if not app_row:
|
||||||
raise HTTPException(status_code=404, detail=f"Application {body.application_id} non trovata")
|
raise HTTPException(status_code=404, detail=f"Application {body.application_id} non trovata")
|
||||||
|
|
||||||
if app_row["status"] != "CONTRACT_SIGNED":
|
if app_row["status"] != "CONTRACT_SIGNED":
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED")
|
detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED")
|
||||||
|
if user.is_owner_role() and app_row["user_id"] != user.user_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Application non di tua proprieta")
|
||||||
|
|
||||||
if user.is_beneficiary() and app_row["user_id"] != user.user_id:
|
# Schema del bando
|
||||||
raise HTTPException(status_code=403, detail="Application non di tua proprietà")
|
schema = _get_schema_published(db, app_row["call_id"])
|
||||||
|
|
||||||
# Schema del bando: richiede PUBLISHED (o DRAFT se superadmin per test)
|
|
||||||
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == app_row["call_id"]).first()
|
|
||||||
if not schema:
|
if not schema:
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
detail="Nessuno schema di rendicontazione configurato per questo bando. "
|
detail="Nessuno schema di rendicontazione configurato per questo bando.")
|
||||||
"Contatta l'ente gestore.")
|
if schema.status != "PUBLISHED" and user.is_owner_role():
|
||||||
if schema.status != "PUBLISHED" and user.is_beneficiary():
|
|
||||||
raise HTTPException(status_code=409,
|
raise HTTPException(status_code=409,
|
||||||
detail="Lo schema di rendicontazione non è ancora stato pubblicato.")
|
detail="Lo schema di rendicontazione non e ancora stato pubblicato.")
|
||||||
|
|
||||||
# Pratica esistente?
|
# Tranche esistenti
|
||||||
exists = db.query(RemissionPractice).filter(RemissionPractice.application_id == body.application_id).first()
|
existing_tranches = db.query(RemissionPractice).filter(
|
||||||
if exists:
|
RemissionPractice.application_id == body.application_id
|
||||||
raise HTTPException(status_code=409, detail="Pratica già esistente")
|
).order_by(RemissionPractice.sequence_number).all()
|
||||||
|
|
||||||
|
rules = (schema.schema_json.get("gate_rules", {}) or {})
|
||||||
|
max_tranches = int(rules.get("max_tranches", 1))
|
||||||
|
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
|
||||||
|
cap_abs = Decimal(str(rules.get("cap_absolute", 12500)))
|
||||||
|
amt_erogato = Decimal(str(app_row["amount_accepted"] or 0))
|
||||||
|
max_remission_global = min(cap_pct * amt_erogato, cap_abs)
|
||||||
|
|
||||||
|
# VALIDAZIONI v2
|
||||||
|
if len(existing_tranches) >= max_tranches:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail=f"Limite tranches raggiunto (max {max_tranches})")
|
||||||
|
|
||||||
|
if existing_tranches:
|
||||||
|
last = existing_tranches[-1]
|
||||||
|
if last.status not in ("APPROVED", "REJECTED"):
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="Completa prima la rendicontazione in corso")
|
||||||
|
|
||||||
|
already_approved = sum(
|
||||||
|
(t.approved_remission or Decimal("0")) for t in existing_tranches if t.status == "APPROVED"
|
||||||
|
)
|
||||||
|
max_remission_this = max(Decimal("0"), max_remission_global - already_approved)
|
||||||
|
if max_remission_this <= 0:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail=f"Remissione massima gia raggiunta (euro {float(already_approved):.2f})")
|
||||||
|
|
||||||
|
# Nuovo sequence_number
|
||||||
|
next_seq = (existing_tranches[-1].sequence_number + 1) if existing_tranches else 1
|
||||||
|
|
||||||
|
# suggested_instructor: solo alla tranche 1 leggo da assigned_applications
|
||||||
|
suggested_instructor_id = None
|
||||||
|
assigned_instructor_id = None
|
||||||
|
if next_seq == 1:
|
||||||
|
suggested_instructor_id = _read_original_instructor(db, body.application_id)
|
||||||
|
assigned_instructor_id = suggested_instructor_id
|
||||||
|
else:
|
||||||
|
# tranche successiva: eredita suggested dalla tranche 1, assegnato ricomincia NULL
|
||||||
|
first = existing_tranches[0]
|
||||||
|
suggested_instructor_id = first.suggested_instructor_id
|
||||||
|
|
||||||
|
# Snapshot schema aggiornato a v2 se schema_version < 2
|
||||||
|
snapshot = copy.deepcopy(schema.schema_json)
|
||||||
|
snapshot = upgrade_schema_to_v2(snapshot)
|
||||||
|
|
||||||
practice = RemissionPractice(
|
practice = RemissionPractice(
|
||||||
call_id=app_row["call_id"],
|
call_id=app_row["call_id"],
|
||||||
@@ -315,15 +505,70 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
|
|||||||
company_id=app_row["company_id"],
|
company_id=app_row["company_id"],
|
||||||
user_id=app_row["user_id"],
|
user_id=app_row["user_id"],
|
||||||
status="DRAFT",
|
status="DRAFT",
|
||||||
schema_snapshot=copy.deepcopy(schema.schema_json),
|
schema_snapshot=snapshot,
|
||||||
amount_erogato=app_row["amount_accepted"] or Decimal("0"),
|
amount_erogato=amt_erogato,
|
||||||
|
sequence_number=next_seq,
|
||||||
|
period_label=body.period_label,
|
||||||
|
suggested_instructor_id=suggested_instructor_id,
|
||||||
|
assigned_instructor_id=assigned_instructor_id,
|
||||||
)
|
)
|
||||||
db.add(practice)
|
db.add(practice)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Copy ULA da tranche precedente
|
||||||
|
if next_seq > 1 and body.copy_ula_from_previous:
|
||||||
|
prev = existing_tranches[-1]
|
||||||
|
for prev_emp in prev.ula_employees:
|
||||||
|
new_emp = RemissionUlaEmployee(
|
||||||
|
practice_id=practice.id,
|
||||||
|
codice_fiscale=prev_emp.codice_fiscale,
|
||||||
|
full_name=prev_emp.full_name,
|
||||||
|
contract_type=prev_emp.contract_type,
|
||||||
|
role_description=prev_emp.role_description,
|
||||||
|
fte_pct=prev_emp.fte_pct,
|
||||||
|
period_start_date=prev_emp.period_start_date,
|
||||||
|
period_end_date=prev_emp.period_end_date,
|
||||||
|
supporting_doc_type=prev_emp.supporting_doc_type,
|
||||||
|
# reset verification: non copiare status/notes/verified_by/verified_at
|
||||||
|
verification_status="PENDING",
|
||||||
|
)
|
||||||
|
db.add(new_emp)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(practice)
|
db.refresh(practice)
|
||||||
|
|
||||||
return ApiResponse(message="Pratica avviata",
|
return ApiResponse(
|
||||||
data=PracticeOut.model_validate(practice).model_dump(mode="json"))
|
message=f"Tranche {next_seq}/{max_tranches} avviata",
|
||||||
|
data=PracticeOut.model_validate(practice).model_dump(mode="json")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{practice_id}/copy-ula-options", response_model=ApiResponse)
|
||||||
|
def copy_ula_options(practice_id: UUID, db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user)):
|
||||||
|
"""Preview dei dipendenti ULA della tranche N-1 copiabili in questa tranche N.
|
||||||
|
Usato dal FE al click su "+Nuova rendicontazione" per mostrare il pre-fill."""
|
||||||
|
p = _get_practice_or_404(db, practice_id, user)
|
||||||
|
if p.sequence_number <= 1:
|
||||||
|
return ApiResponse(data={"options": [], "previous_sequence": None})
|
||||||
|
prev = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.application_id == p.application_id,
|
||||||
|
RemissionPractice.sequence_number == p.sequence_number - 1
|
||||||
|
).first()
|
||||||
|
if not prev:
|
||||||
|
return ApiResponse(data={"options": [], "previous_sequence": None})
|
||||||
|
options = [CopyUlaOption(
|
||||||
|
codice_fiscale=e.codice_fiscale,
|
||||||
|
full_name=e.full_name,
|
||||||
|
contract_type=e.contract_type,
|
||||||
|
role_description=e.role_description,
|
||||||
|
fte_pct=float(e.fte_pct),
|
||||||
|
period_start_date=e.period_start_date,
|
||||||
|
period_end_date=e.period_end_date,
|
||||||
|
supporting_doc_type=e.supporting_doc_type,
|
||||||
|
).model_dump(mode="json") for e in prev.ula_employees]
|
||||||
|
return ApiResponse(data={"options": options, "previous_sequence": prev.sequence_number,
|
||||||
|
"previous_id": str(prev.id)})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{practice_id}", response_model=ApiResponse)
|
@router.get("/{practice_id}", response_model=ApiResponse)
|
||||||
@@ -451,7 +696,7 @@ def clear_document(practice_id: UUID, doc_code: str,
|
|||||||
def gate_check(practice_id: UUID, db: Session = Depends(get_db),
|
def gate_check(practice_id: UUID, db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(get_current_user)):
|
user: AuthUser = Depends(get_current_user)):
|
||||||
p = _get_practice_or_404(db, practice_id, user)
|
p = _get_practice_or_404(db, practice_id, user)
|
||||||
result = _compute_gate_check(p)
|
result = _compute_gate_check(db, p)
|
||||||
return ApiResponse(data=result.model_dump(mode="json"))
|
return ApiResponse(data=result.model_dump(mode="json"))
|
||||||
|
|
||||||
|
|
||||||
@@ -461,7 +706,7 @@ def submit_practice(practice_id: UUID, db: Session = Depends(get_db),
|
|||||||
p = _get_practice_or_404(db, practice_id, user)
|
p = _get_practice_or_404(db, practice_id, user)
|
||||||
_ensure_editable(p)
|
_ensure_editable(p)
|
||||||
|
|
||||||
check = _compute_gate_check(p)
|
check = _compute_gate_check(db, p)
|
||||||
if not check.passed:
|
if not check.passed:
|
||||||
raise HTTPException(status_code=422, detail={
|
raise HTTPException(status_code=422, detail={
|
||||||
"message": "Gate rules non soddisfatte",
|
"message": "Gate rules non soddisfatte",
|
||||||
|
|||||||
@@ -1,19 +1,153 @@
|
|||||||
"""
|
"""
|
||||||
Endpoint gestione schema rendicontazione per bando.
|
Endpoint gestione schema rendicontazione per bando.
|
||||||
|
v2.1 (2026-04-18): picker multi-sorgente (blank, template predefinito, clone da altro bando).
|
||||||
"""
|
"""
|
||||||
import copy
|
import copy
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..auth import AuthUser, get_current_user, require_superadmin
|
from ..auth import AuthUser, get_current_user, require_superadmin
|
||||||
from ..models import CallRemissionSchema
|
from ..models import CallRemissionSchema
|
||||||
from ..schemas import RemissionSchemaOut, RemissionSchemaCreate, RemissionSchemaUpdate, ApiResponse
|
from ..schemas import RemissionSchemaOut, RemissionSchemaCreate, RemissionSchemaUpdate, ApiResponse
|
||||||
from ..templates import RESTART_TEMPLATE
|
from ..templates import (
|
||||||
|
RESTART_TEMPLATE,
|
||||||
|
TEMPLATES,
|
||||||
|
list_templates,
|
||||||
|
get_template,
|
||||||
|
upgrade_schema_to_v2,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/rendicontazione-schemas", tags=["rendicontazione-schemas"])
|
router = APIRouter(prefix="/api/rendicontazione-schemas", tags=["rendicontazione-schemas"])
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# PICKER: template predefiniti + clonable calls
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
@router.get("/templates", response_model=ApiResponse)
|
||||||
|
def get_available_templates(user: AuthUser = Depends(require_superadmin)):
|
||||||
|
"""Elenca i template predefiniti disponibili (blank, restart, ...).
|
||||||
|
Ritorna solo metadati (template_id, label, description) — lo schema completo e con /templates/{id}."""
|
||||||
|
return ApiResponse(data={"templates": list_templates()})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/templates/{template_id}", response_model=ApiResponse)
|
||||||
|
def get_template_preview(template_id: str, user: AuthUser = Depends(require_superadmin)):
|
||||||
|
"""Preview dello schema completo di un template. Non crea nulla."""
|
||||||
|
schema = get_template(template_id)
|
||||||
|
if schema is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Template '{template_id}' non trovato")
|
||||||
|
return ApiResponse(data=schema)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clonable-calls", response_model=ApiResponse)
|
||||||
|
def list_clonable_calls(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(require_superadmin),
|
||||||
|
):
|
||||||
|
"""Elenca i bandi che hanno gia uno schema di rendicontazione (DRAFT o PUBLISHED),
|
||||||
|
disponibili come sorgenti di clonazione. Esclude il bando ancora non deciso dal context."""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT c.id AS call_id, c.name, c.status AS call_status,
|
||||||
|
s.status AS schema_status, s.created_at AS schema_created_at
|
||||||
|
FROM gepafin_rendic.call_remission_schema s
|
||||||
|
JOIN gepafin_schema.call c ON c.id = s.call_id AND c.is_deleted = false
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
""")).mappings().all()
|
||||||
|
return ApiResponse(data={"calls": [dict(r) for r in rows]})
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaInitializeRequest(BaseModel):
|
||||||
|
source: str = Field(..., description="blank | template | clone")
|
||||||
|
template_id: Optional[str] = Field(None, description="se source=template")
|
||||||
|
source_call_id: Optional[int] = Field(None, description="se source=clone")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{call_id}/initialize", response_model=ApiResponse)
|
||||||
|
def initialize_schema(
|
||||||
|
call_id: int,
|
||||||
|
body: SchemaInitializeRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(require_superadmin),
|
||||||
|
):
|
||||||
|
"""Inizializza uno schema di rendicontazione per call_id in modalita:
|
||||||
|
- blank: schema vuoto con solo scheletro sezioni
|
||||||
|
- template: parte da template predefinito (body.template_id)
|
||||||
|
- clone: copia schema_json di un altro bando (body.source_call_id)
|
||||||
|
Lo schema risultante e sempre DRAFT. Fallisce 409 se esiste gia.
|
||||||
|
"""
|
||||||
|
# Target vuoto
|
||||||
|
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Schema gia esistente per call_id={call_id}. Usa PUT per modificarlo.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verifica che il bando target esista
|
||||||
|
target = db.execute(text("""
|
||||||
|
SELECT id, name FROM gepafin_schema.call
|
||||||
|
WHERE id = :cid AND is_deleted = false
|
||||||
|
"""), {"cid": call_id}).mappings().first()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Bando call_id={call_id} non trovato")
|
||||||
|
|
||||||
|
# Risolvi schema_json secondo source
|
||||||
|
source = (body.source or "").lower()
|
||||||
|
|
||||||
|
if source == "blank":
|
||||||
|
schema_json = get_template("blank")
|
||||||
|
msg = f"Schema vuoto inizializzato per call_id={call_id}"
|
||||||
|
|
||||||
|
elif source == "template":
|
||||||
|
tpl_id = body.template_id or ""
|
||||||
|
schema_json = get_template(tpl_id)
|
||||||
|
if schema_json is None:
|
||||||
|
raise HTTPException(status_code=422,
|
||||||
|
detail=f"template_id '{tpl_id}' non valido. Usa GET /templates per l'elenco.")
|
||||||
|
msg = f"Schema inizializzato da template '{tpl_id}' per call_id={call_id}"
|
||||||
|
|
||||||
|
elif source == "clone":
|
||||||
|
src_id = body.source_call_id
|
||||||
|
if not src_id:
|
||||||
|
raise HTTPException(status_code=422, detail="source_call_id richiesto per source=clone")
|
||||||
|
src_schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == src_id).first()
|
||||||
|
if not src_schema:
|
||||||
|
raise HTTPException(status_code=404,
|
||||||
|
detail=f"Schema sorgente non trovato per source_call_id={src_id}")
|
||||||
|
schema_json = copy.deepcopy(src_schema.schema_json)
|
||||||
|
# assicura upgrade a v2 se il sorgente era v1
|
||||||
|
schema_json = upgrade_schema_to_v2(schema_json)
|
||||||
|
msg = f"Schema clonato da call_id={src_id} per call_id={call_id}"
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=422,
|
||||||
|
detail="source deve essere 'blank', 'template' o 'clone'")
|
||||||
|
|
||||||
|
# Persisti nuovo schema DRAFT
|
||||||
|
schema = CallRemissionSchema(
|
||||||
|
call_id=call_id,
|
||||||
|
schema_json=schema_json,
|
||||||
|
status="DRAFT",
|
||||||
|
created_by=user.user_id,
|
||||||
|
)
|
||||||
|
db.add(schema)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(schema)
|
||||||
|
return ApiResponse(
|
||||||
|
message=msg,
|
||||||
|
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Endpoint esistenti (CRUD, publish, delete)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
@router.get("/{call_id}", response_model=ApiResponse)
|
@router.get("/{call_id}", response_model=ApiResponse)
|
||||||
def get_schema(call_id: int, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
|
def get_schema(call_id: int, db: Session = Depends(get_db), user: AuthUser = Depends(get_current_user)):
|
||||||
"""Legge lo schema di rendicontazione per un bando. 404 se non esiste."""
|
"""Legge lo schema di rendicontazione per un bando. 404 se non esiste."""
|
||||||
@@ -33,12 +167,12 @@ def create_schema(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(require_superadmin),
|
user: AuthUser = Depends(require_superadmin),
|
||||||
):
|
):
|
||||||
"""Crea uno schema di rendicontazione per un bando. Fallisce se esiste già."""
|
"""Crea uno schema di rendicontazione per un bando. Fallisce se esiste gia."""
|
||||||
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail=f"Schema già esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
|
detail=f"Schema gia esistente per call_id={call_id}. Usa PUT per aggiornarlo.",
|
||||||
)
|
)
|
||||||
schema = CallRemissionSchema(
|
schema = CallRemissionSchema(
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
@@ -62,7 +196,7 @@ def update_schema(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(require_superadmin),
|
user: AuthUser = Depends(require_superadmin),
|
||||||
):
|
):
|
||||||
"""Aggiorna schema esistente. Blocca modifiche se già PUBLISHED."""
|
"""Aggiorna schema esistente. Blocca modifiche se gia PUBLISHED."""
|
||||||
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||||
if not schema:
|
if not schema:
|
||||||
raise HTTPException(status_code=404, detail=f"Schema non trovato per call_id={call_id}")
|
raise HTTPException(status_code=404, detail=f"Schema non trovato per call_id={call_id}")
|
||||||
@@ -87,26 +221,10 @@ def initialize_restart(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(require_superadmin),
|
user: AuthUser = Depends(require_superadmin),
|
||||||
):
|
):
|
||||||
"""Inizializza schema per un bando usando il template RE-START. Fallisce se esiste già."""
|
"""DEPRECATO in 2.1 — alias di /initialize con source=template&template_id=restart.
|
||||||
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
Mantenuto per backward compatibility del FE."""
|
||||||
if existing:
|
body = SchemaInitializeRequest(source="template", template_id="restart")
|
||||||
raise HTTPException(
|
return initialize_schema(call_id=call_id, body=body, db=db, user=user)
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail=f"Schema già esistente per call_id={call_id}. Usa PUT per modificarlo.",
|
|
||||||
)
|
|
||||||
schema = CallRemissionSchema(
|
|
||||||
call_id=call_id,
|
|
||||||
schema_json=copy.deepcopy(RESTART_TEMPLATE),
|
|
||||||
status="DRAFT",
|
|
||||||
created_by=user.user_id,
|
|
||||||
)
|
|
||||||
db.add(schema)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(schema)
|
|
||||||
return ApiResponse(
|
|
||||||
message=f"Schema RE-START inizializzato per call_id={call_id}",
|
|
||||||
data=RemissionSchemaOut.model_validate(schema).model_dump(mode="json"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{call_id}/publish", response_model=ApiResponse)
|
@router.post("/{call_id}/publish", response_model=ApiResponse)
|
||||||
@@ -115,7 +233,7 @@ def publish_schema(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: AuthUser = Depends(require_superadmin),
|
user: AuthUser = Depends(require_superadmin),
|
||||||
):
|
):
|
||||||
"""Pubblica lo schema (status DRAFT -> PUBLISHED). Una volta pubblicato, non è più editabile."""
|
"""Pubblica lo schema (status DRAFT -> PUBLISHED). Una volta pubblicato, non e piu editabile."""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||||
if not schema:
|
if not schema:
|
||||||
@@ -123,7 +241,7 @@ def publish_schema(
|
|||||||
if schema.status == "PUBLISHED":
|
if schema.status == "PUBLISHED":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail="Schema già pubblicato.",
|
detail="Schema gia pubblicato.",
|
||||||
)
|
)
|
||||||
schema.status = "PUBLISHED"
|
schema.status = "PUBLISHED"
|
||||||
schema.published_at = datetime.now(timezone.utc)
|
schema.published_at = datetime.now(timezone.utc)
|
||||||
@@ -156,7 +274,7 @@ def delete_schema(
|
|||||||
return ApiResponse(message=f"Schema cancellato per call_id={call_id}")
|
return ApiResponse(message=f"Schema cancellato per call_id={call_id}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/templates/restart", response_model=ApiResponse)
|
@router.get("/templates-preview/restart", response_model=ApiResponse)
|
||||||
def get_restart_template(user: AuthUser = Depends(require_superadmin)):
|
def get_restart_template(user: AuthUser = Depends(require_superadmin)):
|
||||||
"""Restituisce il template RE-START senza persisterlo. Utile per preview."""
|
"""DEPRECATO: usa GET /templates/restart invece. Mantenuto per backward compat."""
|
||||||
return ApiResponse(data=RESTART_TEMPLATE)
|
return ApiResponse(data=RESTART_TEMPLATE)
|
||||||
|
|||||||
297
app/routers/verbale.py
Normal file
297
app/routers/verbale.py
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"""
|
||||||
|
Endpoint generazione verbale di istruttoria in HTML/PDF via Jinja2 + weasyprint.
|
||||||
|
Solo ruoli istruttore/superadmin possono scaricare.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, date
|
||||||
|
from uuid import UUID
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response, HTMLResponse
|
||||||
|
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..auth import AuthUser, get_current_user
|
||||||
|
from ..models import RemissionPractice, RemissionCustomCheckValue
|
||||||
|
from .practices import _compute_gate_check
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/remission-practices/instructor", tags=["verbale"])
|
||||||
|
|
||||||
|
|
||||||
|
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates_jinja"
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||||
|
# URL file:// assoluto per weasyprint (che non fa HTTP fetch ma risolve file://)
|
||||||
|
LOGO_FILE_URL = f"file://{STATIC_DIR.resolve()}/gepafin-logo.svg"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Jinja env & filters ----------
|
||||||
|
def _euro(v):
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
n = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "—"
|
||||||
|
s = f"{n:,.2f}"
|
||||||
|
# IT locale: 1,234,567.89 → 1.234.567,89
|
||||||
|
s = s.replace(",", "X").replace(".", ",").replace("X", ".")
|
||||||
|
return f"€ {s}"
|
||||||
|
|
||||||
|
|
||||||
|
def _datefmt(v):
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
v = datetime.fromisoformat(v).date()
|
||||||
|
except ValueError:
|
||||||
|
return v
|
||||||
|
if hasattr(v, "strftime"):
|
||||||
|
return v.strftime("%d/%m/%Y")
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _datetimefmt(v):
|
||||||
|
if v is None:
|
||||||
|
return "—"
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
v = datetime.fromisoformat(v)
|
||||||
|
except ValueError:
|
||||||
|
return v
|
||||||
|
if hasattr(v, "strftime"):
|
||||||
|
return v.strftime("%d/%m/%Y %H:%M")
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
_AMEND_STATUS = {
|
||||||
|
"AWAITING": "In attesa risposta",
|
||||||
|
"RESPONSE_RECEIVED": "Risposta ricevuta",
|
||||||
|
"CLOSED": "Chiuso",
|
||||||
|
"EXPIRED": "Scaduto",
|
||||||
|
"REJECTED": "Rifiutato",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _amendstatus(s):
|
||||||
|
return _AMEND_STATUS.get(s, s or "—")
|
||||||
|
|
||||||
|
|
||||||
|
_env = Environment(
|
||||||
|
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
||||||
|
autoescape=select_autoescape(["html", "xml"]),
|
||||||
|
trim_blocks=True, lstrip_blocks=True,
|
||||||
|
)
|
||||||
|
_env.filters["euro"] = _euro
|
||||||
|
_env.filters["datefmt"] = _datefmt
|
||||||
|
_env.filters["datetimefmt"] = _datetimefmt
|
||||||
|
_env.filters["amendstatus"] = _amendstatus
|
||||||
|
|
||||||
|
|
||||||
|
_CONTRACT_LABELS = {
|
||||||
|
"T_IND": "Tempo indeterminato",
|
||||||
|
"T_DET": "Tempo determinato",
|
||||||
|
"APPR": "Apprendistato",
|
||||||
|
"STAGE": "Tirocinio / Stage",
|
||||||
|
"COLL": "Collaborazione coordinata",
|
||||||
|
"ALTRO": "Altro",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_instructor(user: AuthUser) -> bool:
|
||||||
|
return user.role in ("ROLE_PRE_INSTRUCTOR", "ROLE_INSTRUCTOR_MANAGER", "ROLE_SUPER_ADMIN")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) -> dict:
|
||||||
|
"""Prepara tutto il contesto per il template."""
|
||||||
|
# Gate check + totali
|
||||||
|
gate_obj = _compute_gate_check(db, practice); gate = gate_obj.model_dump() if hasattr(gate_obj, "model_dump") else dict(gate_obj)
|
||||||
|
totals = gate.get("totals") or {}
|
||||||
|
|
||||||
|
# Schema sections
|
||||||
|
sections = practice.schema_snapshot.get("sections") or []
|
||||||
|
cat_section = next((s for s in sections if s.get("type") == "category_grid"), {}) or {}
|
||||||
|
categories = cat_section.get("categories") or []
|
||||||
|
categories_map = {c.get("code"): c.get("label") for c in categories}
|
||||||
|
cat_order = {c.get("code"): i for i, c in enumerate(categories)}
|
||||||
|
|
||||||
|
ula_section = next((s for s in sections if s.get("type") == "ula_block"), {}) or {}
|
||||||
|
ula_enabled = bool(ula_section.get("enabled"))
|
||||||
|
ula_threshold = float(ula_section.get("threshold") or 1)
|
||||||
|
|
||||||
|
docs_section = next((s for s in sections if s.get("type") == "document_checklist"), {}) or {}
|
||||||
|
docs_required_raw = docs_section.get("required_types") or []
|
||||||
|
docs_required = [
|
||||||
|
(r if isinstance(r, dict) else {"code": r, "label": r})
|
||||||
|
for r in docs_required_raw
|
||||||
|
]
|
||||||
|
docs_by_code = {d.doc_code: {
|
||||||
|
"filename": d.filename, "verification_status": d.verification_status,
|
||||||
|
"verification_notes": d.verification_notes,
|
||||||
|
} for d in practice.documents}
|
||||||
|
|
||||||
|
# Raggruppo fatture per categoria in ordine schema
|
||||||
|
sorted_invoices = sorted(
|
||||||
|
practice.invoices,
|
||||||
|
key=lambda i: (cat_order.get(i.category_code, 999), i.invoice_number or "")
|
||||||
|
)
|
||||||
|
invoices_by_cat = OrderedDict()
|
||||||
|
for c in categories:
|
||||||
|
invoices_by_cat[c.get("code")] = []
|
||||||
|
for inv in sorted_invoices:
|
||||||
|
invoices_by_cat.setdefault(inv.category_code, []).append(inv)
|
||||||
|
|
||||||
|
per_cat_declared = totals.get("per_category_declared") or {}
|
||||||
|
per_cat_verified = totals.get("per_category_verified") or {}
|
||||||
|
|
||||||
|
# ULA aggregati
|
||||||
|
ula_fte_decl = sum(float(e.fte_pct or 0) for e in practice.ula_employees)
|
||||||
|
ula_fte_verif = sum(
|
||||||
|
float((e.fte_pct_verified if e.fte_pct_verified is not None else e.fte_pct) or 0)
|
||||||
|
for e in practice.ula_employees
|
||||||
|
if e.verification_status in ("AMMESSA", "PARZIALE")
|
||||||
|
)
|
||||||
|
ula_ok = ula_fte_verif >= ula_threshold
|
||||||
|
|
||||||
|
# Anagrafica beneficiario (da gepafin_schema.company)
|
||||||
|
company_row = db.execute(text("""
|
||||||
|
SELECT company_name, vat_number
|
||||||
|
FROM gepafin_schema.company
|
||||||
|
WHERE id = :cid
|
||||||
|
"""), {"cid": practice.company_id}).mappings().first()
|
||||||
|
company = dict(company_row) if company_row else {}
|
||||||
|
|
||||||
|
# Istruttore
|
||||||
|
instructor_name = None
|
||||||
|
if practice.reviewed_by:
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT first_name || ' ' || last_name AS name
|
||||||
|
FROM gepafin_schema.gepafin_user WHERE id = :uid
|
||||||
|
"""), {"uid": practice.reviewed_by}).scalar()
|
||||||
|
instructor_name = row
|
||||||
|
elif user.user_id:
|
||||||
|
row = db.execute(text("""
|
||||||
|
SELECT first_name || ' ' || last_name AS name
|
||||||
|
FROM gepafin_schema.gepafin_user WHERE id = :uid
|
||||||
|
"""), {"uid": user.user_id}).scalar()
|
||||||
|
instructor_name = row
|
||||||
|
|
||||||
|
# v2: custom_checks merged (schema_snapshot.custom_checks[] + RemissionCustomCheckValue)
|
||||||
|
check_defs = practice.schema_snapshot.get("custom_checks") or []
|
||||||
|
values_by_code = {v.check_code: v for v in practice.custom_checks}
|
||||||
|
custom_checks_merged = []
|
||||||
|
for d in check_defs:
|
||||||
|
code = d.get("code")
|
||||||
|
val = values_by_code.get(code)
|
||||||
|
custom_checks_merged.append({
|
||||||
|
"code": code,
|
||||||
|
"label": d.get("label"),
|
||||||
|
"description": d.get("description"),
|
||||||
|
"requires_document": bool(d.get("requires_document")),
|
||||||
|
"required": bool(d.get("required")),
|
||||||
|
"beneficiary_declared": bool(val.beneficiary_declared) if val else False,
|
||||||
|
"declared_at": val.declared_at if val else None,
|
||||||
|
"has_document": bool(val and val.storage_path),
|
||||||
|
"verification_status": (val.verification_status if val else "PENDING"),
|
||||||
|
"verification_notes": (val.verification_notes if val else None),
|
||||||
|
})
|
||||||
|
|
||||||
|
# v2: storico tranche precedenti APPROVED (se sequence > 1)
|
||||||
|
previous_tranches = []
|
||||||
|
cumulative_approved = 0.0
|
||||||
|
if practice.sequence_number > 1:
|
||||||
|
prevs = db.query(RemissionPractice).filter(
|
||||||
|
RemissionPractice.application_id == practice.application_id,
|
||||||
|
RemissionPractice.sequence_number < practice.sequence_number,
|
||||||
|
RemissionPractice.status == "APPROVED",
|
||||||
|
).order_by(RemissionPractice.sequence_number).all()
|
||||||
|
for pv in prevs:
|
||||||
|
amt = float(pv.approved_remission or 0)
|
||||||
|
cumulative_approved += amt
|
||||||
|
previous_tranches.append({
|
||||||
|
"sequence_number": pv.sequence_number,
|
||||||
|
"period_label": pv.period_label,
|
||||||
|
"reviewed_at": pv.reviewed_at,
|
||||||
|
"approved_remission": amt,
|
||||||
|
"cumulative": cumulative_approved,
|
||||||
|
})
|
||||||
|
|
||||||
|
# v2 max_tranches dallo schema_snapshot (o dal bando corrente, fallback 1)
|
||||||
|
snap_rules = practice.schema_snapshot.get("gate_rules") or {}
|
||||||
|
max_tranches_snapshot = int(snap_rules.get("max_tranches") or totals.get("tranches_max") or 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"practice": practice,
|
||||||
|
"totals": totals,
|
||||||
|
"categories_map": categories_map,
|
||||||
|
"invoices_by_cat": invoices_by_cat,
|
||||||
|
"per_cat_declared": per_cat_declared,
|
||||||
|
"per_cat_verified": per_cat_verified,
|
||||||
|
"ula_section_enabled": ula_enabled,
|
||||||
|
"ula_threshold": ula_threshold,
|
||||||
|
"ula_fte_decl": ula_fte_decl,
|
||||||
|
"ula_fte_verif": ula_fte_verif,
|
||||||
|
"ula_ok": ula_ok,
|
||||||
|
"docs_required": docs_required,
|
||||||
|
"docs_by_code": docs_by_code,
|
||||||
|
"amendments": practice.amendment_requests or [],
|
||||||
|
"contract_labels": _CONTRACT_LABELS,
|
||||||
|
"company": company,
|
||||||
|
"instructor_name": instructor_name,
|
||||||
|
"generated_at": datetime.now().strftime("%d/%m/%Y"),
|
||||||
|
# v2
|
||||||
|
"custom_checks_merged": custom_checks_merged,
|
||||||
|
"previous_tranches": previous_tranches,
|
||||||
|
"max_tranches_snapshot": max_tranches_snapshot,
|
||||||
|
"logo_path": LOGO_FILE_URL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_html(db: Session, practice_id: UUID, user: AuthUser) -> tuple[RemissionPractice, str]:
|
||||||
|
practice = db.query(RemissionPractice).filter(RemissionPractice.id == practice_id).first()
|
||||||
|
if not practice:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
|
||||||
|
if not _is_instructor(user):
|
||||||
|
raise HTTPException(status_code=403, detail="Ruolo istruttore richiesto")
|
||||||
|
ctx = _build_context(db, practice, user)
|
||||||
|
tpl = _env.get_template("verbale_istruttoria.html")
|
||||||
|
html = tpl.render(**ctx)
|
||||||
|
return practice, html
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{practice_id}/verbale.html", response_class=HTMLResponse)
|
||||||
|
def verbale_html(
|
||||||
|
practice_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Rendering HTML del verbale — utile per debug e preview rapida."""
|
||||||
|
_, html = _render_html(db, practice_id, user)
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{practice_id}/verbale.pdf")
|
||||||
|
def verbale_pdf(
|
||||||
|
practice_id: UUID,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: AuthUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Generazione PDF via weasyprint."""
|
||||||
|
# Import locale per non rallentare il cold start se weasyprint manca
|
||||||
|
from weasyprint import HTML as WeasyHTML
|
||||||
|
|
||||||
|
practice, html = _render_html(db, practice_id, user)
|
||||||
|
pdf_bytes = WeasyHTML(string=html).write_pdf()
|
||||||
|
filename = f"verbale_istruttoria_pratica_{practice.application_id}_t{practice.sequence_number}.pdf"
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
158
app/scheduler.py
Normal file
158
app/scheduler.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Scheduler per lifecycle amendment.
|
||||||
|
|
||||||
|
Due cron job attivi:
|
||||||
|
- expire_amendments(): ogni giorno 01:05 — trova amendment AWAITING con
|
||||||
|
deadline < today, le passa a EXPIRED, rimette pratica a UNDER_REVIEW
|
||||||
|
se non ci sono altri amendment aperti. Equivalente a
|
||||||
|
ApplicationAmendmentScheduler.processAmendmentExpirationScheduler del BE.
|
||||||
|
|
||||||
|
- queue_reminders(): ogni giorno 09:00 — legge remission_expiration_config
|
||||||
|
(type='AMENDMENT', interval_days=N), trova amendment AWAITING con
|
||||||
|
deadline == today + N giorni, setta flag reminder_queued_at sul
|
||||||
|
record. Equivalente a ExpirationScheduler.processAmendmentExpiration
|
||||||
|
del BE (data-driven da expiration_config).
|
||||||
|
|
||||||
|
Le notifiche effettive (PEC reminder benef + email interna istruttore) le
|
||||||
|
invia il BE Gepafin via polling sui nostri endpoint /internal. Il
|
||||||
|
microservizio resta sender-agnostico.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, date, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from .db import SessionLocal
|
||||||
|
from .models import RemissionAmendmentRequest, RemissionPractice, RemissionExpirationConfig
|
||||||
|
|
||||||
|
log = logging.getLogger("rendicontazione-api.scheduler")
|
||||||
|
|
||||||
|
|
||||||
|
def expire_amendments() -> dict:
|
||||||
|
"""Espira amendment AWAITING con deadline passata.
|
||||||
|
Ritorna dict {expired_count, practices_reopened} per logging/test."""
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
today = date.today()
|
||||||
|
stats = {"expired_count": 0, "practices_reopened": 0}
|
||||||
|
try:
|
||||||
|
expired_ars = db.query(RemissionAmendmentRequest).filter(
|
||||||
|
RemissionAmendmentRequest.status == "AWAITING",
|
||||||
|
RemissionAmendmentRequest.deadline < today,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
practice_ids_to_check = set()
|
||||||
|
|
||||||
|
for ar in expired_ars:
|
||||||
|
ar.status = "EXPIRED"
|
||||||
|
practice_ids_to_check.add(ar.practice_id)
|
||||||
|
stats["expired_count"] += 1
|
||||||
|
log.info(f"Amendment {ar.id} EXPIRED (deadline era {ar.deadline})")
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for pid in practice_ids_to_check:
|
||||||
|
p = db.query(RemissionPractice).filter(RemissionPractice.id == pid).first()
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
others_open = [
|
||||||
|
a for a in p.amendment_requests
|
||||||
|
if a.status in ("DRAFT", "AWAITING", "RESPONSE_RECEIVED")
|
||||||
|
]
|
||||||
|
if not others_open and p.status == "AWAITING_AMENDMENT":
|
||||||
|
p.status = "UNDER_REVIEW"
|
||||||
|
stats["practices_reopened"] += 1
|
||||||
|
log.info(f"Pratica {pid} ritornata a UNDER_REVIEW (amendment scaduto, nessun altro aperto)")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
log.info(f"expire_amendments: {stats}")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
log.error(f"expire_amendments FAILED: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def queue_reminders() -> dict:
|
||||||
|
"""Legge config data-driven, per ogni interval_days trova amendment
|
||||||
|
con deadline == today + N, scrive flag reminder_queued_at sull'amendment.
|
||||||
|
Il BE vedra questi amendment come pending-reminder via /internal.
|
||||||
|
|
||||||
|
Usiamo campo pec_retry_after come marker unificato (gia presente):
|
||||||
|
- NULL: nessun reminder accodato
|
||||||
|
- timestamp: reminder accodato in questo momento, BE invia al prossimo poll
|
||||||
|
|
||||||
|
Ritorna dict {reminders_queued_by_interval}."""
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
today = date.today()
|
||||||
|
stats = {"reminders_queued": 0, "by_interval": {}}
|
||||||
|
try:
|
||||||
|
configs = db.query(RemissionExpirationConfig).filter(
|
||||||
|
RemissionExpirationConfig.type == "AMENDMENT",
|
||||||
|
RemissionExpirationConfig.is_deleted.is_(False),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
target_deadline = today + timedelta(days=cfg.interval_days)
|
||||||
|
|
||||||
|
ars = db.query(RemissionAmendmentRequest).filter(
|
||||||
|
RemissionAmendmentRequest.status == "AWAITING",
|
||||||
|
RemissionAmendmentRequest.deadline == target_deadline,
|
||||||
|
RemissionAmendmentRequest.pec_sent_at.isnot(None), # solo se gia inviata PEC iniziale
|
||||||
|
# evito di ri-accodare se gia accodato oggi
|
||||||
|
RemissionAmendmentRequest.pec_retry_after.is_(None),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for ar in ars:
|
||||||
|
ar.pec_retry_after = datetime.now(timezone.utc)
|
||||||
|
stats["reminders_queued"] += 1
|
||||||
|
log.info(f"Amendment {ar.id} reminder accodato ({cfg.interval_days}gg alla scadenza)")
|
||||||
|
|
||||||
|
stats["by_interval"][cfg.interval_days] = len(ars)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
log.info(f"queue_reminders: {stats}")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
log.error(f"queue_reminders FAILED: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
_scheduler: Optional[BackgroundScheduler] = None
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler():
|
||||||
|
"""Avvia BackgroundScheduler con i 2 cron. Chiamato in lifespan FastAPI."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
log.warning("start_scheduler chiamato due volte, skip")
|
||||||
|
return _scheduler
|
||||||
|
_scheduler = BackgroundScheduler(timezone="Europe/Rome")
|
||||||
|
# expire ogni notte 01:05 (dopo midnight, sicuro che today e cambiato)
|
||||||
|
_scheduler.add_job(
|
||||||
|
expire_amendments, CronTrigger(hour=1, minute=5),
|
||||||
|
id="expire_amendments", replace_existing=True, misfire_grace_time=3600,
|
||||||
|
)
|
||||||
|
# reminder ogni mattina 09:00
|
||||||
|
_scheduler.add_job(
|
||||||
|
queue_reminders, CronTrigger(hour=9, minute=0),
|
||||||
|
id="queue_reminders", replace_existing=True, misfire_grace_time=3600,
|
||||||
|
)
|
||||||
|
_scheduler.start()
|
||||||
|
log.info("Scheduler avviato: expire_amendments 01:05 + queue_reminders 09:00 (Europe/Rome)")
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler():
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
_scheduler = None
|
||||||
|
log.info("Scheduler fermato")
|
||||||
173
app/schemas.py
173
app/schemas.py
@@ -5,6 +5,7 @@ from typing import Optional, Any, List
|
|||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -40,8 +41,10 @@ class RemissionSchemaOut(BaseModel):
|
|||||||
# ====================== Pratica di rendicontazione (beneficiario) ======================
|
# ====================== Pratica di rendicontazione (beneficiario) ======================
|
||||||
|
|
||||||
class PracticeStartRequest(BaseModel):
|
class PracticeStartRequest(BaseModel):
|
||||||
"""Input minimo per avviare una pratica: solo application_id. Il resto viene dal DB."""
|
"""Input per avviare una (nuova) pratica o tranche."""
|
||||||
application_id: int
|
application_id: int
|
||||||
|
period_label: Optional[str] = None # es "I trimestre 2021" — libero
|
||||||
|
copy_ula_from_previous: bool = True # ignorato se e la prima tranche
|
||||||
|
|
||||||
|
|
||||||
class PracticeUpdate(BaseModel):
|
class PracticeUpdate(BaseModel):
|
||||||
@@ -115,6 +118,7 @@ class DocumentUpsert(BaseModel):
|
|||||||
uploaded_at: Optional[datetime] = None
|
uploaded_at: Optional[datetime] = None
|
||||||
expires_at: Optional[date] = None
|
expires_at: Optional[date] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
source_company_document_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class DocumentOut(BaseModel):
|
class DocumentOut(BaseModel):
|
||||||
@@ -125,6 +129,7 @@ class DocumentOut(BaseModel):
|
|||||||
uploaded_at: Optional[datetime] = None
|
uploaded_at: Optional[datetime] = None
|
||||||
expires_at: Optional[date] = None
|
expires_at: Optional[date] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
source_company_document_id: Optional[int] = None
|
||||||
# istruttoria
|
# istruttoria
|
||||||
verification_status: str = "PENDING"
|
verification_status: str = "PENDING"
|
||||||
verification_notes: Optional[str] = None
|
verification_notes: Optional[str] = None
|
||||||
@@ -160,6 +165,11 @@ class PracticeOut(BaseModel):
|
|||||||
instructor_checklist: Optional[dict] = None
|
instructor_checklist: Optional[dict] = None
|
||||||
verbale_date: Optional[date] = None
|
verbale_date: Optional[date] = None
|
||||||
|
|
||||||
|
# v2 multi-tranche
|
||||||
|
sequence_number: int = 1
|
||||||
|
period_label: Optional[str] = None
|
||||||
|
suggested_instructor_id: Optional[int] = None
|
||||||
|
|
||||||
invoices: List[InvoiceOut] = []
|
invoices: List[InvoiceOut] = []
|
||||||
ula_employees: List[UlaEmployeeOut] = []
|
ula_employees: List[UlaEmployeeOut] = []
|
||||||
documents: List[DocumentOut] = []
|
documents: List[DocumentOut] = []
|
||||||
@@ -178,6 +188,11 @@ class PracticeListItem(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
submitted_at: Optional[datetime] = None
|
submitted_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
# v2 multi-tranche
|
||||||
|
sequence_number: int = 1
|
||||||
|
period_label: Optional[str] = None
|
||||||
|
suggested_instructor_id: Optional[int] = None
|
||||||
|
|
||||||
# campi denormalizzati aggiunti a runtime
|
# campi denormalizzati aggiunti a runtime
|
||||||
call_name: Optional[str] = None
|
call_name: Optional[str] = None
|
||||||
company_name: Optional[str] = None
|
company_name: Optional[str] = None
|
||||||
@@ -197,10 +212,36 @@ class GateCheckResult(BaseModel):
|
|||||||
|
|
||||||
# ====================== Istruttoria ======================
|
# ====================== Istruttoria ======================
|
||||||
|
|
||||||
|
# Stati formali amendment (speculare BE Gepafin ApplicationAmendmentRequestEnum, ridotti)
|
||||||
|
class AmendmentStatus(str, Enum):
|
||||||
|
DRAFT = "DRAFT" # istruttore prepara, PEC non ancora partita
|
||||||
|
AWAITING = "AWAITING" # PEC inviata, attendo risposta benef
|
||||||
|
RESPONSE_RECEIVED = "RESPONSE_RECEIVED" # benef ha risposto, istruttore deve valutare
|
||||||
|
EXPIRED = "EXPIRED" # deadline passata senza risposta (scheduler)
|
||||||
|
CLOSED = "CLOSED" # istruttore chiude dopo response o comunque
|
||||||
|
|
||||||
|
|
||||||
class AmendmentRequestCreate(BaseModel):
|
class AmendmentRequestCreate(BaseModel):
|
||||||
request_text: str
|
request_text: str
|
||||||
deadline: date
|
deadline: date
|
||||||
scope: Optional[dict] = None
|
scope: Optional[dict] = None
|
||||||
|
response_days: Optional[int] = None # pre-compilato default 15gg, variabile per istruttore
|
||||||
|
internal_note: Optional[str] = None # visibile solo istruttore
|
||||||
|
|
||||||
|
|
||||||
|
class AmendmentRequestUpdate(BaseModel):
|
||||||
|
"""Modifica amendment solo in stato DRAFT."""
|
||||||
|
request_text: Optional[str] = None
|
||||||
|
deadline: Optional[date] = None
|
||||||
|
scope: Optional[dict] = None
|
||||||
|
response_days: Optional[int] = None
|
||||||
|
internal_note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AmendmentExtend(BaseModel):
|
||||||
|
"""Prolunga la deadline di un amendment AWAITING."""
|
||||||
|
extended_days: int = Field(..., gt=0, le=60)
|
||||||
|
motivation: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class AmendmentResponseSubmit(BaseModel):
|
class AmendmentResponseSubmit(BaseModel):
|
||||||
@@ -221,9 +262,63 @@ class AmendmentRequestOut(BaseModel):
|
|||||||
closed_by: Optional[int] = None
|
closed_by: Optional[int] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
# soccorso v3
|
||||||
|
response_days: Optional[int] = None
|
||||||
|
extended_days: Optional[int] = None
|
||||||
|
extension_date: Optional[datetime] = None
|
||||||
|
internal_note: Optional[str] = None
|
||||||
|
amendment_document_path: Optional[str] = None
|
||||||
|
amendment_document_type: Optional[str] = None
|
||||||
|
response_document_path: Optional[str] = None
|
||||||
|
response_document_type: Optional[str] = None
|
||||||
|
protocol_id: Optional[str] = None
|
||||||
|
pec_sent_at: Optional[datetime] = None
|
||||||
|
pec_failed_reason: Optional[str] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ===== schemas per endpoint interni chiamati dal BE Gepafin =====
|
||||||
|
|
||||||
|
class AmendmentPendingPecOut(BaseModel):
|
||||||
|
"""Lista amendment che il BE deve processare per invio PEC."""
|
||||||
|
id: UUID
|
||||||
|
practice_id: UUID
|
||||||
|
application_id: int
|
||||||
|
request_text: str
|
||||||
|
deadline: date
|
||||||
|
response_days: Optional[int] = None
|
||||||
|
amendment_document_path: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AmendmentPecDetail(BaseModel):
|
||||||
|
"""Dettaglio richiesto dal BE per comporre PEC."""
|
||||||
|
id: UUID
|
||||||
|
practice_id: UUID
|
||||||
|
application_id: int
|
||||||
|
company_id: int
|
||||||
|
call_id: int
|
||||||
|
sequence_number: int
|
||||||
|
period_label: Optional[str] = None
|
||||||
|
request_text: str
|
||||||
|
deadline: date
|
||||||
|
response_days: Optional[int] = None
|
||||||
|
amendment_document_path: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MarkPecSent(BaseModel):
|
||||||
|
protocol_id: str
|
||||||
|
email_log_id: Optional[int] = None
|
||||||
|
user_action_id: Optional[int] = None
|
||||||
|
pec_sent_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MarkPecFailed(BaseModel):
|
||||||
|
reason: str
|
||||||
|
retry_after: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class ReviewRejectBody(BaseModel):
|
class ReviewRejectBody(BaseModel):
|
||||||
rejection_reason: str
|
rejection_reason: str
|
||||||
|
|
||||||
@@ -237,6 +332,9 @@ class InstructorQueueItem(BaseModel):
|
|||||||
id: UUID
|
id: UUID
|
||||||
call_id: int
|
call_id: int
|
||||||
application_id: int
|
application_id: int
|
||||||
|
sequence_number: int = 1
|
||||||
|
period_label: Optional[str] = None
|
||||||
|
suggested_instructor_id: Optional[int] = None
|
||||||
company_id: int
|
company_id: int
|
||||||
status: str
|
status: str
|
||||||
amount_erogato: Decimal
|
amount_erogato: Decimal
|
||||||
@@ -288,3 +386,76 @@ class ApiResponse(BaseModel):
|
|||||||
status: str = "SUCCESS"
|
status: str = "SUCCESS"
|
||||||
message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
data: Optional[Any] = None
|
data: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== v2 Custom checks ======================
|
||||||
|
|
||||||
|
class CustomCheckDeclareBody(BaseModel):
|
||||||
|
beneficiary_declared: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CustomCheckVerifyBody(BaseModel):
|
||||||
|
verification_status: str # PENDING | VALIDO | NON_VALIDO
|
||||||
|
verification_notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomCheckOut(BaseModel):
|
||||||
|
"""Vista merged di definition (da schema) + value (dal DB)."""
|
||||||
|
code: str
|
||||||
|
label: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
requires_document: bool = False
|
||||||
|
required: bool = False
|
||||||
|
# valori
|
||||||
|
beneficiary_declared: bool = False
|
||||||
|
declared_at: Optional[datetime] = None
|
||||||
|
filename_original: Optional[str] = None
|
||||||
|
storage_path: Optional[str] = None
|
||||||
|
size_bytes: Optional[int] = None
|
||||||
|
document_uploaded_at: Optional[datetime] = None
|
||||||
|
verification_status: str = "PENDING"
|
||||||
|
verification_notes: Optional[str] = None
|
||||||
|
verified_by: Optional[int] = None
|
||||||
|
verified_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== v2 Reassign istruttore ======================
|
||||||
|
|
||||||
|
class PracticeReassignBody(BaseModel):
|
||||||
|
new_instructor_id: Optional[int] = None # None = unassign ritorno in coda
|
||||||
|
reassignment_reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== v2 Tranches ======================
|
||||||
|
|
||||||
|
class ApplicationTranchesSummary(BaseModel):
|
||||||
|
"""Riepilogo pratiche/tranche per una application."""
|
||||||
|
application_id: int
|
||||||
|
call_id: int
|
||||||
|
call_name: Optional[str] = None
|
||||||
|
company_id: int
|
||||||
|
company_name: Optional[str] = None
|
||||||
|
amount_erogato: float
|
||||||
|
max_tranches: int = 1
|
||||||
|
# summary tranche esistenti
|
||||||
|
tranches: List[PracticeListItem] = []
|
||||||
|
# stato apertura nuova tranche
|
||||||
|
can_start_new: bool = False
|
||||||
|
start_blocked_reason: Optional[str] = None
|
||||||
|
# importi cumulativi
|
||||||
|
already_approved_sum: float = 0
|
||||||
|
max_remission_global: float = 0
|
||||||
|
max_remission_next_tranche: float = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CopyUlaOption(BaseModel):
|
||||||
|
"""Dipendente copiabile da tranche precedente."""
|
||||||
|
codice_fiscale: str
|
||||||
|
full_name: str
|
||||||
|
contract_type: str
|
||||||
|
role_description: Optional[str] = None
|
||||||
|
fte_pct: float
|
||||||
|
period_start_date: date
|
||||||
|
period_end_date: date
|
||||||
|
supporting_doc_type: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
1
app/static/gepafin-logo.svg
Normal file
1
app/static/gepafin-logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
159
app/storage.py
Normal file
159
app/storage.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Storage adapter per file upload.
|
||||||
|
|
||||||
|
Implementazione attuale: filesystem locale con bind mount /var/uploads.
|
||||||
|
Struttura: /var/uploads/{application_id}/{entity_type}/{entity_id}/{sha256}-{filename}
|
||||||
|
|
||||||
|
Migrazione futura a S3/MinIO: cambiare solo questa classe.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import BinaryIO, Optional, Tuple
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
BASE_PATH = Path(os.environ.get("RENDIC_UPLOAD_BASE", "/var/uploads"))
|
||||||
|
MAX_SIZE_BYTES = 15 * 1024 * 1024 # 15 MB
|
||||||
|
|
||||||
|
ALLOWED_MIMES = {
|
||||||
|
"application/pdf": ".pdf",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/png": ".png",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StorageError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FileTooLargeError(StorageError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MimeNotAllowedError(StorageError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_filename(name: str, max_len: int = 120) -> str:
|
||||||
|
"""Rimuove caratteri pericolosi, tronca."""
|
||||||
|
keep = "-_.() "
|
||||||
|
clean = "".join(c if (c.isalnum() or c in keep) else "_" for c in name)
|
||||||
|
clean = clean.strip().replace(" ", "_")
|
||||||
|
if len(clean) > max_len:
|
||||||
|
root, ext = os.path.splitext(clean)
|
||||||
|
clean = root[: max_len - len(ext)] + ext
|
||||||
|
return clean or "file"
|
||||||
|
|
||||||
|
|
||||||
|
def save_upload(
|
||||||
|
application_id: int,
|
||||||
|
entity_type: str, # invoice | ula | document | amendment-instructor-doc | amendment-response-doc
|
||||||
|
entity_id: UUID,
|
||||||
|
file_obj: BinaryIO,
|
||||||
|
original_filename: str,
|
||||||
|
content_type: Optional[str],
|
||||||
|
) -> Tuple[str, int, str, str, str]:
|
||||||
|
"""
|
||||||
|
Salva il file su FS e ritorna (storage_path, size_bytes, sha256, mime, safe_filename).
|
||||||
|
storage_path è RELATIVO a BASE_PATH (es: "1/invoice/xxx/yyy-fattura.pdf").
|
||||||
|
|
||||||
|
Valida:
|
||||||
|
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
|
||||||
|
- dimensione <= MAX_SIZE_BYTES
|
||||||
|
"""
|
||||||
|
if entity_type not in ("invoice", "ula", "document", "amendment-instructor-doc", "amendment-response-doc"):
|
||||||
|
raise StorageError(f"entity_type non valido: {entity_type}")
|
||||||
|
|
||||||
|
safe_name = _safe_filename(original_filename)
|
||||||
|
ext = os.path.splitext(safe_name)[1].lower()
|
||||||
|
|
||||||
|
# Risolvi mime: prima content_type client, poi da estensione
|
||||||
|
mime = (content_type or "").lower().split(";")[0].strip()
|
||||||
|
if mime not in ALLOWED_MIMES:
|
||||||
|
# Fallback da estensione
|
||||||
|
ext_to_mime = {v: k for k, v in ALLOWED_MIMES.items()}
|
||||||
|
mime = ext_to_mime.get(ext, "")
|
||||||
|
if mime not in ALLOWED_MIMES:
|
||||||
|
raise MimeNotAllowedError(
|
||||||
|
f"MIME non consentito: '{content_type}' / estensione '{ext}'. "
|
||||||
|
f"Accettati: {list(ALLOWED_MIMES.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calcola sha256 e size streaming per non tenere tutto in RAM
|
||||||
|
hasher = hashlib.sha256()
|
||||||
|
size = 0
|
||||||
|
# Salva in tmp poi rename atomico
|
||||||
|
target_dir = BASE_PATH / str(application_id) / entity_type / str(entity_id)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp_path = target_dir / f".tmp-{entity_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = file_obj.read(65536)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_SIZE_BYTES:
|
||||||
|
raise FileTooLargeError(
|
||||||
|
f"File {size} byte oltre limite {MAX_SIZE_BYTES}"
|
||||||
|
)
|
||||||
|
hasher.update(chunk)
|
||||||
|
out.write(chunk)
|
||||||
|
|
||||||
|
digest = hasher.hexdigest()
|
||||||
|
final_name = f"{digest[:12]}-{safe_name}"
|
||||||
|
final_path = target_dir / final_name
|
||||||
|
|
||||||
|
# Se già esiste (dedup per sha+nome) rimuovi tmp e usa esistente
|
||||||
|
if final_path.exists():
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
os.replace(tmp_path, final_path)
|
||||||
|
|
||||||
|
rel_path = str(final_path.relative_to(BASE_PATH))
|
||||||
|
return rel_path, size, digest, mime, safe_name
|
||||||
|
except Exception:
|
||||||
|
# cleanup tmp in caso di errore
|
||||||
|
try:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def delete_file(storage_path: str) -> bool:
|
||||||
|
"""Elimina fisicamente il file. Ritorna True se rimosso."""
|
||||||
|
if not storage_path:
|
||||||
|
return False
|
||||||
|
abs_path = BASE_PATH / storage_path
|
||||||
|
# hardening: resta sotto BASE_PATH
|
||||||
|
try:
|
||||||
|
abs_path.resolve().relative_to(BASE_PATH.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise StorageError(f"Path fuori da BASE_PATH: {storage_path}")
|
||||||
|
if abs_path.exists():
|
||||||
|
abs_path.unlink()
|
||||||
|
# prova a rimuovere directory vuote (entity_id/entity_type)
|
||||||
|
try:
|
||||||
|
abs_path.parent.rmdir()
|
||||||
|
abs_path.parent.parent.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def open_file(storage_path: str) -> Path:
|
||||||
|
"""Ritorna Path assoluto del file. Solleva FileNotFoundError se mancante."""
|
||||||
|
if not storage_path:
|
||||||
|
raise FileNotFoundError("storage_path vuoto")
|
||||||
|
abs_path = BASE_PATH / storage_path
|
||||||
|
try:
|
||||||
|
abs_path.resolve().relative_to(BASE_PATH.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise StorageError(f"Path fuori da BASE_PATH: {storage_path}")
|
||||||
|
if not abs_path.is_file():
|
||||||
|
raise FileNotFoundError(f"File non trovato: {storage_path}")
|
||||||
|
return abs_path
|
||||||
179
app/templates.py
179
app/templates.py
@@ -1,11 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Template schemi precompilati per bandi noti.
|
Template schemi precompilati per bandi noti.
|
||||||
RE-START: il bando del xlsx di Cecilia, base per la prima iterazione.
|
RE-START: il bando del xlsx di Cecilia, base per la prima iterazione.
|
||||||
|
|
||||||
|
v2 (2026-04-18): schema_version=2, max_tranches, custom_checks[]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
RESTART_TEMPLATE = {
|
RESTART_TEMPLATE = {
|
||||||
"version": "1.0",
|
"version": "2.0",
|
||||||
"template_id": "RESTART_V1",
|
"schema_version": 2,
|
||||||
|
"template_id": "RESTART_V2",
|
||||||
"template_label": "RE-START (fondo prestiti con remissione del debito)",
|
"template_label": "RE-START (fondo prestiti con remissione del debito)",
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
@@ -115,6 +118,22 @@ RESTART_TEMPLATE = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"custom_checks": [
|
||||||
|
{
|
||||||
|
"code": "antiriciclaggio",
|
||||||
|
"label": "Dichiarazione antiriciclaggio",
|
||||||
|
"description": "Dichiaro che il beneficiario rispetta la normativa antiriciclaggio (D.Lgs. 231/2007 e s.m.i.) e che i soggetti coinvolti non sono iscritti in liste sanzionatorie.",
|
||||||
|
"requires_document": False,
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "polizza_fidejussoria",
|
||||||
|
"label": "Polizza fidejussoria",
|
||||||
|
"description": "Allegare copia della polizza fidejussoria a garanzia dell'importo erogato (se richiesta da bando).",
|
||||||
|
"requires_document": True,
|
||||||
|
"required": False,
|
||||||
|
},
|
||||||
|
],
|
||||||
"gate_rules": {
|
"gate_rules": {
|
||||||
"amount_range": {"min": 5000, "max": 25000},
|
"amount_range": {"min": 5000, "max": 25000},
|
||||||
"cap_pct_erogato": 0.5,
|
"cap_pct_erogato": 0.5,
|
||||||
@@ -125,5 +144,161 @@ RESTART_TEMPLATE = {
|
|||||||
"require_at_least_one_invoice_per_nonzero_category": True,
|
"require_at_least_one_invoice_per_nonzero_category": True,
|
||||||
"require_ula_above_threshold": True,
|
"require_ula_above_threshold": True,
|
||||||
"require_all_documents_resolved": True,
|
"require_all_documents_resolved": True,
|
||||||
|
"max_tranches": 2, # v2: superadmin configurabile, default 1
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_schema_to_v2(schema_json: dict) -> dict:
|
||||||
|
"""Upgrade in-place di schema v1 a v2.
|
||||||
|
- Aggiunge schema_version=2 se mancante
|
||||||
|
- Aggiunge gate_rules.max_tranches=1 se mancante
|
||||||
|
- Aggiunge custom_checks=[] se mancante
|
||||||
|
- Assicura ula_section.enabled presente (default True se ula_block esiste)
|
||||||
|
Idempotente: se lo schema e gia v2, no-op.
|
||||||
|
"""
|
||||||
|
if not isinstance(schema_json, dict):
|
||||||
|
return schema_json
|
||||||
|
changed = False
|
||||||
|
if schema_json.get("schema_version", 1) < 2:
|
||||||
|
schema_json["schema_version"] = 2
|
||||||
|
changed = True
|
||||||
|
gate = schema_json.setdefault("gate_rules", {})
|
||||||
|
if "max_tranches" not in gate:
|
||||||
|
gate["max_tranches"] = 1
|
||||||
|
changed = True
|
||||||
|
if "custom_checks" not in schema_json:
|
||||||
|
schema_json["custom_checks"] = []
|
||||||
|
changed = True
|
||||||
|
# ula_section.enabled esplicito
|
||||||
|
for sec in schema_json.get("sections", []):
|
||||||
|
if sec.get("type") == "ula_block" and "enabled" not in sec:
|
||||||
|
sec["enabled"] = True
|
||||||
|
changed = True
|
||||||
|
return schema_json
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# BLANK_TEMPLATE — scheletro minimo v2, solo sezioni vuote da popolare
|
||||||
|
# =========================================================================
|
||||||
|
BLANK_TEMPLATE = {
|
||||||
|
"version": "2.0",
|
||||||
|
"schema_version": 2,
|
||||||
|
"template_id": "BLANK_V2",
|
||||||
|
"template_label": "Nuovo schema vuoto",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "static_fields",
|
||||||
|
"id": "general",
|
||||||
|
"label": "Dati generali",
|
||||||
|
"description": "Regime IVA, dati base del beneficiario, periodo di ammissibilita delle spese.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"id": "period_start_date",
|
||||||
|
"type": "date",
|
||||||
|
"label": "Periodo ammissibilita — Data inizio",
|
||||||
|
"description": "Data minima di emissione/pagamento fatture ammissibili.",
|
||||||
|
"editable_by": "superadmin",
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "period_end_date",
|
||||||
|
"type": "date",
|
||||||
|
"label": "Periodo ammissibilita — Data fine",
|
||||||
|
"description": "Data massima di emissione/pagamento fatture ammissibili.",
|
||||||
|
"editable_by": "superadmin",
|
||||||
|
"required": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "iva_regime",
|
||||||
|
"type": "select",
|
||||||
|
"label": "Regime IVA",
|
||||||
|
"required": True,
|
||||||
|
"options": [
|
||||||
|
{"value": "ORDINARIO", "label": "Ordinario — IVA non ammissibile"},
|
||||||
|
{"value": "FORFETTARIO", "label": "Forfettario — IVA ammissibile"},
|
||||||
|
{"value": "ESENTE", "label": "Esente"},
|
||||||
|
],
|
||||||
|
"help": "Il regime IVA determina se l'IVA delle fatture e rendicontabile.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "invoice_table",
|
||||||
|
"id": "invoices",
|
||||||
|
"label": "Fatture ammissibili",
|
||||||
|
"description": "Categorie di spesa da configurare. Aggiungi almeno una categoria prima di pubblicare.",
|
||||||
|
"categories": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ula_block",
|
||||||
|
"id": "ula",
|
||||||
|
"label": "Incremento occupazione (ULA)",
|
||||||
|
"description": "Dipendenti su cui calcolare l'incremento ULA. Disattiva la sezione se il bando non lo richiede.",
|
||||||
|
"enabled": False,
|
||||||
|
"threshold": 1.0,
|
||||||
|
"fields": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "documents_required",
|
||||||
|
"id": "docs",
|
||||||
|
"label": "Documenti richiesti",
|
||||||
|
"description": "Documenti che il beneficiario deve allegare alla rendicontazione. Aggiungi almeno i documenti obbligatori.",
|
||||||
|
"items": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"custom_checks": [],
|
||||||
|
"gate_rules": {
|
||||||
|
"invoices_min_count": 1,
|
||||||
|
"amount_range": {"min": 0, "max": 100000},
|
||||||
|
"cap_pct_erogato": 0.5,
|
||||||
|
"cap_absolute": 100000,
|
||||||
|
"amount_basis": "imponibile_only_ordinario",
|
||||||
|
"period_start_rule": "erogato_date",
|
||||||
|
"period_end": None,
|
||||||
|
"require_at_least_one_invoice_per_nonzero_category": True,
|
||||||
|
"require_ula_above_threshold": False,
|
||||||
|
"require_all_documents_resolved": True,
|
||||||
|
"max_tranches": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# TEMPLATES registry — esteso in futuro con nuovi bandi
|
||||||
|
# =========================================================================
|
||||||
|
TEMPLATES = {
|
||||||
|
"blank": {
|
||||||
|
"template_id": "blank",
|
||||||
|
"label": "Nuovo schema (da zero)",
|
||||||
|
"description": "Scheletro minimo: sezioni vuote (categorie, documenti, controlli) da popolare. Usa questo quando il bando e nuovo e non somiglia a bandi precedenti.",
|
||||||
|
"schema": BLANK_TEMPLATE,
|
||||||
|
},
|
||||||
|
"restart": {
|
||||||
|
"template_id": "restart",
|
||||||
|
"label": "RE-START (fondo prestiti con remissione del debito)",
|
||||||
|
"description": "Template del bando RE-START: 3 categorie B1/B2/B3 (tecnologie, ULA, formazione), sezione ULA attiva con soglia 1.0, 4 documenti standard, max 2 tranches.",
|
||||||
|
"schema": RESTART_TEMPLATE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_templates():
|
||||||
|
"""Restituisce i template disponibili (senza lo schema completo, solo metadati)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"template_id": t["template_id"],
|
||||||
|
"label": t["label"],
|
||||||
|
"description": t["description"],
|
||||||
|
}
|
||||||
|
for t in TEMPLATES.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_template(template_id: str):
|
||||||
|
"""Restituisce uno schema template pronto per l'uso (deep copy)."""
|
||||||
|
import copy
|
||||||
|
t = TEMPLATES.get(template_id)
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
return copy.deepcopy(t["schema"])
|
||||||
|
|||||||
582
app/templates_jinja/verbale_istruttoria.html
Normal file
582
app/templates_jinja/verbale_istruttoria.html
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Verbale di istruttoria — Pratica {{ practice.application_id }}</title>
|
||||||
|
<style>
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 22mm 18mm 20mm 18mm;
|
||||||
|
@top-left {
|
||||||
|
content: "GEPAFIN S.p.A.";
|
||||||
|
font-family: "DejaVu Sans", Helvetica, sans-serif;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #4a5568;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
@top-right {
|
||||||
|
content: "Verbale di istruttoria — Pratica {{ practice.application_id }}";
|
||||||
|
font-family: "DejaVu Sans", Helvetica, sans-serif;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
@bottom-center {
|
||||||
|
content: "Pagina " counter(page) " di " counter(pages);
|
||||||
|
font-family: "DejaVu Sans", Helvetica, sans-serif;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
@bottom-right {
|
||||||
|
content: "Generato: {{ generated_at }}";
|
||||||
|
font-family: "DejaVu Sans", Helvetica, sans-serif;
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html { font-family: "DejaVu Sans", Helvetica, sans-serif; font-size: 10pt; color: #1a202c; }
|
||||||
|
body { margin: 0; }
|
||||||
|
|
||||||
|
h1 { font-size: 18pt; margin: 0 0 4pt 0; color: #1a365d; letter-spacing: -0.3px; }
|
||||||
|
h2 { font-size: 12pt; margin: 18pt 0 6pt 0; padding: 4pt 0 4pt 8pt;
|
||||||
|
border-left: 3pt solid #2b6cb0; color: #1a365d; page-break-after: avoid; }
|
||||||
|
h3 { font-size: 10pt; margin: 12pt 0 4pt 0; color: #2d3748; page-break-after: avoid; }
|
||||||
|
p { margin: 4pt 0; line-height: 1.4; }
|
||||||
|
small { color: #4a5568; }
|
||||||
|
|
||||||
|
/* Intestazione GEPAFIN */
|
||||||
|
.hdr {
|
||||||
|
border-bottom: 2pt solid #1a365d;
|
||||||
|
padding-bottom: 10pt;
|
||||||
|
margin-bottom: 14pt;
|
||||||
|
}
|
||||||
|
.hdr__logo-img {
|
||||||
|
height: 38pt; width: auto;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.hdr__subtitle {
|
||||||
|
font-size: 9pt; color: #4a5568; margin-top: 2pt; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.hdr__right {
|
||||||
|
float: right; text-align: right; font-size: 9pt; color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge esito */
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 3pt 8pt; border-radius: 4pt;
|
||||||
|
font-size: 10pt; font-weight: 700; letter-spacing: 0.4pt;
|
||||||
|
}
|
||||||
|
.badge--approved { background: #c6f6d5; color: #22543d; border: 0.5pt solid #68d391; }
|
||||||
|
.badge--rejected { background: #fed7d7; color: #742a2a; border: 0.5pt solid #fc8181; }
|
||||||
|
.badge--review { background: #fefcbf; color: #744210; border: 0.5pt solid #ecc94b; }
|
||||||
|
.badge--amendment { background: #feebc8; color: #7b341e; border: 0.5pt solid #f6ad55; }
|
||||||
|
|
||||||
|
/* Grid dati pratica */
|
||||||
|
.meta-grid {
|
||||||
|
display: table; width: 100%; border-collapse: collapse;
|
||||||
|
margin: 8pt 0 4pt 0; background: #f7fafc; border: 0.5pt solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.meta-grid .row { display: table-row; }
|
||||||
|
.meta-grid .cell { display: table-cell; padding: 5pt 8pt;
|
||||||
|
border-bottom: 0.3pt solid #edf2f7; vertical-align: top; }
|
||||||
|
.meta-grid .cell.label { width: 32%; font-weight: 700; color: #4a5568; font-size: 9pt; background: #edf2f7; }
|
||||||
|
.meta-grid .cell.val { font-size: 10pt; }
|
||||||
|
|
||||||
|
/* Tabelle fatture / ULA / doc */
|
||||||
|
table.data {
|
||||||
|
width: 100%; border-collapse: collapse; margin: 4pt 0 8pt 0;
|
||||||
|
font-size: 8.5pt; border: 0.5pt solid #cbd5e0;
|
||||||
|
}
|
||||||
|
table.data th {
|
||||||
|
background: #2d3748; color: white; padding: 4pt 6pt;
|
||||||
|
font-weight: 600; text-align: left; border-right: 0.3pt solid #4a5568;
|
||||||
|
}
|
||||||
|
table.data td {
|
||||||
|
padding: 4pt 6pt; border-bottom: 0.3pt solid #e2e8f0;
|
||||||
|
border-right: 0.3pt solid #edf2f7; vertical-align: top;
|
||||||
|
}
|
||||||
|
table.data td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
table.data tr.subheader td {
|
||||||
|
background: #ebf4ff; color: #2a4365; font-weight: 700;
|
||||||
|
border-top: 0.5pt solid #4299e1; border-bottom: 0.5pt solid #4299e1;
|
||||||
|
padding: 5pt 6pt; font-size: 9pt;
|
||||||
|
}
|
||||||
|
table.data tr.totals td {
|
||||||
|
background: #f7fafc; font-weight: 700; border-top: 0.8pt solid #2d3748;
|
||||||
|
}
|
||||||
|
table.data tr.rejected td {
|
||||||
|
background: #fff5f5; color: #742a2a;
|
||||||
|
}
|
||||||
|
table.data tr.partial td {
|
||||||
|
background: #fffaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stato tag inline */
|
||||||
|
.status-inline {
|
||||||
|
display: inline-block; padding: 1pt 5pt; border-radius: 3pt;
|
||||||
|
font-size: 7.5pt; font-weight: 700; letter-spacing: 0.3pt;
|
||||||
|
}
|
||||||
|
.status-AMMESSA, .status-VALIDO { background: #c6f6d5; color: #22543d; }
|
||||||
|
.status-PARZIALE { background: #feebc8; color: #7b341e; }
|
||||||
|
.status-RESPINTA, .status-NON_VALIDO, .status-SCADUTO { background: #fed7d7; color: #742a2a; }
|
||||||
|
.status-PENDING { background: #edf2f7; color: #4a5568; }
|
||||||
|
|
||||||
|
.note-box {
|
||||||
|
margin: 4pt 0 6pt 0;
|
||||||
|
padding: 6pt 8pt;
|
||||||
|
background: #fffaf0;
|
||||||
|
border-left: 2pt solid #ed8936;
|
||||||
|
font-size: 9pt; font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals-summary {
|
||||||
|
display: table; width: 100%; margin: 10pt 0;
|
||||||
|
background: #f7fafc; border: 0.5pt solid #cbd5e0;
|
||||||
|
}
|
||||||
|
.totals-summary .row { display: table-row; }
|
||||||
|
.totals-summary .cell {
|
||||||
|
display: table-cell; padding: 8pt 10pt; width: 25%;
|
||||||
|
border-right: 0.3pt solid #e2e8f0; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.totals-summary .cell:last-child { border-right: none; }
|
||||||
|
.totals-summary .lbl { font-size: 8pt; color: #4a5568; text-transform: uppercase; letter-spacing: 0.3pt; }
|
||||||
|
.totals-summary .val { font-size: 14pt; font-weight: 700; color: #1a365d; margin-top: 2pt; }
|
||||||
|
.totals-summary .val.final { color: #2b6cb0; }
|
||||||
|
.totals-summary .val.residuo { color: #c53030; }
|
||||||
|
|
||||||
|
.amend-box {
|
||||||
|
border: 0.5pt solid #f6ad55; border-left: 2pt solid #ed8936;
|
||||||
|
background: #fffaf0; padding: 6pt 10pt; margin: 4pt 0 6pt 0;
|
||||||
|
}
|
||||||
|
.amend-box .head { font-size: 9pt; color: #744210; margin-bottom: 4pt; }
|
||||||
|
.amend-box .req, .amend-box .resp { font-size: 9pt; margin: 3pt 0; white-space: pre-wrap; }
|
||||||
|
.amend-box .resp { padding: 4pt 6pt; background: white; border-radius: 2pt; }
|
||||||
|
|
||||||
|
/* Firma */
|
||||||
|
.sign-block {
|
||||||
|
margin-top: 18pt;
|
||||||
|
display: table; width: 100%;
|
||||||
|
}
|
||||||
|
.sign-block .col { display: table-cell; width: 50%; padding: 6pt 10pt; vertical-align: top; }
|
||||||
|
.sign-block .lbl { font-size: 9pt; color: #4a5568; font-weight: 700; letter-spacing: 0.3pt; }
|
||||||
|
.sign-block .val { font-size: 10pt; margin-top: 2pt; }
|
||||||
|
.sign-block .sig-line {
|
||||||
|
margin-top: 30pt;
|
||||||
|
border-top: 0.5pt solid #2d3748;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #4a5568;
|
||||||
|
padding-top: 2pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok { color: #22543d; font-weight: 700; }
|
||||||
|
.ko { color: #c53030; font-weight: 700; }
|
||||||
|
.text-secondary { color: #718096; }
|
||||||
|
|
||||||
|
/* Clearfix per header float */
|
||||||
|
.clearfix::after { content: ""; display: table; clear: both; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hdr clearfix">
|
||||||
|
<div class="hdr__right">
|
||||||
|
<div><strong>Verbale di istruttoria</strong></div>
|
||||||
|
<div>Rendicontazione bando</div>
|
||||||
|
<div>Pratica n. {{ practice.application_id }}{% if max_tranches_snapshot > 1 or practice.sequence_number > 1 %} — Tranche {{ practice.sequence_number }}/{{ max_tranches_snapshot }}{% endif %}</div>
|
||||||
|
{% if practice.period_label %}<div><small>{{ practice.period_label }}</small></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<img src="{{ logo_path }}" alt="Gepafin" class="hdr__logo-img" />
|
||||||
|
<div class="hdr__subtitle">Finanziaria regionale dell'Umbria</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Verbale di istruttoria — Rendicontazione</h1>
|
||||||
|
<p>
|
||||||
|
{% if practice.status == 'APPROVED' %}
|
||||||
|
<span class="badge badge--approved">ESITO: APPROVATA</span>
|
||||||
|
{% elif practice.status == 'REJECTED' %}
|
||||||
|
<span class="badge badge--rejected">ESITO: RESPINTA</span>
|
||||||
|
{% elif practice.status == 'AWAITING_AMENDMENT' %}
|
||||||
|
<span class="badge badge--amendment">SOCCORSO ISTRUTTORIO IN CORSO</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge--review">IN ISTRUTTORIA</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Dati pratica</h2>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Bando</div>
|
||||||
|
<div class="cell val">{{ practice.schema_snapshot.template_label or ('Bando #' ~ practice.call_id) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Numero applicazione</div>
|
||||||
|
<div class="cell val">#{{ practice.application_id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Beneficiario</div>
|
||||||
|
<div class="cell val">
|
||||||
|
{{ company.company_name or '(non disponibile)' }}
|
||||||
|
{% if company.vat_number %} · P.IVA {{ company.vat_number }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Regime IVA</div>
|
||||||
|
<div class="cell val">{{ practice.iva_regime or '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Importo erogato</div>
|
||||||
|
<div class="cell val">{{ practice.amount_erogato|euro }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Periodo rendicontazione</div>
|
||||||
|
<div class="cell val">
|
||||||
|
{% set period = practice.schema_snapshot.get('period') or {} %}
|
||||||
|
{% if period.get('start_date') and period.get('end_date') %}
|
||||||
|
{{ period.start_date|datefmt }} — {{ period.end_date|datefmt }}
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if max_tranches_snapshot > 1 %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Tranche / fase</div>
|
||||||
|
<div class="cell val">
|
||||||
|
<strong>Tranche {{ practice.sequence_number }} di {{ max_tranches_snapshot }}</strong>
|
||||||
|
{% if practice.period_label %} — {{ practice.period_label }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Data presentazione</div>
|
||||||
|
<div class="cell val">{{ practice.submitted_at|datetimefmt if practice.submitted_at else '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell label">Data istruttoria</div>
|
||||||
|
<div class="cell val">{{ practice.reviewed_at|datetimefmt if practice.reviewed_at else generated_at }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ============ FATTURE ============ #}
|
||||||
|
<h2>Verifica fatture</h2>
|
||||||
|
{% if practice.invoices %}
|
||||||
|
{% set use_taxable = totals.use_taxable_only is not sameas false %}
|
||||||
|
<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:9%">N°</th>
|
||||||
|
<th style="width:9%">Data</th>
|
||||||
|
<th style="width:24%">Fornitore / Descrizione</th>
|
||||||
|
<th style="width:12%" class="num">{{ 'Imponibile' if use_taxable else 'Totale' }} dichiarato</th>
|
||||||
|
<th style="width:12%" class="num">{{ 'Imponibile' if use_taxable else 'Totale' }} ammesso</th>
|
||||||
|
<th style="width:10%">Stato</th>
|
||||||
|
<th style="width:24%">Motivazione istruttore</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cat_code, items in invoices_by_cat.items() %}
|
||||||
|
{% set cat_label = categories_map.get(cat_code, cat_code) %}
|
||||||
|
{% set cat_decl = per_cat_declared.get(cat_code, 0) %}
|
||||||
|
{% set cat_verif = per_cat_verified.get(cat_code, 0) %}
|
||||||
|
<tr class="subheader">
|
||||||
|
<td colspan="3"><strong>{{ cat_code }}</strong> — {{ cat_label }}</td>
|
||||||
|
<td class="num">{{ cat_decl|euro }}</td>
|
||||||
|
<td class="num">{{ cat_verif|euro }}</td>
|
||||||
|
<td colspan="2"><small>{{ items|length }} fatture</small></td>
|
||||||
|
</tr>
|
||||||
|
{% for inv in items %}
|
||||||
|
{% set cls = 'rejected' if inv.verification_status == 'RESPINTA' else ('partial' if inv.verification_status == 'PARZIALE' else '') %}
|
||||||
|
{% set declared = inv.taxable if use_taxable else inv.total %}
|
||||||
|
{% if inv.verification_status == 'PENDING' %}
|
||||||
|
{% set verified_val = None %}
|
||||||
|
{% elif inv.verification_status == 'RESPINTA' %}
|
||||||
|
{% set verified_val = 0 %}
|
||||||
|
{% elif use_taxable %}
|
||||||
|
{% set verified_val = inv.taxable_verified if inv.taxable_verified is not none else declared %}
|
||||||
|
{% else %}
|
||||||
|
{% set verified_val = inv.total_verified if inv.total_verified is not none else declared %}
|
||||||
|
{% endif %}
|
||||||
|
<tr class="{{ cls }}">
|
||||||
|
<td>{{ inv.invoice_number }}</td>
|
||||||
|
<td>{{ inv.invoice_date|datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ inv.supplier_name }}</strong><br>
|
||||||
|
<small>{{ inv.description|truncate(80) }}</small>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ declared|euro }}</td>
|
||||||
|
<td class="num">{{ verified_val|euro if verified_val is not none else '—' }}</td>
|
||||||
|
<td><span class="status-inline status-{{ inv.verification_status }}">{{ inv.verification_status }}</span></td>
|
||||||
|
<td>{{ inv.verification_notes or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
<tr class="totals">
|
||||||
|
<td colspan="3"><strong>Totale complessivo</strong></td>
|
||||||
|
<td class="num">{{ totals.grand_total_declared|euro }}</td>
|
||||||
|
<td class="num">{{ totals.grand_total_verified|euro }}</td>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-secondary">Nessuna fattura rendicontata.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ============ ULA ============ #}
|
||||||
|
{% if ula_section_enabled and practice.ula_employees %}
|
||||||
|
<h2>Verifica dipendenti ULA</h2>
|
||||||
|
<p><small>Soglia incremento richiesta: <strong>≥ {{ '%.2f'|format(ula_threshold) }}</strong> · FTE dichiarato: <strong>{{ '%.2f'|format(ula_fte_decl) }}</strong> · FTE ammesso: <strong class="{{ 'ok' if ula_ok else 'ko' }}">{{ '%.2f'|format(ula_fte_verif) }}</strong></small></p>
|
||||||
|
<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:16%">CF</th>
|
||||||
|
<th style="width:18%">Dipendente</th>
|
||||||
|
<th style="width:14%">Contratto</th>
|
||||||
|
<th style="width:16%">Periodo</th>
|
||||||
|
<th style="width:8%" class="num">FTE dich.</th>
|
||||||
|
<th style="width:8%" class="num">FTE amm.</th>
|
||||||
|
<th style="width:8%">Stato</th>
|
||||||
|
<th style="width:12%">Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for emp in practice.ula_employees %}
|
||||||
|
{% set cls = 'rejected' if emp.verification_status == 'RESPINTA' else ('partial' if emp.verification_status == 'PARZIALE' else '') %}
|
||||||
|
{% set fte_verif = emp.fte_pct_verified if emp.fte_pct_verified is not none else emp.fte_pct %}
|
||||||
|
<tr class="{{ cls }}">
|
||||||
|
<td>{{ emp.codice_fiscale }}</td>
|
||||||
|
<td>{{ emp.full_name }}</td>
|
||||||
|
<td>{{ contract_labels.get(emp.contract_type, emp.contract_type) }}</td>
|
||||||
|
<td>{{ emp.period_start_date|datefmt }}<br><small>→ {{ emp.period_end_date|datefmt }}</small></td>
|
||||||
|
<td class="num">{{ '%.2f'|format(emp.fte_pct|float) }}</td>
|
||||||
|
<td class="num">
|
||||||
|
{% if emp.verification_status == 'PENDING' %}—
|
||||||
|
{% elif emp.verification_status == 'RESPINTA' %}0.00
|
||||||
|
{% else %}{{ '%.2f'|format(fte_verif|float) }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="status-inline status-{{ emp.verification_status }}">{{ emp.verification_status }}</span></td>
|
||||||
|
<td>{{ emp.verification_notes|default('—') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ============ DOCUMENTI ============ #}
|
||||||
|
<h2>Verifica documenti</h2>
|
||||||
|
{% if docs_required %}
|
||||||
|
<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:25%">Documento</th>
|
||||||
|
<th style="width:20%">File allegato</th>
|
||||||
|
<th style="width:12%">Esito</th>
|
||||||
|
<th style="width:43%">Note istruttore</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for dr in docs_required %}
|
||||||
|
{% set doc = docs_by_code.get(dr.code, {}) %}
|
||||||
|
{% set stat = doc.verification_status or 'PENDING' %}
|
||||||
|
{% set cls = 'rejected' if stat in ('NON_VALIDO', 'SCADUTO') else '' %}
|
||||||
|
<tr class="{{ cls }}">
|
||||||
|
<td><strong>{{ dr.label }}</strong><br><small>{{ dr.code }}</small></td>
|
||||||
|
<td>
|
||||||
|
{% if doc.filename %}<i>{{ doc.filename }}</i>
|
||||||
|
{% else %}<span class="ko">non caricato</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="status-inline status-{{ stat }}">{{ stat }}</span></td>
|
||||||
|
<td>{{ doc.verification_notes|default('—') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-secondary">Nessun documento richiesto dallo schema del bando.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ============ CONTROLLI AGGIUNTIVI ============ #}
|
||||||
|
{% if custom_checks_merged %}
|
||||||
|
<h2>Controlli aggiuntivi (dichiarazioni beneficiario)</h2>
|
||||||
|
<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:32%">Controllo</th>
|
||||||
|
<th style="width:12%">Obbligatorio</th>
|
||||||
|
<th style="width:10%">Dichiarato</th>
|
||||||
|
<th style="width:11%">Doc. allegato</th>
|
||||||
|
<th style="width:11%">Validazione</th>
|
||||||
|
<th style="width:24%">Note istruttore</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for cc in custom_checks_merged %}
|
||||||
|
{% set stat = cc.verification_status or 'PENDING' %}
|
||||||
|
{% set cls = 'rejected' if stat == 'NON_VALIDO' else '' %}
|
||||||
|
{% set missing = cc.required and not cc.beneficiary_declared %}
|
||||||
|
<tr class="{{ 'rejected' if missing else cls }}">
|
||||||
|
<td>
|
||||||
|
<strong>{{ cc.label or cc.code }}</strong>
|
||||||
|
{% if cc.description %}<br><small>{{ cc.description|truncate(180) }}</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{% if cc.required %}<span class="ko">SÌ</span>{% else %}<small class="text-secondary">opzionale</small>{% endif %}</td>
|
||||||
|
<td>{% if cc.beneficiary_declared %}<span class="ok">SÌ</span>{% else %}<span class="ko">NO</span>{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
{% if cc.requires_document %}
|
||||||
|
{% if cc.has_document %}<span class="ok">SÌ</span>{% else %}<span class="ko">NO</span>{% endif %}
|
||||||
|
{% else %}<small class="text-secondary">non richiesto</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><span class="status-inline status-{{ stat }}">{{ stat }}</span></td>
|
||||||
|
<td>{{ cc.verification_notes or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ============ SOCCORSI ============ #}
|
||||||
|
{% if amendments %}
|
||||||
|
<h2>Soccorso istruttorio</h2>
|
||||||
|
{% for a in amendments %}
|
||||||
|
<div class="amend-box">
|
||||||
|
<div class="head">
|
||||||
|
<strong>{{ a.status|amendstatus }}</strong>
|
||||||
|
· deadline {{ a.deadline|datefmt }}
|
||||||
|
· aperto il {{ a.created_at|datetimefmt }}
|
||||||
|
{% if a.closed_at %} · chiuso il {{ a.closed_at|datetimefmt }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="req"><strong>Richiesta istruttore:</strong><br>{{ a.request_text }}</div>
|
||||||
|
{% if a.response_text %}
|
||||||
|
<div class="resp"><strong>Risposta beneficiario</strong> ({{ a.response_at|datetimefmt }}):<br>{{ a.response_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ============ STORICO TRANCHES PRECEDENTI ============ #}
|
||||||
|
{% if previous_tranches %}
|
||||||
|
<h2>Storico tranches precedenti</h2>
|
||||||
|
<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:8%">#</th>
|
||||||
|
<th style="width:35%">Periodo / fase</th>
|
||||||
|
<th style="width:17%">Data approvazione</th>
|
||||||
|
<th style="width:20%" class="num">Importo ammesso</th>
|
||||||
|
<th style="width:20%" class="num">Cumulativo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for t in previous_tranches %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>T{{ t.sequence_number }}</strong></td>
|
||||||
|
<td>{{ t.period_label or '—' }}</td>
|
||||||
|
<td>{{ t.reviewed_at|datefmt }}</td>
|
||||||
|
<td class="num">{{ t.approved_remission|euro }}</td>
|
||||||
|
<td class="num"><strong>{{ t.cumulative|euro }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ============ 5 VOCI UFFICIALI CECILIA ============ #}
|
||||||
|
<h2>Riepilogo finanziario (cap tranche {{ practice.sequence_number }})</h2>
|
||||||
|
|
||||||
|
<div class="totals-summary">
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell" style="width:50%">
|
||||||
|
<div class="lbl">(1) Importo massimo ammissibile (cap globale)</div>
|
||||||
|
<div class="val">{{ totals.max_remission_global|euro }}</div>
|
||||||
|
{% if totals.already_approved_previous_tranches > 0 %}
|
||||||
|
<div class="lbl" style="margin-top:4pt">già approvato nelle tranche precedenti</div>
|
||||||
|
<div style="font-size:10pt; font-weight:700; color:#744210">− {{ totals.already_approved_previous_tranches|euro }}</div>
|
||||||
|
<div class="lbl" style="margin-top:4pt">max. disponibile per questa tranche</div>
|
||||||
|
<div style="font-size:11pt; font-weight:700; color:#2b6cb0">= {{ totals.max_remission_this_tranche|euro }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="cell" style="width:50%">
|
||||||
|
<div class="lbl">(4) Importo finanziamento erogato</div>
|
||||||
|
<div class="val">{{ totals.amount_erogato|euro }}</div>
|
||||||
|
<div class="lbl" style="margin-top:6pt">tranches complessive</div>
|
||||||
|
<div style="font-size:10pt">{{ totals.tranches_count }} / {{ totals.tranches_max }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="totals-summary" style="margin-top:8pt">
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell" style="width:33%">
|
||||||
|
<div class="lbl">(2) Richiesto pre-controllo (ammissibile)</div>
|
||||||
|
<div class="val">{{ totals.pre_check_admissible|euro }}</div>
|
||||||
|
<div class="lbl" style="margin-top:4pt">dichiarato tranche</div>
|
||||||
|
<div style="font-size:9pt">{{ totals.grand_total_declared|euro }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cell" style="width:33%">
|
||||||
|
<div class="lbl">(3) Ammesso post-controllo istruttore</div>
|
||||||
|
<div class="val final">{{ totals.remission_due|euro }}</div>
|
||||||
|
{% if totals.any_verified %}
|
||||||
|
<div class="lbl" style="margin-top:4pt">verificato tranche</div>
|
||||||
|
<div style="font-size:9pt">{{ totals.grand_total_verified|euro }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="lbl" style="margin-top:4pt"><em>in attesa di verifica istruttore</em></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="cell" style="width:34%; background:#fff5f5">
|
||||||
|
<div class="lbl">(5) Residuo da restituire</div>
|
||||||
|
<div class="val residuo">{{ totals.residuo_da_restituire|euro }}</div>
|
||||||
|
<div class="lbl" style="margin-top:4pt">= erogato − approvato precedente − ammesso tranche</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if practice.status == 'APPROVED' %}
|
||||||
|
<div class="totals-summary" style="margin-top:8pt; background:#f0fff4; border-color:#68d391">
|
||||||
|
<div class="row">
|
||||||
|
<div class="cell" style="width:100%">
|
||||||
|
<div class="lbl">REMISSIONE APPROVATA PER QUESTA TRANCHE</div>
|
||||||
|
<div class="val" style="color:#22543d; font-size:18pt">{{ practice.approved_remission|euro }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ============ CHECKLIST + NOTE ============ #}
|
||||||
|
{% set checklist = practice.instructor_checklist or {} %}
|
||||||
|
{% if checklist %}
|
||||||
|
<h3>Checklist finale istruttore</h3>
|
||||||
|
<ul style="font-size:9pt; margin: 4pt 0 6pt 14pt;">
|
||||||
|
<li>Documentazione completa e coerente: {{ 'SÌ' if checklist.get('domanda_completa') else 'NO' }}</li>
|
||||||
|
<li>Incremento ULA > 1 verificato: {{ 'SÌ' if checklist.get('ula_ok') else 'NO' }}</li>
|
||||||
|
<li>Importo erogato entro il range bando: {{ 'SÌ' if checklist.get('erogato_in_range') else 'NO' }}</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if practice.instructor_final_notes %}
|
||||||
|
<h3>Note sintetiche di istruttoria</h3>
|
||||||
|
<div class="note-box">{{ practice.instructor_final_notes }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if practice.rejection_reason %}
|
||||||
|
<h3>Motivazione del rigetto</h3>
|
||||||
|
<div class="note-box" style="background:#fff5f5; border-left-color:#fc8181;">{{ practice.rejection_reason }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ============ FIRMA ============ #}
|
||||||
|
<div class="sign-block">
|
||||||
|
<div class="col">
|
||||||
|
<div class="lbl">LUOGO E DATA</div>
|
||||||
|
<div class="val">Perugia, {{ generated_at }}</div>
|
||||||
|
<div class="sig-line"> </div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="lbl">ISTRUTTORE</div>
|
||||||
|
<div class="val">{{ instructor_name or '(firma)' }}</div>
|
||||||
|
<div class="sig-line">Firma</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,3 +6,7 @@ pydantic==2.6.3
|
|||||||
pydantic-settings==2.2.1
|
pydantic-settings==2.2.1
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
|
weasyprint==61.2
|
||||||
|
pydyf==0.10.0
|
||||||
|
jinja2==3.1.3
|
||||||
|
APScheduler==3.10.4
|
||||||
|
|||||||
661
scripts/seed_sandbox.py
Normal file
661
scripts/seed_sandbox.py
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Seed sandbox riproducibile per Gepafin rendicontazione.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python /app/scripts/seed_sandbox.py --reset --scenario=napoli-sas
|
||||||
|
python /app/scripts/seed_sandbox.py --reset --scenario=napoli-sas --advance=under_review
|
||||||
|
|
||||||
|
Scenari disponibili:
|
||||||
|
napoli-sas : pratica completa con 5 fatture (B1/B2/B3), 2 ULA, 4 documenti,
|
||||||
|
PDF fixture generati e caricati nello storage /var/uploads.
|
||||||
|
|
||||||
|
Advance (opzionale, default = draft):
|
||||||
|
draft : lascia la pratica in DRAFT (stato beneficiario)
|
||||||
|
submitted : pratica inviata, pronta a essere presa in carico dall'istruttore
|
||||||
|
under_review : istruttore l'ha presa in carico, pronta per verifica
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, '/app')
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from app.db import engine, SessionLocal
|
||||||
|
from app.models import (
|
||||||
|
CallRemissionSchema,
|
||||||
|
RemissionPractice,
|
||||||
|
RemissionInvoice,
|
||||||
|
RemissionUlaEmployee,
|
||||||
|
RemissionDocument,
|
||||||
|
RemissionAmendmentRequest,
|
||||||
|
RemissionCustomCheckValue,
|
||||||
|
)
|
||||||
|
from app.storage import save_upload, BASE_PATH
|
||||||
|
from app.templates import RESTART_TEMPLATE
|
||||||
|
|
||||||
|
CALL_ID = 1
|
||||||
|
COMPANY_ID = 1
|
||||||
|
BENEFICIARY_USER_ID = 9 # beneficiario@sandbox.local
|
||||||
|
INSTRUCTOR_USER_ID = 10 # istruttore@sandbox.local
|
||||||
|
MANAGER_USER_ID = 11 # manager@sandbox.local
|
||||||
|
APPLICATION_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PDF fixture generator (weasyprint)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def make_pdf_bytes(title: str, subtitle: str, lines: list[str]) -> bytes:
|
||||||
|
from weasyprint import HTML
|
||||||
|
rows = "".join(f"<tr><td>{i+1}</td><td>{ln}</td></tr>" for i, ln in enumerate(lines))
|
||||||
|
html = f"""
|
||||||
|
<html><head><meta charset="utf-8"><style>
|
||||||
|
@page {{ size: A4; margin: 2cm; }}
|
||||||
|
body {{ font-family: "DejaVu Sans", sans-serif; color: #1a202c; }}
|
||||||
|
h1 {{ color: #1a365d; font-size: 18pt; margin: 0 0 4pt 0; }}
|
||||||
|
h2 {{ color: #4a5568; font-size: 11pt; margin: 0 0 18pt 0; font-weight: normal; }}
|
||||||
|
table {{ width: 100%; border-collapse: collapse; margin-top: 12pt; }}
|
||||||
|
th, td {{ border: 0.5pt solid #cbd5e0; padding: 6pt 10pt; text-align: left; font-size: 10pt; }}
|
||||||
|
th {{ background: #2d3748; color: white; }}
|
||||||
|
.stamp {{ margin-top: 40pt; padding: 10pt; border: 1pt solid #4299e1;
|
||||||
|
background: #ebf4ff; color: #2a4365; font-size: 9pt; }}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<h2>{subtitle}</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th style="width:50pt">#</th><th>Voce</th></tr></thead>
|
||||||
|
<tbody>{rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="stamp">
|
||||||
|
<strong>FIXTURE DEMO</strong> — documento generato automaticamente
|
||||||
|
da seed_sandbox.py il {datetime.now().strftime('%d/%m/%Y %H:%M')}.
|
||||||
|
Questo PDF è un placeholder a scopo dimostrativo per la sandbox Gepafin.
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
|
"""
|
||||||
|
return HTML(string=html).write_pdf()
|
||||||
|
|
||||||
|
|
||||||
|
def attach_pdf(db, entity, entity_type: str, application_id: int,
|
||||||
|
filename: str, title: str, subtitle: str, lines: list[str], uploader_id: int):
|
||||||
|
"""Genera PDF e lo carica tramite lo storage adapter, aggiorna entity."""
|
||||||
|
pdf = make_pdf_bytes(title, subtitle, lines)
|
||||||
|
rel_path, size, digest, mime, safe_name = save_upload(
|
||||||
|
application_id=application_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity.id,
|
||||||
|
file_obj=io.BytesIO(pdf),
|
||||||
|
original_filename=filename,
|
||||||
|
content_type='application/pdf',
|
||||||
|
)
|
||||||
|
entity.storage_path = rel_path
|
||||||
|
entity.mime = mime
|
||||||
|
entity.size_bytes = size
|
||||||
|
entity.sha256 = digest
|
||||||
|
entity.uploaded_by = uploader_id
|
||||||
|
if hasattr(entity, 'uploaded_at'):
|
||||||
|
entity.uploaded_at = datetime.now(timezone.utc)
|
||||||
|
# filename originale specifico per tipo
|
||||||
|
if entity_type == 'invoice':
|
||||||
|
entity.pdf_filename = safe_name
|
||||||
|
elif entity_type == 'ula':
|
||||||
|
entity.supporting_doc_filename = safe_name
|
||||||
|
elif entity_type == 'document':
|
||||||
|
entity.filename = safe_name
|
||||||
|
entity.uploaded_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_assigned_application(db):
|
||||||
|
"""Popola gepafin_schema.assigned_applications per abilitare suggested_instructor_id
|
||||||
|
alla creazione della prima tranche. Idempotente."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
existing = db.execute(text("""
|
||||||
|
SELECT id FROM gepafin_schema.assigned_applications
|
||||||
|
WHERE application_id = :aid AND user_id = :uid AND is_deleted = false
|
||||||
|
"""), {"aid": APPLICATION_ID, "uid": INSTRUCTOR_USER_ID}).scalar()
|
||||||
|
if existing:
|
||||||
|
print(f"[assigned_applications] gia presente (id={existing})")
|
||||||
|
return
|
||||||
|
db.execute(text("""
|
||||||
|
INSERT INTO gepafin_schema.assigned_applications
|
||||||
|
(user_id, assigned_by, application_id, status, is_deleted, assigned_at, created_date, updated_date)
|
||||||
|
VALUES (:uid, :admin, :aid, 'ASSIGNED', false, NOW(), NOW(), NOW())
|
||||||
|
"""), {"uid": INSTRUCTOR_USER_ID, "admin": 8, "aid": APPLICATION_ID})
|
||||||
|
db.commit()
|
||||||
|
print(f"[assigned_applications] user={INSTRUCTOR_USER_ID} assegnato a application={APPLICATION_ID}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def do_reset(scope: str):
|
||||||
|
"""TRUNCATE tabelle remission_* e pulizia storage. scope: 'all' | 'practices'."""
|
||||||
|
print(f"[reset] scope={scope}")
|
||||||
|
with engine.begin() as conn:
|
||||||
|
if scope == 'all':
|
||||||
|
conn.execute(text("""
|
||||||
|
TRUNCATE
|
||||||
|
gepafin_rendic.remission_custom_check_value,
|
||||||
|
gepafin_rendic.remission_amendment_request,
|
||||||
|
gepafin_rendic.remission_document,
|
||||||
|
gepafin_rendic.remission_ula_employee,
|
||||||
|
gepafin_rendic.remission_invoice,
|
||||||
|
gepafin_rendic.remission_practice
|
||||||
|
RESTART IDENTITY CASCADE
|
||||||
|
"""))
|
||||||
|
print("[reset] tabelle remission_* truncate-ate")
|
||||||
|
else:
|
||||||
|
conn.execute(text("""
|
||||||
|
DELETE FROM gepafin_rendic.remission_amendment_request;
|
||||||
|
DELETE FROM gepafin_rendic.remission_document;
|
||||||
|
DELETE FROM gepafin_rendic.remission_ula_employee;
|
||||||
|
DELETE FROM gepafin_rendic.remission_invoice;
|
||||||
|
DELETE FROM gepafin_rendic.remission_practice;
|
||||||
|
"""))
|
||||||
|
# Pulizia storage
|
||||||
|
if BASE_PATH.exists():
|
||||||
|
for p in BASE_PATH.iterdir():
|
||||||
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p)
|
||||||
|
elif p.is_file() and not p.name.startswith('.'):
|
||||||
|
p.unlink()
|
||||||
|
print(f"[reset] {BASE_PATH} pulito")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema RE-START pubblicato
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def ensure_schema_published(db):
|
||||||
|
schema_row = db.query(CallRemissionSchema).filter(
|
||||||
|
CallRemissionSchema.call_id == CALL_ID
|
||||||
|
).first()
|
||||||
|
if not schema_row:
|
||||||
|
schema_row = CallRemissionSchema(
|
||||||
|
call_id=CALL_ID,
|
||||||
|
schema_version=1,
|
||||||
|
status='PUBLISHED',
|
||||||
|
schema_json=RESTART_TEMPLATE,
|
||||||
|
created_by=1,
|
||||||
|
published_at=datetime.now(timezone.utc),
|
||||||
|
published_by=1,
|
||||||
|
)
|
||||||
|
db.add(schema_row)
|
||||||
|
db.flush()
|
||||||
|
print("[schema] creato e pubblicato schema RE-START")
|
||||||
|
elif schema_row.status != 'PUBLISHED':
|
||||||
|
schema_row.status = 'PUBLISHED'
|
||||||
|
schema_row.published_at = datetime.now(timezone.utc)
|
||||||
|
schema_row.published_by = 1
|
||||||
|
print("[schema] schema RE-START promosso a PUBLISHED")
|
||||||
|
return schema_row
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scenario: NAPOLI SAS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def scenario_napoli_sas(db, advance='draft'):
|
||||||
|
"""
|
||||||
|
Pratica NAPOLI SAS completa.
|
||||||
|
5 fatture (2 B1 / 2 B2 / 1 B3), 2 ULA, 4 documenti. Tutti con PDF fixture allegato.
|
||||||
|
"""
|
||||||
|
schema_row = ensure_schema_published(db)
|
||||||
|
|
||||||
|
practice = RemissionPractice(
|
||||||
|
call_id=CALL_ID,
|
||||||
|
application_id=APPLICATION_ID,
|
||||||
|
company_id=COMPANY_ID,
|
||||||
|
user_id=BENEFICIARY_USER_ID,
|
||||||
|
status='DRAFT',
|
||||||
|
schema_snapshot=schema_row.schema_json,
|
||||||
|
iva_regime='ORDINARIO',
|
||||||
|
amount_erogato=Decimal('17000'),
|
||||||
|
notes_beneficiario='Pratica di rendicontazione bando RE-START — investimento digitale 2021.',
|
||||||
|
)
|
||||||
|
db.add(practice)
|
||||||
|
db.flush()
|
||||||
|
print(f"[practice] creata id={practice.id}")
|
||||||
|
|
||||||
|
invoices_data = [
|
||||||
|
dict(category_code='B1',
|
||||||
|
invoice_number='2021/487', invoice_date=date(2021, 3, 15), payment_date=date(2021, 3, 31),
|
||||||
|
supplier_name='Dell Italia S.r.l.', supplier_vat='IT12345678901',
|
||||||
|
description='Fornitura 5 workstation Precision 3660 per sviluppo software',
|
||||||
|
taxable=Decimal('3000'), vat=Decimal('660'), total=Decimal('3660'),
|
||||||
|
fname='ft_2021_487_dell.pdf'),
|
||||||
|
dict(category_code='B1',
|
||||||
|
invoice_number='2020/988', invoice_date=date(2020, 11, 20), payment_date=date(2020, 12, 10),
|
||||||
|
supplier_name='HP Italia S.p.A.', supplier_vat='IT98765432109',
|
||||||
|
description='2 laptop EliteBook 840 G7 + 3 monitor DreamColor',
|
||||||
|
taxable=Decimal('2000'), vat=Decimal('440'), total=Decimal('2440'),
|
||||||
|
fname='ft_2020_988_hp.pdf'),
|
||||||
|
dict(category_code='B2',
|
||||||
|
invoice_number='2021/58', invoice_date=date(2021, 5, 8), payment_date=date(2021, 5, 30),
|
||||||
|
supplier_name='Netcomm S.c. a r.l.', supplier_vat='IT07008690961',
|
||||||
|
description='Canone annuale Osservatorio eCommerce B2c 2021 + assicurazione accessoria',
|
||||||
|
taxable=Decimal('2500'), vat=Decimal('550'), total=Decimal('3050'),
|
||||||
|
fname='ft_2021_58_netcomm.pdf'),
|
||||||
|
dict(category_code='B2',
|
||||||
|
invoice_number='2021/115', invoice_date=date(2021, 7, 2), payment_date=date(2021, 7, 20),
|
||||||
|
supplier_name='Studio Romano & Associati', supplier_vat='IT03214569876',
|
||||||
|
description='Consulenza strategica digital transformation — 20 ore senior partner',
|
||||||
|
taxable=Decimal('1500'), vat=Decimal('330'), total=Decimal('1830'),
|
||||||
|
fname='ft_2021_115_romano.pdf'),
|
||||||
|
dict(category_code='B3',
|
||||||
|
invoice_number='2021/221', invoice_date=date(2021, 9, 15), payment_date=date(2021, 9, 30),
|
||||||
|
supplier_name='CertQuality S.r.l.', supplier_vat='IT11223344556',
|
||||||
|
description='Certificazione ISO 27001:2013 — audit + rilascio certificato triennale',
|
||||||
|
taxable=Decimal('2400'), vat=Decimal('528'), total=Decimal('2928'),
|
||||||
|
fname='ft_2021_221_certquality.pdf'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, d in enumerate(invoices_data):
|
||||||
|
inv = RemissionInvoice(
|
||||||
|
practice_id=practice.id,
|
||||||
|
category_code=d['category_code'],
|
||||||
|
invoice_number=d['invoice_number'],
|
||||||
|
invoice_date=d['invoice_date'],
|
||||||
|
payment_date=d['payment_date'],
|
||||||
|
supplier_name=d['supplier_name'],
|
||||||
|
supplier_vat=d['supplier_vat'],
|
||||||
|
description=d['description'],
|
||||||
|
taxable=d['taxable'],
|
||||||
|
vat=d['vat'],
|
||||||
|
total=d['total'],
|
||||||
|
)
|
||||||
|
db.add(inv)
|
||||||
|
db.flush()
|
||||||
|
attach_pdf(
|
||||||
|
db, inv, 'invoice', APPLICATION_ID,
|
||||||
|
filename=d['fname'],
|
||||||
|
title=f"Fattura n. {d['invoice_number']}",
|
||||||
|
subtitle=f"{d['supplier_name']} — {d['invoice_date'].strftime('%d/%m/%Y')}",
|
||||||
|
lines=[
|
||||||
|
f"Fornitore: {d['supplier_name']} — P.IVA {d['supplier_vat']}",
|
||||||
|
f"Descrizione: {d['description']}",
|
||||||
|
f"Imponibile: € {d['taxable']}",
|
||||||
|
f"IVA 22%: € {d['vat']}",
|
||||||
|
f"Totale: € {d['total']}",
|
||||||
|
f"Data fattura: {d['invoice_date'].strftime('%d/%m/%Y')}",
|
||||||
|
f"Data pagamento: {d['payment_date'].strftime('%d/%m/%Y')}",
|
||||||
|
],
|
||||||
|
uploader_id=BENEFICIARY_USER_ID,
|
||||||
|
)
|
||||||
|
print(f"[invoice] {i+1}/5 {d['category_code']} {d['invoice_number']} + PDF {inv.size_bytes}b")
|
||||||
|
|
||||||
|
ula_data = [
|
||||||
|
dict(cf='RSSMRA85T10H501Z', name='Mario Rossi', ctype='T_IND',
|
||||||
|
role='Sviluppatore senior', fte=Decimal('1.0000'),
|
||||||
|
start=date(2021, 1, 27), end=date(2021, 12, 31),
|
||||||
|
doc_type='LUL', fname='lul_rossi_2021.pdf'),
|
||||||
|
dict(cf='BNCLRA90A41F205D', name='Laura Bianchi', ctype='T_DET',
|
||||||
|
role='Digital marketing specialist', fte=Decimal('0.5000'),
|
||||||
|
start=date(2021, 4, 1), end=date(2021, 12, 31),
|
||||||
|
doc_type='LUL', fname='lul_bianchi_2021.pdf'),
|
||||||
|
]
|
||||||
|
for i, d in enumerate(ula_data):
|
||||||
|
emp = RemissionUlaEmployee(
|
||||||
|
practice_id=practice.id,
|
||||||
|
codice_fiscale=d['cf'],
|
||||||
|
full_name=d['name'],
|
||||||
|
contract_type=d['ctype'],
|
||||||
|
role_description=d['role'],
|
||||||
|
fte_pct=d['fte'],
|
||||||
|
period_start_date=d['start'],
|
||||||
|
period_end_date=d['end'],
|
||||||
|
supporting_doc_type=d['doc_type'],
|
||||||
|
)
|
||||||
|
db.add(emp)
|
||||||
|
db.flush()
|
||||||
|
attach_pdf(
|
||||||
|
db, emp, 'ula', APPLICATION_ID,
|
||||||
|
filename=d['fname'],
|
||||||
|
title=f"Libro Unico del Lavoro — {d['name']}",
|
||||||
|
subtitle=f"Periodo {d['start'].strftime('%d/%m/%Y')} — {d['end'].strftime('%d/%m/%Y')}",
|
||||||
|
lines=[
|
||||||
|
f"Codice fiscale: {d['cf']}",
|
||||||
|
f"Nome e cognome: {d['name']}",
|
||||||
|
f"Tipo contratto: {d['ctype']}",
|
||||||
|
f"Mansione: {d['role']}",
|
||||||
|
f"FTE: {float(d['fte']):.2f}",
|
||||||
|
f"Inizio rapporto: {d['start'].strftime('%d/%m/%Y')}",
|
||||||
|
f"Fine rapporto: {d['end'].strftime('%d/%m/%Y')}",
|
||||||
|
f"Tipo documento: {d['doc_type']}",
|
||||||
|
],
|
||||||
|
uploader_id=BENEFICIARY_USER_ID,
|
||||||
|
)
|
||||||
|
print(f"[ula] {i+1}/2 {d['name']} FTE={d['fte']} + PDF {emp.size_bytes}b")
|
||||||
|
|
||||||
|
docs_data = [
|
||||||
|
dict(code='DURC', label='DURC', fname='durc_napoli.pdf',
|
||||||
|
title='DURC — Documento Unico di Regolarità Contributiva',
|
||||||
|
subtitle='NAPOLI SAS — regolarità contributiva verificata',
|
||||||
|
lines=[
|
||||||
|
'Impresa: NAPOLI SAS Sandbox',
|
||||||
|
'P.IVA: 03517010546-SBX',
|
||||||
|
'Stato: REGOLARE',
|
||||||
|
'Data emissione: 15/11/2021',
|
||||||
|
'Scadenza: 15/03/2022',
|
||||||
|
'Ente erogatore: INPS',
|
||||||
|
]),
|
||||||
|
dict(code='VISURA_CAMERALE', label='Visura camerale', fname='visura_napoli.pdf',
|
||||||
|
title='Visura camerale ordinaria',
|
||||||
|
subtitle='NAPOLI SAS Sandbox — estratto CCIAA Perugia',
|
||||||
|
lines=[
|
||||||
|
'Denominazione: NAPOLI SAS Sandbox',
|
||||||
|
'Forma giuridica: Società in accomandita semplice',
|
||||||
|
'Sede legale: Perugia',
|
||||||
|
'Iscrizione REA: PG-123456',
|
||||||
|
'Attività prevalente: 62.02 Consulenza informatica',
|
||||||
|
'Stato: ATTIVA',
|
||||||
|
]),
|
||||||
|
dict(code='BILANCIO', label='Bilancio', fname='bilancio_napoli_2021.pdf',
|
||||||
|
title='Bilancio di esercizio 2021',
|
||||||
|
subtitle='NAPOLI SAS Sandbox — stato patrimoniale e conto economico',
|
||||||
|
lines=[
|
||||||
|
'Anno: 2021',
|
||||||
|
'Ricavi: € 85.200',
|
||||||
|
'Costi: € 68.300',
|
||||||
|
'Utile ante imposte: € 16.900',
|
||||||
|
'Patrimonio netto: € 45.600',
|
||||||
|
'Immobilizzazioni materiali: € 12.400',
|
||||||
|
]),
|
||||||
|
dict(code='ALTRO', label='Altra documentazione', fname='quietanze_napoli.pdf',
|
||||||
|
title='Quietanze di pagamento',
|
||||||
|
subtitle='Raccolta quietanze fatture rendicontate',
|
||||||
|
lines=[
|
||||||
|
'Bonifico Dell € 3.660 il 31/03/2021',
|
||||||
|
'Bonifico HP € 2.440 il 10/12/2020',
|
||||||
|
'Bonifico Netcomm € 3.050 il 30/05/2021',
|
||||||
|
'Bonifico Romano € 1.830 il 20/07/2021',
|
||||||
|
'Bonifico CertQuality € 2.928 il 30/09/2021',
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
for i, d in enumerate(docs_data):
|
||||||
|
doc = RemissionDocument(
|
||||||
|
practice_id=practice.id,
|
||||||
|
doc_code=d['code'],
|
||||||
|
notes=None,
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.flush()
|
||||||
|
attach_pdf(
|
||||||
|
db, doc, 'document', APPLICATION_ID,
|
||||||
|
filename=d['fname'],
|
||||||
|
title=d['title'],
|
||||||
|
subtitle=d['subtitle'],
|
||||||
|
lines=d['lines'],
|
||||||
|
uploader_id=BENEFICIARY_USER_ID,
|
||||||
|
)
|
||||||
|
print(f"[doc] {i+1}/4 {d['code']} + PDF {doc.size_bytes}b")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# ----- advance state se richiesto -----
|
||||||
|
if advance == 'draft':
|
||||||
|
print(f"[advance] rimasta in DRAFT")
|
||||||
|
return practice.id
|
||||||
|
|
||||||
|
# submit
|
||||||
|
practice.status = 'SUBMITTED'
|
||||||
|
practice.submitted_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
print(f"[advance] pratica SUBMITTED")
|
||||||
|
|
||||||
|
if advance == 'submitted':
|
||||||
|
return practice.id
|
||||||
|
|
||||||
|
# claim by instructor
|
||||||
|
practice.status = 'UNDER_REVIEW'
|
||||||
|
practice.assigned_instructor_id = INSTRUCTOR_USER_ID
|
||||||
|
db.commit()
|
||||||
|
print(f"[advance] pratica UNDER_REVIEW (claimed by user {INSTRUCTOR_USER_ID})")
|
||||||
|
return practice.id
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_napoli_sas_multi(db):
|
||||||
|
"""Scenario multi-tranche:
|
||||||
|
- tranche 1 APPROVED con 1 fattura B3 524.50€, rettifica 57.36€ assicurazione, ammesso 467.14€
|
||||||
|
(caso reale pratica 5888 di Cecilia)
|
||||||
|
- tranche 2 DRAFT vuota, pronta per la demo
|
||||||
|
|
||||||
|
Popola anche:
|
||||||
|
- assigned_applications (istruttore originariamente assegnato)
|
||||||
|
- 2 custom_checks dichiarati + polizza con PDF allegato su tranche 1
|
||||||
|
"""
|
||||||
|
schema_row = ensure_schema_published(db)
|
||||||
|
ensure_assigned_application(db)
|
||||||
|
|
||||||
|
# ---------- Tranche 1 APPROVED ----------
|
||||||
|
practice1 = RemissionPractice(
|
||||||
|
call_id=CALL_ID,
|
||||||
|
application_id=APPLICATION_ID,
|
||||||
|
company_id=COMPANY_ID,
|
||||||
|
user_id=BENEFICIARY_USER_ID,
|
||||||
|
status="APPROVED",
|
||||||
|
schema_snapshot=schema_row.schema_json,
|
||||||
|
iva_regime="ORDINARIO",
|
||||||
|
amount_erogato=Decimal("17000"),
|
||||||
|
sequence_number=1,
|
||||||
|
period_label="I fase 2021",
|
||||||
|
suggested_instructor_id=INSTRUCTOR_USER_ID,
|
||||||
|
assigned_instructor_id=INSTRUCTOR_USER_ID,
|
||||||
|
approved_remission=Decimal("467.14"),
|
||||||
|
reviewed_at=datetime.now(timezone.utc),
|
||||||
|
reviewed_by=INSTRUCTOR_USER_ID,
|
||||||
|
instructor_final_notes="Pratica tranche I: ammessa 1 fattura B3 con rettifica quota assicurativa.",
|
||||||
|
submitted_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(practice1)
|
||||||
|
db.flush()
|
||||||
|
print(f"[practice] tranche 1 APPROVED id={practice1.id}")
|
||||||
|
|
||||||
|
# 1 fattura B3 con PARZIALE (storno 57.36)
|
||||||
|
inv1 = RemissionInvoice(
|
||||||
|
practice_id=practice1.id,
|
||||||
|
category_code="B3",
|
||||||
|
invoice_number="2021/042",
|
||||||
|
invoice_date=date(2021, 4, 15),
|
||||||
|
payment_date=date(2021, 4, 30),
|
||||||
|
supplier_name="Formazione Digitale S.r.l.",
|
||||||
|
supplier_vat="IT03521460542",
|
||||||
|
description="Corso di formazione digitale 40h + quota assicurazione partecipanti",
|
||||||
|
taxable=Decimal("524.50"),
|
||||||
|
vat=Decimal("115.39"),
|
||||||
|
total=Decimal("639.89"),
|
||||||
|
taxable_verified=Decimal("467.14"),
|
||||||
|
vat_verified=Decimal("102.77"),
|
||||||
|
total_verified=Decimal("569.91"),
|
||||||
|
verification_status="PARZIALE",
|
||||||
|
verification_notes="Storno di 57.36 EUR per quota assicurazione partecipanti non ammissibile (non rientra nelle spese formative dirette).",
|
||||||
|
verified_by=INSTRUCTOR_USER_ID,
|
||||||
|
verified_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(inv1)
|
||||||
|
db.flush()
|
||||||
|
attach_pdf(
|
||||||
|
db, inv1, "invoice", APPLICATION_ID,
|
||||||
|
filename="ft_2021_042_formazione.pdf",
|
||||||
|
title=f"Fattura n. {inv1.invoice_number}",
|
||||||
|
subtitle=f"{inv1.supplier_name}",
|
||||||
|
lines=[
|
||||||
|
f"Fornitore: {inv1.supplier_name} P.IVA {inv1.supplier_vat}",
|
||||||
|
f"Descrizione: {inv1.description}",
|
||||||
|
f"Imponibile: EUR {inv1.taxable}",
|
||||||
|
f"IVA 22%: EUR {inv1.vat}",
|
||||||
|
f"Totale: EUR {inv1.total}",
|
||||||
|
],
|
||||||
|
uploader_id=BENEFICIARY_USER_ID,
|
||||||
|
)
|
||||||
|
print(f"[invoice T1] B3 2021/042 PARZIALE + PDF {inv1.size_bytes}b")
|
||||||
|
|
||||||
|
# 1 ULA T_IND 1.0 AMMESSA
|
||||||
|
emp1 = RemissionUlaEmployee(
|
||||||
|
practice_id=practice1.id,
|
||||||
|
codice_fiscale="RSSMRA85T10H501Z",
|
||||||
|
full_name="Mario Rossi",
|
||||||
|
contract_type="T_IND",
|
||||||
|
role_description="Sviluppatore senior",
|
||||||
|
fte_pct=Decimal("1.0000"),
|
||||||
|
fte_pct_verified=Decimal("1.0000"),
|
||||||
|
period_start_date=date(2021, 1, 27),
|
||||||
|
period_end_date=date(2021, 12, 31),
|
||||||
|
supporting_doc_type="LUL",
|
||||||
|
verification_status="AMMESSA",
|
||||||
|
verified_by=INSTRUCTOR_USER_ID,
|
||||||
|
verified_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(emp1)
|
||||||
|
db.flush()
|
||||||
|
attach_pdf(
|
||||||
|
db, emp1, "ula", APPLICATION_ID,
|
||||||
|
filename="lul_rossi_2021_t1.pdf",
|
||||||
|
title=f"LUL {emp1.full_name}",
|
||||||
|
subtitle=f"{emp1.period_start_date} to {emp1.period_end_date}",
|
||||||
|
lines=[f"CF: {emp1.codice_fiscale}", f"FTE: 1.00", "Contratto: T_IND"],
|
||||||
|
uploader_id=BENEFICIARY_USER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Documenti validati
|
||||||
|
for code, label in [("DURC", "DURC"), ("VISURA_CAMERALE", "Visura"),
|
||||||
|
("BILANCIO", "Bilancio 2021"), ("ANTIRICICLAGGIO", "Antiriciclaggio")]:
|
||||||
|
doc = RemissionDocument(
|
||||||
|
practice_id=practice1.id,
|
||||||
|
doc_code=code,
|
||||||
|
verification_status="VALIDO",
|
||||||
|
verified_by=INSTRUCTOR_USER_ID,
|
||||||
|
verified_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.flush()
|
||||||
|
attach_pdf(
|
||||||
|
db, doc, "document", APPLICATION_ID,
|
||||||
|
filename=f"{code.lower()}_napoli_t1.pdf",
|
||||||
|
title=label,
|
||||||
|
subtitle="Tranche I — NAPOLI SAS",
|
||||||
|
lines=["Documento valido", "Approvato dall istruttore"],
|
||||||
|
uploader_id=BENEFICIARY_USER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom checks tranche 1: antiriciclaggio dichiarato + polizza con PDF
|
||||||
|
cc_antir = RemissionCustomCheckValue(
|
||||||
|
practice_id=practice1.id,
|
||||||
|
check_code="antiriciclaggio",
|
||||||
|
beneficiary_declared=True,
|
||||||
|
declared_at=datetime.now(timezone.utc),
|
||||||
|
verification_status="VALIDO",
|
||||||
|
verified_by=INSTRUCTOR_USER_ID,
|
||||||
|
verified_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(cc_antir)
|
||||||
|
|
||||||
|
cc_polizza = RemissionCustomCheckValue(
|
||||||
|
practice_id=practice1.id,
|
||||||
|
check_code="polizza_fidejussoria",
|
||||||
|
beneficiary_declared=True,
|
||||||
|
declared_at=datetime.now(timezone.utc),
|
||||||
|
verification_status="VALIDO",
|
||||||
|
verified_by=INSTRUCTOR_USER_ID,
|
||||||
|
verified_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(cc_polizza)
|
||||||
|
db.flush()
|
||||||
|
# Genero PDF polizza e lo salvo direttamente in custom_checks/
|
||||||
|
from pathlib import Path as _P
|
||||||
|
pdf = make_pdf_bytes(
|
||||||
|
"Polizza fidejussoria tranche I",
|
||||||
|
"NAPOLI SAS Sandbox — garanzia bando RE-START",
|
||||||
|
[
|
||||||
|
"Compagnia: Generali Assicurazioni",
|
||||||
|
"Importo garantito: EUR 17.000",
|
||||||
|
"Data emissione: 15/01/2021",
|
||||||
|
"Scadenza: 31/12/2022",
|
||||||
|
"N. polizza: FID-2021-NS-0042",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
import hashlib
|
||||||
|
digest = hashlib.sha256(pdf).hexdigest()
|
||||||
|
target_dir = BASE_PATH / "custom_checks" / str(practice1.id) / "polizza_fidejussoria"
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_file = target_dir / f"{digest[:12]}-polizza_fidejussoria.pdf"
|
||||||
|
target_file.write_bytes(pdf)
|
||||||
|
cc_polizza.storage_path = str(target_file.relative_to(BASE_PATH))
|
||||||
|
cc_polizza.mime = "application/pdf"
|
||||||
|
cc_polizza.size_bytes = len(pdf)
|
||||||
|
cc_polizza.sha256 = digest
|
||||||
|
cc_polizza.document_uploaded_at = datetime.now(timezone.utc)
|
||||||
|
cc_polizza.uploaded_by = BENEFICIARY_USER_ID
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"[custom_checks T1] antiriciclaggio VALIDO, polizza VALIDO + PDF {len(pdf)}b")
|
||||||
|
|
||||||
|
# ---------- Tranche 2 DRAFT vuota ----------
|
||||||
|
practice2 = RemissionPractice(
|
||||||
|
call_id=CALL_ID,
|
||||||
|
application_id=APPLICATION_ID,
|
||||||
|
company_id=COMPANY_ID,
|
||||||
|
user_id=BENEFICIARY_USER_ID,
|
||||||
|
status="DRAFT",
|
||||||
|
schema_snapshot=schema_row.schema_json,
|
||||||
|
iva_regime="ORDINARIO",
|
||||||
|
amount_erogato=Decimal("17000"),
|
||||||
|
sequence_number=2,
|
||||||
|
period_label="II fase 2021",
|
||||||
|
suggested_instructor_id=INSTRUCTOR_USER_ID,
|
||||||
|
assigned_instructor_id=None, # non ancora assegnata (simulo workflow capo)
|
||||||
|
)
|
||||||
|
db.add(practice2)
|
||||||
|
db.flush()
|
||||||
|
print(f"[practice] tranche 2 DRAFT id={practice2.id} (vuota, pronta demo)")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return practice1.id, practice2.id
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Seed sandbox Gepafin rendicontazione")
|
||||||
|
ap.add_argument('--reset', action='store_true',
|
||||||
|
help='Cancella tutti i dati remission_* e pulisci storage prima del seed')
|
||||||
|
ap.add_argument('--scenario', choices=['napoli-sas', 'napoli-sas-multi', 'full'], default='napoli-sas')
|
||||||
|
ap.add_argument('--advance', choices=['draft', 'submitted', 'under_review'], default='under_review',
|
||||||
|
help='Stato finale della pratica dopo il seed')
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.reset:
|
||||||
|
do_reset('all')
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if args.scenario == 'napoli-sas':
|
||||||
|
pid = scenario_napoli_sas(db, advance=args.advance)
|
||||||
|
print(f"\n✓ Scenario napoli-sas completato. practice_id={pid}")
|
||||||
|
print(f" Accedi a: http://78.46.41.91:18072/istruttoria/{pid}")
|
||||||
|
elif args.scenario == 'napoli-sas-multi':
|
||||||
|
pid1, pid2 = scenario_napoli_sas_multi(db)
|
||||||
|
print(f"\n✓ Scenario napoli-sas-multi completato")
|
||||||
|
print(f" tranche 1 APPROVED id={pid1}")
|
||||||
|
print(f" tranche 2 DRAFT id={pid2}")
|
||||||
|
print(f" Istruttoria T1: http://78.46.41.91:18072/istruttoria/{pid1}")
|
||||||
|
print(f" Rendicontazione T2: http://78.46.41.91:18072/rendicontazioni/{pid2}")
|
||||||
|
elif args.scenario == 'full':
|
||||||
|
pid1, pid2 = scenario_napoli_sas_multi(db)
|
||||||
|
print(f"\n✓ Scenario full = napoli-sas-multi (solo questo disponibile).")
|
||||||
|
print(f" tranche 1={pid1} tranche 2={pid2}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user