bmc_hub/app/modules/orders/backend/router.py

575 lines
21 KiB
Python

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")