Compare commits
3 Commits
6c089fb7b2
...
c19b2aa0b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c19b2aa0b1 | ||
|
|
86681678c4 | ||
|
|
25215f388b |
@@ -15,7 +15,7 @@ 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 .routers import health, schemas, practices, debug, instructor, files, verbale, custom_checks, assignment
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("rendicontazione-api")
|
||||
@@ -42,7 +42,7 @@ async def lifespan(app: FastAPI):
|
||||
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 +61,15 @@ 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.get("/", tags=["root"])
|
||||
def root():
|
||||
return {
|
||||
"service": "rendicontazione-api",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"docs": "/docs",
|
||||
"health": "/health",
|
||||
}
|
||||
|
||||
@@ -41,6 +41,60 @@ 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-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);
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -227,3 +234,47 @@ class RemissionAmendmentRequest(Base):
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
practice = relationship("RemissionPractice", back_populates="amendment_requests")
|
||||
|
||||
|
||||
class RemissionCustomCheckValue(Base):
|
||||
"""Valore di un controllo custom configurato dallo schema del bando.
|
||||
Schema custom_checks[] nel template definisce code/label/description/requires_document/required.
|
||||
Qui salviamo dichiarazione beneficiario + eventuale documento + verifica istruttore.
|
||||
"""
|
||||
__tablename__ = "remission_custom_check_value"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("practice_id", "check_code", name="uq_custom_check_practice_code"),
|
||||
{"schema": "gepafin_rendic"},
|
||||
)
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
practice_id = Column(UUID(as_uuid=True),
|
||||
ForeignKey("gepafin_rendic.remission_practice.id", ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
check_code = Column(String(64), nullable=False) # es "antiriciclaggio", "polizza_fidejussoria"
|
||||
|
||||
# Dichiarazione beneficiario
|
||||
beneficiary_declared = Column(Boolean, nullable=False, default=False)
|
||||
declared_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Documento allegato (se requires_document)
|
||||
storage_path = Column(String(1024), nullable=True)
|
||||
mime = Column(String(128), nullable=True)
|
||||
size_bytes = Column(BigInteger, nullable=True)
|
||||
sha256 = Column(String(64), nullable=True)
|
||||
document_uploaded_at = Column(DateTime(timezone=True), nullable=True)
|
||||
uploaded_by = Column(Integer, nullable=True)
|
||||
|
||||
# Verifica istruttore
|
||||
verification_status = Column(String(20), nullable=False, default="PENDING")
|
||||
# PENDING | VALIDO | NON_VALIDO
|
||||
verification_notes = Column(Text, nullable=True)
|
||||
verified_by = Column(Integer, nullable=True)
|
||||
verified_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), nullable=False,
|
||||
server_default=func.now(), onupdate=func.now())
|
||||
|
||||
practice = relationship("RemissionPractice", back_populates="custom_checks")
|
||||
|
||||
|
||||
183
app/routers/assignment.py
Normal file
183
app/routers/assignment.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
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'."""
|
||||
practices = db.query(RemissionPractice).filter(
|
||||
RemissionPractice.status.in_(["SUBMITTED", "UNDER_REVIEW", "AWAITING_AMENDMENT"])
|
||||
).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_beneficiary() and p.user_id != user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Accesso negato")
|
||||
if not user.is_beneficiary() 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_beneficiary():
|
||||
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}"'},
|
||||
)
|
||||
@@ -64,7 +64,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
|
||||
@@ -109,7 +109,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 +152,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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -209,9 +242,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 +276,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"],
|
||||
app_id = r["application_id"]
|
||||
trs = by_app.get(app_id, [])
|
||||
|
||||
# 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"],
|
||||
"company_id": r["company_id"],
|
||||
"amount_erogato": float(r["amount_accepted"] or 0),
|
||||
"call_name": r["call_name"],
|
||||
"company_id": r["company_id"],
|
||||
"company_name": r["company_name"],
|
||||
"status": "NOT_STARTED"
|
||||
"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={
|
||||
"practices": [_enrich_list_item(db, p).model_dump(mode="json") for p in practices],
|
||||
"ready_to_start": pending
|
||||
})
|
||||
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 +411,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_beneficiary() and app_row["user_id"] != user.user_id:
|
||||
raise HTTPException(status_code=403, detail="Application non di tua proprietà")
|
||||
raise HTTPException(status_code=403, detail="Application non di tua proprieta")
|
||||
|
||||
# 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.")
|
||||
detail="Nessuno schema di rendicontazione configurato per questo bando.")
|
||||
if schema.status != "PUBLISHED" and user.is_beneficiary():
|
||||
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 +481,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 +672,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 +682,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",
|
||||
|
||||
@@ -106,7 +106,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
|
||||
|
||||
@@ -40,8 +40,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):
|
||||
@@ -160,6 +162,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 +185,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
|
||||
@@ -237,6 +249,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 +303,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,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,35 @@ 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
|
||||
|
||||
@@ -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