Compare commits
13 Commits
6c089fb7b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83bb0a29ec | ||
|
|
1dbf542104 | ||
|
|
34c4a47a1c | ||
|
|
da13ca7478 | ||
|
|
7c8de6aec8 | ||
|
|
a3f863ecdb | ||
|
|
8950633481 | ||
|
|
345856f55c | ||
|
|
aeab399afa | ||
|
|
3021792c31 | ||
|
|
c19b2aa0b1 | ||
|
|
86681678c4 | ||
|
|
25215f388b |
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:
|
||||
sub: "email:userId:hubId"
|
||||
userId: int
|
||||
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | ...
|
||||
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | "ROLE_CONFIDI" | ...
|
||||
exp: unix timestamp
|
||||
loginAttemptId: int
|
||||
"""
|
||||
@@ -29,6 +29,15 @@ class AuthUser:
|
||||
def is_beneficiary(self) -> bool:
|
||||
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(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
|
||||
@@ -18,6 +18,10 @@ class Settings(BaseSettings):
|
||||
# CORS
|
||||
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:
|
||||
env_file = ".env"
|
||||
env_prefix = "RENDIC_"
|
||||
|
||||
12
app/main.py
12
app/main.py
@@ -15,7 +15,8 @@ from sqlalchemy import text
|
||||
from .config import get_settings
|
||||
from .db import engine, Base
|
||||
from .migrations import run_migrations
|
||||
from .routers import health, schemas, practices, debug, instructor, files, verbale
|
||||
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")
|
||||
log = logging.getLogger("rendicontazione-api")
|
||||
@@ -32,17 +33,19 @@ async def lifespan(app: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations(engine)
|
||||
log.info(f"Schema '{settings.db_schema}' + tabelle + migrations OK")
|
||||
start_scheduler()
|
||||
except Exception as e:
|
||||
log.error(f"Errore bootstrap DB: {e}")
|
||||
raise
|
||||
yield
|
||||
stop_scheduler()
|
||||
log.info("Shutdown rendicontazione-api")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="rendicontazione-api",
|
||||
description="Microservizio rendicontazione per Gepafin — sviluppato da BFLOWS",
|
||||
version="0.3.0",
|
||||
version="0.4.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -61,13 +64,16 @@ app.include_router(debug.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"])
|
||||
def root():
|
||||
return {
|
||||
"service": "rendicontazione-api",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
}
|
||||
|
||||
@@ -41,6 +41,144 @@ MIGRATIONS = [
|
||||
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;
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -43,13 +43,14 @@ class RemissionPractice(Base):
|
||||
"""
|
||||
__tablename__ = "remission_practice"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("application_id", name="uq_remission_practice_application"),
|
||||
UniqueConstraint("application_id", "sequence_number",
|
||||
name="uq_remission_practice_app_seq"),
|
||||
{"schema": "gepafin_rendic"},
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
call_id = Column(Integer, nullable=False)
|
||||
application_id = Column(Integer, nullable=False, unique=True)
|
||||
application_id = Column(Integer, nullable=False) # unique (application_id, sequence_number)
|
||||
company_id = Column(Integer, nullable=False)
|
||||
user_id = Column(Integer, nullable=False) # beneficiario che compila
|
||||
|
||||
@@ -61,6 +62,11 @@ class RemissionPractice(Base):
|
||||
amount_erogato = Column(Numeric(14, 2), nullable=False) # copiato da application.amount_accepted
|
||||
notes_beneficiario = Column(Text, nullable=True)
|
||||
|
||||
# Multi-tranche v2 (2026-04-18)
|
||||
sequence_number = Column(Integer, nullable=False, default=1)
|
||||
period_label = Column(String(100), nullable=True) # libero, es "I trimestre 2021"
|
||||
suggested_instructor_id = Column(Integer, nullable=True) # letto da BE assigned_applications
|
||||
|
||||
# colonne istruttoria
|
||||
assigned_instructor_id = Column(Integer, nullable=True)
|
||||
reviewed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
@@ -80,6 +86,7 @@ class RemissionPractice(Base):
|
||||
ula_employees = relationship("RemissionUlaEmployee", back_populates="practice", cascade="all, delete-orphan")
|
||||
documents = relationship("RemissionDocument", back_populates="practice", cascade="all, delete-orphan")
|
||||
amendment_requests = relationship("RemissionAmendmentRequest", back_populates="practice", cascade="all, delete-orphan")
|
||||
custom_checks = relationship("RemissionCustomCheckValue", back_populates="practice", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class RemissionInvoice(Base):
|
||||
@@ -192,6 +199,11 @@ class RemissionDocument(Base):
|
||||
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
|
||||
verification_status = Column(String(16), nullable=False, default="PENDING")
|
||||
# PENDING | VALIDO | NON_VALIDO | SCADUTO
|
||||
@@ -223,7 +235,86 @@ class RemissionAmendmentRequest(Base):
|
||||
closed_at = Column(DateTime(timezone=True), 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())
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
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}"'},
|
||||
)
|
||||
@@ -11,7 +11,9 @@ 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
|
||||
@@ -36,7 +38,7 @@ 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_beneficiary() and practice.user_id == user.user_id:
|
||||
if user.is_owner_role() and practice.user_id == user.user_id:
|
||||
return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
|
||||
return False
|
||||
|
||||
@@ -44,14 +46,14 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
|
||||
if _is_instructor(user):
|
||||
return True
|
||||
if user.is_beneficiary() and practice.user_id == user.user_id:
|
||||
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_beneficiary() and practice.user_id == user.user_id:
|
||||
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
|
||||
@@ -267,3 +269,97 @@ def delete_entity_file(
|
||||
|
||||
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 typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text, or_, and_
|
||||
|
||||
from ..db import get_db
|
||||
from ..auth import AuthUser, get_current_user
|
||||
from ..storage import save_upload, FileTooLargeError, MimeNotAllowedError, StorageError
|
||||
from ..models import RemissionPractice, RemissionAmendmentRequest
|
||||
from ..schemas import (
|
||||
AmendmentRequestCreate, AmendmentRequestOut, AmendmentResponseSubmit,
|
||||
AmendmentRequestCreate, AmendmentRequestUpdate, AmendmentExtend, AmendmentRequestOut, AmendmentResponseSubmit, AmendmentStatus,
|
||||
ReviewApproveBody, ReviewRejectBody,
|
||||
InstructorQueueItem, PracticeOut, ApiResponse,
|
||||
InvoiceVerifyBody, UlaVerifyBody, DocumentVerifyBody,
|
||||
@@ -64,7 +65,7 @@ def _enrich_queue_item(db: Session, p: RemissionPractice) -> InstructorQueueItem
|
||||
|
||||
# calcolo remissione due dalla schema_snapshot
|
||||
try:
|
||||
check = _compute_gate_check(p)
|
||||
check = _compute_gate_check(db, p)
|
||||
item.remission_due = check.totals.get("remission_due", 0)
|
||||
except Exception:
|
||||
item.remission_due = None
|
||||
@@ -88,8 +89,14 @@ def instructor_queue(db: Session = Depends(get_db), user: AuthUser = Depends(_re
|
||||
)
|
||||
if not manager:
|
||||
# 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_(
|
||||
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 == "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)."""
|
||||
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]
|
||||
|
||||
return ApiResponse(data={
|
||||
@@ -152,7 +159,7 @@ def approve_practice(practice_id: UUID, body: ReviewApproveBody,
|
||||
if body.approved_remission is not None:
|
||||
p.approved_remission = body.approved_remission
|
||||
else:
|
||||
check = _compute_gate_check(p)
|
||||
check = _compute_gate_check(db, p)
|
||||
p.approved_remission = Decimal(str(check.totals.get("remission_due", 0)))
|
||||
|
||||
p.status = "APPROVED"
|
||||
@@ -189,10 +196,29 @@ def reject_practice(practice_id: UUID, body: ReviewRejectBody,
|
||||
|
||||
# ========== 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)
|
||||
def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
||||
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)
|
||||
if p.status not in ("UNDER_REVIEW", "AWAITING_AMENDMENT"):
|
||||
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:
|
||||
raise HTTPException(status_code=422, detail="Testo richiesta (min 10 caratteri)")
|
||||
|
||||
# controllo: non ci deve essere già una amendment AWAITING aperta
|
||||
open_ar = [a for a in p.amendment_requests if a.status == "AWAITING"]
|
||||
# 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 in (AmendmentStatus.DRAFT.value, AmendmentStatus.AWAITING.value,
|
||||
AmendmentStatus.RESPONSE_RECEIVED.value)]
|
||||
if open_ar:
|
||||
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(
|
||||
practice_id=p.id,
|
||||
@@ -212,37 +240,142 @@ def create_amendment(practice_id: UUID, body: AmendmentRequestCreate,
|
||||
request_text=body.request_text,
|
||||
deadline=body.deadline,
|
||||
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)
|
||||
# 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"
|
||||
db.commit()
|
||||
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"))
|
||||
|
||||
|
||||
@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)
|
||||
def close_amendment(practice_id: UUID, amendment_id: UUID,
|
||||
db: Session = Depends(get_db), user: AuthUser = Depends(_require_instructor)):
|
||||
"""Istruttore chiude il soccorso (dopo aver visto la risposta beneficiario).
|
||||
La pratica torna in UNDER_REVIEW."""
|
||||
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")
|
||||
if ar.status == "CLOSED":
|
||||
raise HTTPException(status_code=409, detail="Amendment già chiusa")
|
||||
"""Istruttore chiude il soccorso. La pratica torna in UNDER_REVIEW
|
||||
se non ci sono altri amendment aperti su di essa."""
|
||||
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||
if ar.status == AmendmentStatus.CLOSED.value:
|
||||
raise HTTPException(status_code=409, detail="Amendment gia chiusa")
|
||||
|
||||
ar.status = "CLOSED"
|
||||
ar.status = AmendmentStatus.CLOSED.value
|
||||
ar.closed_at = datetime.now(timezone.utc)
|
||||
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)
|
||||
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:
|
||||
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"))
|
||||
|
||||
|
||||
@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
|
||||
@router.post("/{practice_id}/amendment/{amendment_id}/respond-beneficiary", response_model=ApiResponse)
|
||||
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)):
|
||||
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
|
||||
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")
|
||||
|
||||
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")
|
||||
ar = _amendment_or_404(db, practice_id, amendment_id)
|
||||
if ar.status != "AWAITING":
|
||||
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)
|
||||
return ApiResponse(message="Verbale aggiornato",
|
||||
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
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import text, func
|
||||
|
||||
from ..db import get_db
|
||||
from ..auth import AuthUser, get_current_user
|
||||
@@ -23,8 +23,10 @@ from ..schemas import (
|
||||
UlaEmployeeCreate, UlaEmployeeOut,
|
||||
DocumentUpsert, DocumentOut,
|
||||
GateCheckResult,
|
||||
ApplicationTranchesSummary, CopyUlaOption,
|
||||
ApiResponse
|
||||
)
|
||||
from ..templates import upgrade_schema_to_v2
|
||||
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
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.
|
||||
Calcola:
|
||||
- per_category_declared: totali dichiarati dal beneficiario (sempre)
|
||||
@@ -117,12 +119,43 @@ def _compute_gate_check(practice: RemissionPractice) -> GateCheckResult:
|
||||
amt_erogato = practice.amount_erogato
|
||||
cap_pct = Decimal(str(rules.get("cap_pct_erogato", 0.5)))
|
||||
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
|
||||
# altrimenti uso grand_total (dichiarato) per preview pre-istruttoria
|
||||
effective_total = grand_total_verified if any_verified else grand_total
|
||||
remission_due = min(effective_total, max_remission)
|
||||
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_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"
|
||||
})
|
||||
|
||||
# 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)
|
||||
amt_range = rules.get("amount_range", {})
|
||||
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),
|
||||
"any_verified": any_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,
|
||||
"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
|
||||
|
||||
|
||||
|
||||
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 ----------
|
||||
|
||||
@router.get("/mine", response_model=ApiResponse)
|
||||
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."""
|
||||
# pratiche esistenti
|
||||
practices = db.query(RemissionPractice).filter(RemissionPractice.user_id == user.user_id).all()
|
||||
existing_app_ids = {p.application_id for p in practices}
|
||||
"""Lista pratiche del beneficiario raggruppate per application_id (v2 multi-tranche).
|
||||
Ogni application ha il riepilogo cumulativo + elenco tranche esistenti + stato apertura nuova tranche.
|
||||
"""
|
||||
# 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("""
|
||||
SELECT a.id as application_id, a.call_id, a.company_id, a.amount_accepted,
|
||||
a.status, c.name as call_name, comp.company_name as company_name
|
||||
SELECT a.id as application_id, a.call_id, a.company_id, a.amount_accepted, a.status,
|
||||
c.name as call_name, comp.company_name as company_name
|
||||
FROM gepafin_schema.application a
|
||||
JOIN gepafin_schema.call c ON c.id = a.call_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
|
||||
ORDER BY a.id
|
||||
"""), {"uid": user.user_id}).mappings().all()
|
||||
|
||||
pending = []
|
||||
applications = []
|
||||
for r in rows:
|
||||
if r["application_id"] not in existing_app_ids:
|
||||
pending.append({
|
||||
"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"
|
||||
})
|
||||
app_id = r["application_id"]
|
||||
trs = by_app.get(app_id, [])
|
||||
|
||||
return ApiResponse(data={
|
||||
"practices": [_enrich_list_item(db, p).model_dump(mode="json") for p in practices],
|
||||
"ready_to_start": pending
|
||||
})
|
||||
# leggo schema del bando per max_tranches e cap
|
||||
schema = _get_schema_published(db, r["call_id"])
|
||||
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)
|
||||
def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
|
||||
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
|
||||
app_row = db.execute(text("""
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=f"Application {body.application_id} non trovata")
|
||||
|
||||
if app_row["status"] != "CONTRACT_SIGNED":
|
||||
raise HTTPException(status_code=409,
|
||||
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:
|
||||
raise HTTPException(status_code=403, detail="Application non di tua proprietà")
|
||||
|
||||
# Schema del bando: richiede PUBLISHED (o DRAFT se superadmin per test)
|
||||
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == app_row["call_id"]).first()
|
||||
# Schema del bando
|
||||
schema = _get_schema_published(db, app_row["call_id"])
|
||||
if not schema:
|
||||
raise HTTPException(status_code=409,
|
||||
detail="Nessuno schema di rendicontazione configurato per questo bando. "
|
||||
"Contatta l'ente gestore.")
|
||||
if schema.status != "PUBLISHED" and user.is_beneficiary():
|
||||
detail="Nessuno schema di rendicontazione configurato per questo bando.")
|
||||
if schema.status != "PUBLISHED" and user.is_owner_role():
|
||||
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?
|
||||
exists = db.query(RemissionPractice).filter(RemissionPractice.application_id == body.application_id).first()
|
||||
if exists:
|
||||
raise HTTPException(status_code=409, detail="Pratica già esistente")
|
||||
# Tranche esistenti
|
||||
existing_tranches = db.query(RemissionPractice).filter(
|
||||
RemissionPractice.application_id == body.application_id
|
||||
).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(
|
||||
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"],
|
||||
user_id=app_row["user_id"],
|
||||
status="DRAFT",
|
||||
schema_snapshot=copy.deepcopy(schema.schema_json),
|
||||
amount_erogato=app_row["amount_accepted"] or Decimal("0"),
|
||||
schema_snapshot=snapshot,
|
||||
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.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.refresh(practice)
|
||||
|
||||
return ApiResponse(message="Pratica avviata",
|
||||
data=PracticeOut.model_validate(practice).model_dump(mode="json"))
|
||||
return ApiResponse(
|
||||
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)
|
||||
@@ -451,7 +696,7 @@ def clear_document(practice_id: UUID, doc_code: str,
|
||||
def gate_check(practice_id: UUID, db: Session = Depends(get_db),
|
||||
user: AuthUser = Depends(get_current_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"))
|
||||
|
||||
|
||||
@@ -461,7 +706,7 @@ def submit_practice(practice_id: UUID, db: Session = Depends(get_db),
|
||||
p = _get_practice_or_404(db, practice_id, user)
|
||||
_ensure_editable(p)
|
||||
|
||||
check = _compute_gate_check(p)
|
||||
check = _compute_gate_check(db, p)
|
||||
if not check.passed:
|
||||
raise HTTPException(status_code=422, detail={
|
||||
"message": "Gate rules non soddisfatte",
|
||||
|
||||
@@ -1,19 +1,153 @@
|
||||
"""
|
||||
Endpoint gestione schema rendicontazione per bando.
|
||||
v2.1 (2026-04-18): picker multi-sorgente (blank, template predefinito, clone da altro bando).
|
||||
"""
|
||||
import copy
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from ..db import get_db
|
||||
from ..auth import AuthUser, get_current_user, require_superadmin
|
||||
from ..models import CallRemissionSchema
|
||||
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"])
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 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)
|
||||
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."""
|
||||
@@ -33,12 +167,12 @@ def create_schema(
|
||||
db: Session = Depends(get_db),
|
||||
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()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
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(
|
||||
call_id=call_id,
|
||||
@@ -62,7 +196,7 @@ def update_schema(
|
||||
db: Session = Depends(get_db),
|
||||
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()
|
||||
if not schema:
|
||||
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),
|
||||
user: AuthUser = Depends(require_superadmin),
|
||||
):
|
||||
"""Inizializza schema per un bando usando il template RE-START. Fallisce se esiste già."""
|
||||
existing = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
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"),
|
||||
)
|
||||
"""DEPRECATO in 2.1 — alias di /initialize con source=template&template_id=restart.
|
||||
Mantenuto per backward compatibility del FE."""
|
||||
body = SchemaInitializeRequest(source="template", template_id="restart")
|
||||
return initialize_schema(call_id=call_id, body=body, db=db, user=user)
|
||||
|
||||
|
||||
@router.post("/{call_id}/publish", response_model=ApiResponse)
|
||||
@@ -115,7 +233,7 @@ def publish_schema(
|
||||
db: Session = Depends(get_db),
|
||||
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
|
||||
schema = db.query(CallRemissionSchema).filter(CallRemissionSchema.call_id == call_id).first()
|
||||
if not schema:
|
||||
@@ -123,7 +241,7 @@ def publish_schema(
|
||||
if schema.status == "PUBLISHED":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Schema già pubblicato.",
|
||||
detail="Schema gia pubblicato.",
|
||||
)
|
||||
schema.status = "PUBLISHED"
|
||||
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}")
|
||||
|
||||
|
||||
@router.get("/templates/restart", response_model=ApiResponse)
|
||||
@router.get("/templates-preview/restart", response_model=ApiResponse)
|
||||
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)
|
||||
|
||||
@@ -16,13 +16,16 @@ from sqlalchemy import text
|
||||
|
||||
from ..db import get_db
|
||||
from ..auth import AuthUser, get_current_user
|
||||
from ..models import RemissionPractice
|
||||
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 ----------
|
||||
@@ -106,7 +109,7 @@ def _is_instructor(user: AuthUser) -> bool:
|
||||
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(practice); gate = gate_obj.model_dump() if hasattr(gate_obj, "model_dump") else dict(gate_obj)
|
||||
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
|
||||
@@ -177,6 +180,50 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
|
||||
"""), {"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,
|
||||
@@ -196,6 +243,11 @@ def _build_context(db: Session, practice: RemissionPractice, user: AuthUser) ->
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +286,7 @@ def verbale_pdf(
|
||||
|
||||
practice, html = _render_html(db, practice_id, user)
|
||||
pdf_bytes = WeasyHTML(string=html).write_pdf()
|
||||
filename = f"verbale_istruttoria_pratica_{practice.application_id}.pdf"
|
||||
filename = f"verbale_istruttoria_pratica_{practice.application_id}_t{practice.sequence_number}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
|
||||
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 decimal import Decimal
|
||||
from uuid import UUID
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@@ -40,8 +41,10 @@ class RemissionSchemaOut(BaseModel):
|
||||
# ====================== Pratica di rendicontazione (beneficiario) ======================
|
||||
|
||||
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
|
||||
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):
|
||||
@@ -115,6 +118,7 @@ class DocumentUpsert(BaseModel):
|
||||
uploaded_at: Optional[datetime] = None
|
||||
expires_at: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
source_company_document_id: Optional[int] = None
|
||||
|
||||
|
||||
class DocumentOut(BaseModel):
|
||||
@@ -125,6 +129,7 @@ class DocumentOut(BaseModel):
|
||||
uploaded_at: Optional[datetime] = None
|
||||
expires_at: Optional[date] = None
|
||||
notes: Optional[str] = None
|
||||
source_company_document_id: Optional[int] = None
|
||||
# istruttoria
|
||||
verification_status: str = "PENDING"
|
||||
verification_notes: Optional[str] = None
|
||||
@@ -160,6 +165,11 @@ class PracticeOut(BaseModel):
|
||||
instructor_checklist: Optional[dict] = 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] = []
|
||||
ula_employees: List[UlaEmployeeOut] = []
|
||||
documents: List[DocumentOut] = []
|
||||
@@ -178,6 +188,11 @@ class PracticeListItem(BaseModel):
|
||||
created_at: datetime
|
||||
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
|
||||
call_name: Optional[str] = None
|
||||
company_name: Optional[str] = None
|
||||
@@ -197,10 +212,36 @@ class GateCheckResult(BaseModel):
|
||||
|
||||
# ====================== 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):
|
||||
request_text: str
|
||||
deadline: date
|
||||
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):
|
||||
@@ -221,9 +262,63 @@ class AmendmentRequestOut(BaseModel):
|
||||
closed_by: Optional[int] = None
|
||||
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}
|
||||
|
||||
|
||||
# ===== 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):
|
||||
rejection_reason: str
|
||||
|
||||
@@ -237,6 +332,9 @@ class InstructorQueueItem(BaseModel):
|
||||
id: UUID
|
||||
call_id: int
|
||||
application_id: int
|
||||
sequence_number: int = 1
|
||||
period_label: Optional[str] = None
|
||||
suggested_instructor_id: Optional[int] = None
|
||||
company_id: int
|
||||
status: str
|
||||
amount_erogato: Decimal
|
||||
@@ -288,3 +386,76 @@ class ApiResponse(BaseModel):
|
||||
status: str = "SUCCESS"
|
||||
message: Optional[str] = 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 |
@@ -48,7 +48,7 @@ def _safe_filename(name: str, max_len: int = 120) -> str:
|
||||
|
||||
def save_upload(
|
||||
application_id: int,
|
||||
entity_type: str, # invoice | ula | document
|
||||
entity_type: str, # invoice | ula | document | amendment-instructor-doc | amendment-response-doc
|
||||
entity_id: UUID,
|
||||
file_obj: BinaryIO,
|
||||
original_filename: str,
|
||||
@@ -62,7 +62,7 @@ def save_upload(
|
||||
- mime in ALLOWED_MIMES (usa content_type del client, fallback su estensione)
|
||||
- dimensione <= MAX_SIZE_BYTES
|
||||
"""
|
||||
if entity_type not in ("invoice", "ula", "document"):
|
||||
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)
|
||||
|
||||
179
app/templates.py
179
app/templates.py
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
Template schemi precompilati per bandi noti.
|
||||
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 = {
|
||||
"version": "1.0",
|
||||
"template_id": "RESTART_V1",
|
||||
"version": "2.0",
|
||||
"schema_version": 2,
|
||||
"template_id": "RESTART_V2",
|
||||
"template_label": "RE-START (fondo prestiti con remissione del debito)",
|
||||
"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": {
|
||||
"amount_range": {"min": 5000, "max": 25000},
|
||||
"cap_pct_erogato": 0.5,
|
||||
@@ -125,5 +144,161 @@ RESTART_TEMPLATE = {
|
||||
"require_at_least_one_invoice_per_nonzero_category": True,
|
||||
"require_ula_above_threshold": 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"])
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
padding-bottom: 10pt;
|
||||
margin-bottom: 14pt;
|
||||
}
|
||||
.hdr__logo {
|
||||
font-size: 22pt; font-weight: 900; color: #1a365d; letter-spacing: 1pt;
|
||||
.hdr__logo-img {
|
||||
height: 38pt; width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
.hdr__subtitle {
|
||||
@@ -182,10 +182,11 @@
|
||||
<div class="hdr__right">
|
||||
<div><strong>Verbale di istruttoria</strong></div>
|
||||
<div>Rendicontazione bando</div>
|
||||
<div>Pratica n. {{ practice.application_id }}</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>
|
||||
<span class="hdr__logo">GEPAFIN</span>
|
||||
<img src="{{ logo_path }}" alt="Gepafin" class="hdr__logo-img" />
|
||||
<div class="hdr__subtitle">Finanziaria regionale dell'Umbria</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,6 +238,15 @@
|
||||
{% 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>
|
||||
@@ -387,6 +397,45 @@
|
||||
<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>
|
||||
@@ -406,43 +455,94 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# ============ TOTALI ============ #}
|
||||
<h2>Riepilogo finanziario</h2>
|
||||
{# ============ 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">
|
||||
<div class="lbl">Totale dichiarato</div>
|
||||
<div class="val">{{ totals.grand_total_declared|euro }}</div>
|
||||
<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">
|
||||
<div class="lbl">Totale ammesso</div>
|
||||
<div class="val">{{ totals.grand_total_verified|euro }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="lbl">Cap remissione</div>
|
||||
<div class="val">{{ totals.max_remission|euro }}</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<div class="lbl">Remissione spettante</div>
|
||||
<div class="val final">{{ totals.remission_due|euro }}</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>
|
||||
{% if practice.status == 'APPROVED' %}
|
||||
<div class="row">
|
||||
<div class="cell" style="background: #f0fff4;">
|
||||
<div class="lbl">Remissione approvata</div>
|
||||
<div class="val" style="color: #22543d;">{{ practice.approved_remission|euro }}</div>
|
||||
</div>
|
||||
<div class="cell" style="background: #fff5f5;">
|
||||
<div class="lbl">Residuo da restituire</div>
|
||||
<div class="val residuo">{{ (practice.amount_erogato - (practice.approved_remission or 0))|euro }}</div>
|
||||
</div>
|
||||
<div class="cell" colspan="2"></div>
|
||||
<div class="cell"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
||||
@@ -9,3 +9,4 @@ python-multipart==0.0.9
|
||||
weasyprint==61.2
|
||||
pydyf==0.10.0
|
||||
jinja2==3.1.3
|
||||
APScheduler==3.10.4
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.models import (
|
||||
RemissionUlaEmployee,
|
||||
RemissionDocument,
|
||||
RemissionAmendmentRequest,
|
||||
RemissionCustomCheckValue,
|
||||
)
|
||||
from app.storage import save_upload, BASE_PATH
|
||||
from app.templates import RESTART_TEMPLATE
|
||||
@@ -44,6 +45,7 @@ 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
|
||||
|
||||
|
||||
@@ -110,6 +112,27 @@ def attach_pdf(db, entity, entity_type: str, application_id: int,
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,6 +143,7 @@ def do_reset(scope: str):
|
||||
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,
|
||||
@@ -396,6 +420,207 @@ def scenario_napoli_sas(db, advance='draft'):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -403,7 +628,7 @@ 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', 'full'], default='napoli-sas')
|
||||
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()
|
||||
@@ -417,11 +642,17 @@ def main():
|
||||
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':
|
||||
pid = scenario_napoli_sas(db, advance=args.advance)
|
||||
# placeholder per futuri scenari ROMA-SRL / BOLOGNA-SPA
|
||||
print(f"\n✓ Scenario 'full' eseguito (solo napoli-sas disponibile).")
|
||||
print(f" practice_id={pid}")
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user