440 lines
16 KiB
Python
440 lines
16 KiB
Python
from fastapi import APIRouter, HTTPException, BackgroundTasks, status
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, Field
|
|
from typing import List, Optional, Dict, Any
|
|
|
|
from pif_compiler.classes.main_workflow import receive_order, process_order_pipeline, retry_order, trigger_pipeline, Project
|
|
from pif_compiler.functions.db_utils import db_connect, get_ordine_by_id, get_all_ordini, delete_ordine
|
|
from pif_compiler.functions.common_func import generate_project_source_pdfs, create_sources_zip
|
|
from pif_compiler.functions.common_log import get_logger
|
|
|
|
logger = get_logger()
|
|
router = APIRouter()
|
|
|
|
|
|
# ==================== REQUEST / RESPONSE MODELS ====================
|
|
|
|
class OrderIngredientInput(BaseModel):
|
|
inci: Optional[str] = None
|
|
cas: Optional[str] = None
|
|
percentage: float = 0.0
|
|
is_colorante: bool = False
|
|
skip_tox: bool = False
|
|
|
|
|
|
class OrderCreateRequest(BaseModel):
|
|
client_name: str = Field(..., description="Nome del cliente")
|
|
product_name: str = Field(..., description="Nome del prodotto cosmetico")
|
|
preset_esposizione: str = Field(..., description="Nome del preset di esposizione")
|
|
ingredients: List[OrderIngredientInput] = Field(..., description="Lista ingredienti")
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"client_name": "Cosmetica Italia srl",
|
|
"product_name": "Crema 'Cremosa'",
|
|
"preset_esposizione": "Test Preset",
|
|
"ingredients": [
|
|
{"inci": "AQUA", "cas": "", "percentage": 90, "is_colorante": False, "skip_tox": True},
|
|
{"inci": None, "cas": "56-81-5", "percentage": 6, "is_colorante": False, "skip_tox": False},
|
|
{"inci": None, "cas": "9007-16-3", "percentage": 3, "is_colorante": False, "skip_tox": False},
|
|
{"inci": None, "cas": "JYY-807", "percentage": 1, "is_colorante": True, "skip_tox": False}
|
|
]
|
|
}
|
|
}
|
|
|
|
|
|
class OrderCreateResponse(BaseModel):
|
|
success: bool
|
|
id_ordine: Optional[int] = None
|
|
message: Optional[str] = None
|
|
error: Optional[str] = None
|
|
|
|
|
|
# ==================== ROUTES ====================
|
|
|
|
@router.post("/orders/create", response_model=OrderCreateResponse, tags=["Orders"])
|
|
async def create_order(request: OrderCreateRequest, background_tasks: BackgroundTasks):
|
|
"""
|
|
Crea un nuovo ordine e avvia l'elaborazione in background.
|
|
Il JSON viene salvato su MongoDB, il record su PostgreSQL (stato=RICEVUTO).
|
|
L'arricchimento degli ingredienti avviene in background.
|
|
"""
|
|
logger.info(f"Nuovo ordine ricevuto: cliente={request.client_name}, prodotto={request.product_name}")
|
|
|
|
try:
|
|
raw_json = request.model_dump()
|
|
id_ordine = receive_order(raw_json)
|
|
|
|
if id_ordine is None:
|
|
return OrderCreateResponse(
|
|
success=False,
|
|
error="Errore nel salvataggio dell'ordine"
|
|
)
|
|
|
|
# Avvia l'elaborazione in background
|
|
background_tasks.add_task(process_order_pipeline)
|
|
|
|
logger.info(f"Ordine {id_ordine} creato, elaborazione avviata in background")
|
|
return OrderCreateResponse(
|
|
success=True,
|
|
id_ordine=id_ordine,
|
|
message=f"Ordine {id_ordine} ricevuto. Elaborazione avviata in background."
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Errore creazione ordine: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Errore interno: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/orders/retry/{id_ordine}", response_model=OrderCreateResponse, tags=["Orders"])
|
|
async def retry_failed_order(id_ordine: int, background_tasks: BackgroundTasks):
|
|
"""
|
|
Resetta un ordine in stato ERRORE a RICEVUTO e rilancia la pipeline.
|
|
"""
|
|
logger.info(f"Retry ordine {id_ordine}")
|
|
|
|
try:
|
|
success = retry_order(id_ordine)
|
|
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Ordine {id_ordine} non trovato o non in stato ERRORE"
|
|
)
|
|
|
|
background_tasks.add_task(process_order_pipeline)
|
|
|
|
return OrderCreateResponse(
|
|
success=True,
|
|
id_ordine=id_ordine,
|
|
message=f"Ordine {id_ordine} resettato. Rielaborazione avviata in background."
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Errore retry ordine {id_ordine}: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Errore interno: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.post("/orders/trigger-pipeline", response_model=OrderCreateResponse, tags=["Orders"])
|
|
async def manual_trigger_pipeline(background_tasks: BackgroundTasks):
|
|
"""
|
|
Lancia manualmente la pipeline di elaborazione.
|
|
Processa il prossimo ordine pendente (più vecchio con stato RICEVUTO).
|
|
"""
|
|
logger.info("Trigger manuale pipeline")
|
|
|
|
try:
|
|
background_tasks.add_task(trigger_pipeline)
|
|
|
|
return OrderCreateResponse(
|
|
success=True,
|
|
message="Pipeline avviata in background."
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Errore trigger pipeline: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Errore interno: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/orders/export/{id_ordine}", tags=["Orders"])
|
|
async def export_order_excel(id_ordine: int):
|
|
"""
|
|
Genera e scarica il file Excel per un ordine completato.
|
|
L'ordine deve avere un uuid_progetto associato (stato >= ARRICCHITO).
|
|
"""
|
|
logger.info(f"Export Excel per ordine {id_ordine}")
|
|
|
|
try:
|
|
# Recupera l'ordine dal DB
|
|
row = get_ordine_by_id(id_ordine)
|
|
if not row:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ordine {id_ordine} non trovato"
|
|
)
|
|
|
|
uuid_progetto = row[4] # uuid_progetto
|
|
if not uuid_progetto:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Ordine {id_ordine} non ha un progetto associato (elaborazione non completata?)"
|
|
)
|
|
|
|
# Recupera il progetto da MongoDB
|
|
from bson import ObjectId
|
|
collection = db_connect(collection_name='projects')
|
|
doc = collection.find_one({"_id": ObjectId(uuid_progetto)})
|
|
if not doc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Progetto {uuid_progetto} non trovato in MongoDB"
|
|
)
|
|
|
|
doc.pop("_id", None)
|
|
project = Project(**doc)
|
|
|
|
# Genera Excel
|
|
output_path = project.export_excel()
|
|
|
|
logger.info(f"Excel generato per ordine {id_ordine}: {output_path}")
|
|
return FileResponse(
|
|
path=output_path,
|
|
filename=f"progetto_{id_ordine}.xlsx",
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Errore export Excel ordine {id_ordine}: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Errore interno: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/orders/export-sources/{id_ordine}", tags=["Orders"])
|
|
async def export_order_sources(id_ordine: int):
|
|
"""
|
|
Genera i PDF delle fonti per ogni ingrediente di un ordine e li restituisce in un archivio ZIP.
|
|
|
|
Include: PDF tossicologico del best_case (CAS_source.pdf) + PDF COSING (CAS_cosing.pdf).
|
|
"""
|
|
logger.info(f"Export fonti PDF per ordine {id_ordine}")
|
|
|
|
try:
|
|
# Recupera l'ordine dal DB
|
|
row = get_ordine_by_id(id_ordine)
|
|
if not row:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ordine {id_ordine} non trovato"
|
|
)
|
|
|
|
uuid_progetto = row[4]
|
|
if not uuid_progetto:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Ordine {id_ordine} non ha un progetto associato"
|
|
)
|
|
|
|
# Recupera il progetto da MongoDB
|
|
from bson import ObjectId
|
|
collection = db_connect(collection_name='projects')
|
|
doc = collection.find_one({"_id": ObjectId(uuid_progetto)})
|
|
if not doc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Progetto {uuid_progetto} non trovato in MongoDB"
|
|
)
|
|
|
|
doc.pop("_id", None)
|
|
project = Project(**doc)
|
|
|
|
# Genera i PDF delle fonti
|
|
pdf_paths = await generate_project_source_pdfs(project)
|
|
|
|
if not pdf_paths:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Nessuna fonte (tox/COSING) disponibile per questo progetto"
|
|
)
|
|
|
|
# Crea ZIP
|
|
import os
|
|
os.makedirs("exports", exist_ok=True)
|
|
zip_path = f"exports/fonti_ordine_{id_ordine}.zip"
|
|
create_sources_zip(pdf_paths, zip_path)
|
|
|
|
logger.info(f"ZIP fonti generato per ordine {id_ordine}: {zip_path} ({len(pdf_paths)} PDF)")
|
|
return FileResponse(
|
|
path=zip_path,
|
|
filename=f"fonti_ordine_{id_ordine}.zip",
|
|
media_type="application/zip"
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Errore export fonti ordine {id_ordine}: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Errore interno: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/orders/list", tags=["Orders"])
|
|
async def list_orders():
|
|
"""
|
|
Recupera la lista di tutti gli ordini con info cliente, compilatore e stato.
|
|
Per ciascun ordine recupera product_name dal documento MongoDB.
|
|
"""
|
|
logger.info("Richiesta lista ordini")
|
|
|
|
try:
|
|
rows = get_all_ordini()
|
|
|
|
orders = []
|
|
orders_col = db_connect(collection_name='orders')
|
|
|
|
for row in rows:
|
|
id_ordine, uuid_ordine, uuid_progetto, data_ordine, stato_ordine, note, \
|
|
nome_cliente, nome_compilatore, nome_stato = row
|
|
|
|
# Recupera product_name da MongoDB
|
|
product_name = None
|
|
if uuid_ordine:
|
|
try:
|
|
from bson import ObjectId
|
|
doc = orders_col.find_one({"_id": ObjectId(uuid_ordine)}, {"product_name": 1})
|
|
if doc:
|
|
product_name = doc.get("product_name")
|
|
except Exception:
|
|
pass
|
|
|
|
orders.append({
|
|
"id_ordine": id_ordine,
|
|
"uuid_ordine": uuid_ordine,
|
|
"uuid_progetto": uuid_progetto,
|
|
"data_ordine": data_ordine.isoformat() if data_ordine else None,
|
|
"stato_ordine": stato_ordine,
|
|
"note": note,
|
|
"nome_cliente": nome_cliente,
|
|
"nome_compilatore": nome_compilatore,
|
|
"nome_stato": nome_stato,
|
|
"product_name": product_name,
|
|
})
|
|
|
|
return {"success": True, "data": orders}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Errore lista ordini: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Errore interno: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/orders/detail/{id_ordine}", tags=["Orders"])
|
|
async def get_order_detail(id_ordine: int):
|
|
"""
|
|
Recupera il dettaglio completo di un ordine: dati PostgreSQL + documento MongoDB.
|
|
Include product_name, preset, lista ingredienti dal JSON originale.
|
|
"""
|
|
logger.info(f"Dettaglio ordine {id_ordine}")
|
|
|
|
try:
|
|
row = get_ordine_by_id(id_ordine)
|
|
if not row:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ordine {id_ordine} non trovato"
|
|
)
|
|
|
|
id_ord, id_cliente, id_compilatore, uuid_ordine, uuid_progetto, \
|
|
data_ordine, stato_ordine, note = row
|
|
|
|
# Recupera nomi da PostgreSQL
|
|
from pif_compiler.functions.db_utils import postgres_connect
|
|
conn = postgres_connect()
|
|
with conn.cursor() as cur:
|
|
nome_cliente = None
|
|
if id_cliente:
|
|
cur.execute("SELECT nome_cliente FROM clienti WHERE id_cliente = %s", (id_cliente,))
|
|
r = cur.fetchone()
|
|
nome_cliente = r[0] if r else None
|
|
|
|
nome_compilatore = None
|
|
if id_compilatore:
|
|
cur.execute("SELECT nome_compilatore FROM compilatori WHERE id_compilatore = %s", (id_compilatore,))
|
|
r = cur.fetchone()
|
|
nome_compilatore = r[0] if r else None
|
|
|
|
cur.execute("SELECT nome_stato FROM stati_ordini WHERE id_stato = %s", (stato_ordine,))
|
|
r = cur.fetchone()
|
|
nome_stato = r[0] if r else None
|
|
conn.close()
|
|
|
|
# Recupera dati dal documento MongoDB ordine
|
|
product_name = None
|
|
preset_esposizione = None
|
|
ingredients = []
|
|
|
|
if uuid_ordine:
|
|
from bson import ObjectId
|
|
orders_col = db_connect(collection_name='orders')
|
|
try:
|
|
doc = orders_col.find_one({"_id": ObjectId(uuid_ordine)})
|
|
if doc:
|
|
product_name = doc.get("product_name")
|
|
preset_esposizione = doc.get("preset_esposizione")
|
|
ingredients = doc.get("ingredients", [])
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"success": True,
|
|
"order": {
|
|
"id_ordine": id_ord,
|
|
"uuid_ordine": uuid_ordine,
|
|
"uuid_progetto": uuid_progetto,
|
|
"data_ordine": data_ordine.isoformat() if data_ordine else None,
|
|
"stato_ordine": stato_ordine,
|
|
"nome_stato": nome_stato,
|
|
"note": note,
|
|
"nome_cliente": nome_cliente,
|
|
"nome_compilatore": nome_compilatore,
|
|
"product_name": product_name,
|
|
"preset_esposizione": preset_esposizione,
|
|
"ingredients": ingredients,
|
|
}
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Errore dettaglio ordine {id_ordine}: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Errore interno: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.delete("/orders/{id_ordine}", tags=["Orders"])
|
|
async def remove_order(id_ordine: int):
|
|
"""
|
|
Elimina un ordine e tutti i dati correlati (progetto, lineage, documenti MongoDB).
|
|
"""
|
|
logger.info(f"Eliminazione ordine {id_ordine}")
|
|
|
|
try:
|
|
success = delete_ordine(id_ordine)
|
|
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Ordine {id_ordine} non trovato o errore nell'eliminazione"
|
|
)
|
|
|
|
return {"success": True, "message": f"Ordine {id_ordine} eliminato con successo"}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Errore eliminazione ordine {id_ordine}: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Errore interno: {str(e)}"
|
|
)
|