575 lines
21 KiB
Python
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")
|