import logging import json from datetime import datetime from uuid import uuid4 from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel, Field from app.modules.orders.backend.economic_export import ordre_economic_export_service from app.modules.orders.backend.service import aggregate_order_lines logger = logging.getLogger(__name__) router = APIRouter() ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"} class OrdreLineInput(BaseModel): line_key: str source_type: str source_id: int description: str quantity: float = Field(gt=0) unit_price: float = Field(ge=0) discount_percentage: float = Field(default=0, ge=0, le=100) unit: Optional[str] = None product_id: Optional[int] = None selected: bool = True class OrdreExportRequest(BaseModel): customer_id: int lines: List[OrdreLineInput] notes: Optional[str] = None layout_number: Optional[int] = None draft_id: Optional[int] = None force_export: bool = False class OrdreDraftUpsertRequest(BaseModel): title: str = Field(min_length=1, max_length=120) customer_id: Optional[int] = None lines: List[Dict[str, Any]] = Field(default_factory=list) notes: Optional[str] = None layout_number: Optional[int] = None def _safe_json_field(value: Any) -> Any: if value is None: return None if isinstance(value, (dict, list)): return value if isinstance(value, str): try: return json.loads(value) except json.JSONDecodeError: return value return value def _get_user_id_from_request(http_request: Request) -> Optional[int]: state_user_id = getattr(http_request.state, "user_id", None) if state_user_id is None: return None try: return int(state_user_id) except (TypeError, ValueError): return None def _log_sync_event( draft_id: int, event_type: str, from_status: Optional[str], to_status: Optional[str], event_payload: Dict[str, Any], user_id: Optional[int], ) -> None: """Best-effort logging of sync events for ordre_drafts.""" try: from app.core.database import execute_query execute_query( """ INSERT INTO ordre_draft_sync_events ( draft_id, event_type, from_status, to_status, event_payload, created_by_user_id ) VALUES (%s, %s, %s, %s, %s::jsonb, %s) """, ( draft_id, event_type, from_status, to_status, json.dumps(event_payload, ensure_ascii=False), user_id, ) ) except Exception as e: logger.warning("⚠️ Could not log ordre sync event for draft %s: %s", draft_id, e) @router.get("/ordre/aggregate") async def get_ordre_aggregate( customer_id: Optional[int] = Query(None), sag_id: Optional[int] = Query(None), q: Optional[str] = Query(None), ): """Aggregate global ordre lines from subscriptions, hardware and sales.""" try: return aggregate_order_lines(customer_id=customer_id, sag_id=sag_id, q=q) except Exception as e: logger.error("❌ Error aggregating ordre lines: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to aggregate ordre lines") @router.get("/ordre/config") async def get_ordre_config(): """Return ordre module safety config for frontend banner.""" return { "economic_read_only": ordre_economic_export_service.read_only, "economic_dry_run": ordre_economic_export_service.dry_run, "default_layout": ordre_economic_export_service.default_layout, "default_product": ordre_economic_export_service.default_product, } @router.post("/ordre/export") async def export_ordre(request: OrdreExportRequest, http_request: Request): """Export selected ordre lines to e-conomic draft order.""" try: user_id = _get_user_id_from_request(http_request) previous_status = None export_idempotency_key = None if request.draft_id: from app.core.database import execute_query_single draft_row = execute_query_single( """ SELECT id, sync_status, export_idempotency_key, export_status_json FROM ordre_drafts WHERE id = %s """, (request.draft_id,) ) if not draft_row: raise HTTPException(status_code=404, detail="Draft not found") previous_status = (draft_row.get("sync_status") or "pending").strip().lower() if previous_status in {"exported", "posted", "paid"} and not request.force_export: raise HTTPException( status_code=409, detail=f"Draft already exported with status '{previous_status}'. Use force_export=true to retry.", ) export_idempotency_key = draft_row.get("export_idempotency_key") or str(uuid4()) _log_sync_event( request.draft_id, "export_attempt", previous_status, previous_status, {"force_export": request.force_export, "idempotency_key": export_idempotency_key}, user_id, ) line_payload = [line.model_dump() for line in request.lines] export_result = await ordre_economic_export_service.export_order( customer_id=request.customer_id, lines=line_payload, notes=request.notes, layout_number=request.layout_number, user_id=user_id, ) exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")] export_result["exported_line_keys"] = exported_line_keys if request.draft_id: from app.core.database import execute_query_single, execute_query existing = execute_query_single("SELECT export_status_json FROM ordre_drafts WHERE id = %s", (request.draft_id,)) existing_status = _safe_json_field((existing or {}).get("export_status_json")) or {} if not isinstance(existing_status, dict): existing_status = {} line_status = "dry-run" if export_result.get("dry_run") else "exported" for line_key in exported_line_keys: existing_status[line_key] = { "status": line_status, "timestamp": datetime.utcnow().isoformat(), } economic_order_number = ( export_result.get("economic_order_number") or export_result.get("order_number") or export_result.get("orderNumber") ) economic_invoice_number = ( export_result.get("economic_invoice_number") or export_result.get("invoice_number") or export_result.get("invoiceNumber") ) target_sync_status = "pending" if export_result.get("dry_run") else "exported" execute_query( """ UPDATE ordre_drafts SET export_status_json = %s::jsonb, sync_status = %s, export_idempotency_key = %s, economic_order_number = COALESCE(%s, economic_order_number), economic_invoice_number = COALESCE(%s, economic_invoice_number), last_sync_at = CURRENT_TIMESTAMP, last_exported_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, ( json.dumps(existing_status, ensure_ascii=False), target_sync_status, export_idempotency_key, str(economic_order_number) if economic_order_number is not None else None, str(economic_invoice_number) if economic_invoice_number is not None else None, request.draft_id, ), ) _log_sync_event( request.draft_id, "export_success", previous_status, target_sync_status, { "dry_run": bool(export_result.get("dry_run")), "idempotency_key": export_idempotency_key, "economic_order_number": economic_order_number, "economic_invoice_number": economic_invoice_number, }, user_id, ) return export_result except HTTPException: raise except Exception as e: logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to export ordre") @router.get("/ordre/drafts") async def list_ordre_drafts( http_request: Request, limit: int = Query(25, ge=1, le=100) ): """List all ordre drafts (no user filtering).""" try: query = """ SELECT id, title, customer_id, notes, layout_number, created_by_user_id, coverage_start, coverage_end, billing_direction, source_subscription_ids, invoice_aggregate_key, sync_status, export_idempotency_key, economic_order_number, economic_invoice_number, ev_latest.event_type AS latest_event_type, ev_latest.created_at AS latest_event_at, ( SELECT COUNT(*) FROM ordre_draft_sync_events ev WHERE ev.draft_id = ordre_drafts.id ) AS sync_event_count, last_sync_at, created_at, updated_at, last_exported_at FROM ordre_drafts LEFT JOIN LATERAL ( SELECT event_type, created_at FROM ordre_draft_sync_events WHERE draft_id = ordre_drafts.id ORDER BY created_at DESC, id DESC LIMIT 1 ) ev_latest ON TRUE ORDER BY updated_at DESC, id DESC LIMIT %s """ params = (limit,) from app.core.database import execute_query return execute_query(query, params) or [] except Exception as e: logger.error("❌ Error listing ordre drafts: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to list ordre drafts") @router.get("/ordre/drafts/{draft_id}") async def get_ordre_draft(draft_id: int, http_request: Request): """Get single ordre draft with lines payload (no user filtering).""" try: query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1" params = (draft_id,) from app.core.database import execute_query_single draft = execute_query_single(query, params) if not draft: raise HTTPException(status_code=404, detail="Draft not found") draft["lines_json"] = _safe_json_field(draft.get("lines_json")) or [] draft["export_status_json"] = _safe_json_field(draft.get("export_status_json")) or {} return draft except HTTPException: raise except Exception as e: logger.error("❌ Error fetching ordre draft: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to fetch ordre draft") @router.post("/ordre/drafts") async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request): """Create a new ordre draft.""" try: user_id = _get_user_id_from_request(http_request) from app.core.database import execute_query query = """ INSERT INTO ordre_drafts ( title, customer_id, lines_json, notes, layout_number, created_by_user_id, sync_status, export_status_json, updated_at ) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, CURRENT_TIMESTAMP) RETURNING * """ params = ( request.title, request.customer_id, json.dumps(request.lines, ensure_ascii=False), request.notes, request.layout_number, user_id, json.dumps({}, ensure_ascii=False), ) result = execute_query(query, params) return result[0] except Exception as e: logger.error("❌ Error creating ordre draft: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to create ordre draft") @router.get("/ordre/drafts/sync-status/summary") async def get_ordre_draft_sync_summary(http_request: Request): """Return sync status counters for ordre drafts.""" try: from app.core.database import execute_query_single query = """ SELECT COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count, COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count, COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count, COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count, COUNT(*) FILTER (WHERE sync_status = 'paid') AS paid_count, COUNT(*) AS total_count FROM ordre_drafts """ return execute_query_single(query, ()) or { "pending_count": 0, "exported_count": 0, "failed_count": 0, "posted_count": 0, "paid_count": 0, "total_count": 0, } except Exception as e: logger.error("❌ Error loading ordre sync summary: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to load sync summary") @router.patch("/ordre/drafts/{draft_id}/sync-status") async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any], http_request: Request): """Update sync lifecycle fields for one ordre draft.""" try: user_id = _get_user_id_from_request(http_request) sync_status = (payload.get("sync_status") or "").strip().lower() if sync_status not in ALLOWED_SYNC_STATUSES: raise HTTPException(status_code=400, detail="Invalid sync_status") economic_order_number = payload.get("economic_order_number") economic_invoice_number = payload.get("economic_invoice_number") export_status_json = payload.get("export_status_json") updates = ["sync_status = %s", "last_sync_at = CURRENT_TIMESTAMP", "updated_at = CURRENT_TIMESTAMP"] values: List[Any] = [sync_status] if economic_order_number is not None: updates.append("economic_order_number = %s") values.append(str(economic_order_number) if economic_order_number else None) if economic_invoice_number is not None: updates.append("economic_invoice_number = %s") values.append(str(economic_invoice_number) if economic_invoice_number else None) if export_status_json is not None: updates.append("export_status_json = %s::jsonb") values.append(json.dumps(export_status_json, ensure_ascii=False)) if sync_status in {"exported", "posted", "paid"}: updates.append("last_exported_at = CURRENT_TIMESTAMP") from app.core.database import execute_query_single previous = execute_query_single( "SELECT sync_status FROM ordre_drafts WHERE id = %s", (draft_id,) ) if not previous: raise HTTPException(status_code=404, detail="Draft not found") from_status = (previous.get("sync_status") or "pending").strip().lower() values.append(draft_id) from app.core.database import execute_query result = execute_query( f""" UPDATE ordre_drafts SET {', '.join(updates)} WHERE id = %s RETURNING * """, tuple(values) ) if not result: raise HTTPException(status_code=404, detail="Draft not found") _log_sync_event( draft_id, "sync_status_manual_update", from_status, sync_status, { "economic_order_number": economic_order_number, "economic_invoice_number": economic_invoice_number, }, user_id, ) return result[0] except HTTPException: raise except Exception as e: logger.error("❌ Error updating ordre draft sync status: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to update draft sync status") @router.get("/ordre/drafts/{draft_id}/sync-events") async def list_ordre_draft_sync_events( draft_id: int, http_request: Request, limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), event_type: Optional[str] = Query(None), from_status: Optional[str] = Query(None), to_status: Optional[str] = Query(None), from_date: Optional[str] = Query(None), to_date: Optional[str] = Query(None), ): """List audit events for one ordre draft sync lifecycle.""" try: from app.core.database import execute_query where_clauses = ["draft_id = %s"] params: List[Any] = [draft_id] if event_type: where_clauses.append("event_type = %s") params.append(event_type) if from_status: where_clauses.append("from_status = %s") params.append(from_status) if to_status: where_clauses.append("to_status = %s") params.append(to_status) if from_date: where_clauses.append("created_at >= %s::timestamp") params.append(from_date) if to_date: where_clauses.append("created_at <= %s::timestamp") params.append(to_date) count_query = f""" SELECT COUNT(*) AS total FROM ordre_draft_sync_events WHERE {' AND '.join(where_clauses)} """ total_row = execute_query(count_query, tuple(params)) or [{"total": 0}] total = int(total_row[0].get("total") or 0) data_query = f""" SELECT id, draft_id, event_type, from_status, to_status, event_payload, created_by_user_id, created_at FROM ordre_draft_sync_events WHERE {' AND '.join(where_clauses)} ORDER BY created_at DESC, id DESC LIMIT %s OFFSET %s """ params.extend([limit, offset]) rows = execute_query(data_query, tuple(params)) or [] return { "items": rows, "total": total, "limit": limit, "offset": offset, } except Exception as e: logger.error("❌ Error listing ordre draft sync events: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to list sync events") @router.patch("/ordre/drafts/{draft_id}") async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request): """Update existing ordre draft.""" try: from app.core.database import execute_query query = """ UPDATE ordre_drafts SET title = %s, customer_id = %s, lines_json = %s::jsonb, notes = %s, layout_number = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING * """ params = ( request.title, request.customer_id, json.dumps(request.lines, ensure_ascii=False), request.notes, request.layout_number, draft_id, ) result = execute_query(query, params) if not result: raise HTTPException(status_code=404, detail="Draft not found") return result[0] except HTTPException: raise except Exception as e: logger.error("❌ Error updating ordre draft: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to update ordre draft") @router.delete("/ordre/drafts/{draft_id}") async def delete_ordre_draft(draft_id: int, http_request: Request): """Delete ordre draft.""" try: from app.core.database import execute_query query = "DELETE FROM ordre_drafts WHERE id = %s RETURNING id" params = (draft_id,) result = execute_query(query, params) if not result: raise HTTPException(status_code=404, detail="Draft not found") return {"status": "deleted", "id": draft_id} except HTTPException: raise except Exception as e: logger.error("❌ Error deleting ordre draft: %s", e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to delete ordre draft")