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

857 lines
35 KiB
Python
Raw Normal View History

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", "uncertain", "exported", "failed", "posted", "paid"}
def _economic_order_number(export_result: Dict[str, Any]) -> Any:
"""Normalize legacy and catalog-aware e-conomic export response fields."""
return (
export_result.get("economic_order_number")
or export_result.get("economic_draft_id")
or export_result.get("draftOrderNumber")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
def _validate_sync_status_number(sync_status: str, incoming: Any, existing: Any) -> None:
if sync_status in {"exported", "posted", "paid"} and not (incoming if incoming is not None else existing):
raise HTTPException(409, "Status kan ikke sættes til eksporteret uden et e-conomic-ordrenummer")
def _snapshot_lines(lines):
from app.products.backend.economic_documents import active_connection, snapshot_draft_lines
return snapshot_draft_lines(lines) if active_connection() else lines
async def _check_draft_prices(http_request, customer_id, lines, draft_id=None):
from app.products.backend.economic_documents import active_connection
if not active_connection():
return
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
from app.products.backend.economic_pricing import validate_manual_prices
from app.core.database import execute_query_single
actor = await get_current_user(http_request, await security(http_request))
saved = execute_query_single('SELECT customer_id,lines_json FROM ordre_drafts WHERE id=%s', (draft_id,)) if draft_id else None
saved_lines = _safe_json_field(saved['lines_json']) if saved and saved['customer_id'] == customer_id else []
validate_manual_prices(lines, customer_id, actor.get('is_superadmin') or AuthService.user_has_permission(actor['id'], 'economic.pricing.override'), saved_lines)
class OrdreLineInput(BaseModel):
line_key: str
source_type: str
source_id: Optional[int] = None
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
ean: Optional[str] = None
selected: bool = True
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
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
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
create_missing_products: Dict[str, int] = Field(default_factory=dict)
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
class OrdreDraftConsolidateRequest(BaseModel):
draft_ids: List[int] = Field(..., min_length=2)
class EconomicDraftUpdatePreviewRequest(BaseModel):
lines: List[Dict[str, Any]]
notes: Optional[str] = None
class EconomicDraftUpdateConfirmRequest(BaseModel):
request_id: str
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)
from app.products.backend.economic_documents import active_connection
if active_connection() or request.create_missing_products:
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
actor = await get_current_user(http_request, await security(http_request))
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.documents.export'):
raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport')
if request.create_missing_products and not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.catalog.manage'):
raise HTTPException(403, 'Du mangler rettighed til at oprette varer i e-conomic')
user_id = actor['id']
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]
await _check_draft_prices(http_request, request.customer_id, line_payload, request.draft_id)
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,
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
currency=request.currency,
create_missing_products=request.create_missing_products,
export_reference=export_idempotency_key,
)
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 = _economic_order_number(export_result)
economic_invoice_number = (
export_result.get("economic_invoice_number")
or export_result.get("invoice_number")
or export_result.get("invoiceNumber")
)
if not export_result.get("dry_run") and not economic_order_number:
raise HTTPException(502, "e-conomic returnerede intet ordrenummer. Kladden er ikke markeret som eksporteret.")
details = export_result.get("details") or {}
request_payload = details.get("request_payload") or {}
response_payload = details.get("economic_response") or {}
net_amount = sum(
float(line.get("quantity") or 0) * float(line.get("unitNetPrice") or 0)
* (1 - float(line.get("discountPercentage") or 0) / 100)
for line in (request_payload.get("lines") or [])
)
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),
economic_export_id = %s,
economic_order_url = %s,
exported_economic_customer_number = %s,
exported_currency = %s,
exported_layout_number = %s,
exported_net_amount = %s,
export_request_json = %s::jsonb,
export_response_json = %s::jsonb,
exported_created_products = %s::jsonb,
last_export_error = NULL,
exported_by_user_id = %s,
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,
export_result.get("export_id") or export_idempotency_key,
details.get("economic_order_url"),
str(details.get("economic_customer_number") or "") or None,
details.get("currency"),
details.get("layout_number"),
net_amount,
json.dumps(request_payload, ensure_ascii=False),
json.dumps(response_payload, ensure_ascii=False),
json.dumps(details.get("created_product_numbers") or [], ensure_ascii=False),
user_id,
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,
"economic_export_id": export_result.get("export_id") or export_idempotency_key,
"economic_customer_number": details.get("economic_customer_number"),
"currency": details.get("currency"),
"layout_number": details.get("layout_number"),
"net_amount": net_amount,
"created_product_numbers": details.get("created_product_numbers") or [],
},
user_id,
)
return export_result
except HTTPException as exc:
is_preview = isinstance(exc.detail, dict) and exc.detail.get("code") == "economic_products_missing"
if request.draft_id and not is_preview:
try:
from app.core.database import execute_query
execute_query(
"UPDATE ordre_drafts SET last_export_error=%s,last_sync_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s",
(str(exc.detail)[:2000], request.draft_id),
)
_log_sync_event(
request.draft_id, "export_failed", previous_status, previous_status,
{"status_code": exc.status_code, "message": str(exc.detail)[:1000],
"idempotency_key": export_idempotency_key}, user_id,
)
except Exception as log_exc:
logger.warning("Kunne ikke gemme eksportfejl for kladde %s: %s", request.draft_id, log_exc)
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 ordre_drafts.id, ordre_drafts.title, ordre_drafts.customer_id, ordre_drafts.notes, ordre_drafts.layout_number, ordre_drafts.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,
ordre_drafts.last_sync_at, ordre_drafts.created_at, ordre_drafts.updated_at, ordre_drafts.last_exported_at
FROM ordre_drafts
LEFT JOIN LATERAL (
SELECT ordre_draft_sync_events.event_type, ordre_draft_sync_events.created_at
FROM ordre_draft_sync_events
WHERE draft_id = ordre_drafts.id
ORDER BY ordre_draft_sync_events.created_at DESC, ordre_draft_sync_events.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 d.*, c.name AS customer_name,
c.economic_customer_number AS current_economic_customer_number,
u.full_name AS exported_by_name
FROM ordre_drafts d
LEFT JOIN customers c ON c.id = d.customer_id
LEFT JOIN users u ON u.user_id = d.exported_by_user_id
WHERE d.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")
async def _require_economic_order_update(http_request: Request) -> Dict[str, Any]:
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
actor = await get_current_user(http_request, await security(http_request))
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.orders.update'):
raise HTTPException(403, 'Du mangler rettighed til at opdatere e-conomic-ordrekladder')
return actor
@router.post('/ordre/drafts/{draft_id}/economic-update-preview')
async def preview_economic_draft_update(draft_id: int, payload: EconomicDraftUpdatePreviewRequest, http_request: Request):
actor = await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import create_update_preview
return await create_update_preview(draft_id, payload.lines, payload.notes, actor['id'])
@router.post('/ordre/drafts/{draft_id}/economic-update-confirm')
async def confirm_economic_draft_update(draft_id: int, payload: EconomicDraftUpdateConfirmRequest, http_request: Request):
actor = await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import apply_update
return await apply_update(payload.request_id, draft_id, actor['id'])
@router.post('/ordre/drafts/{draft_id}/economic-status-refresh')
async def refresh_economic_draft_status(draft_id: int, http_request: Request):
await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import sync_one_draft
return await sync_one_draft(draft_id, force=True)
@router.post("/ordre/drafts")
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
"""Create a new ordre draft."""
try:
await _check_draft_prices(http_request, request.customer_id, request.lines)
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(_snapshot_lines(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 HTTPException:
raise
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 = 'uncertain') AS uncertain_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,
"uncertain_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, economic_order_number 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()
_validate_sync_status_number(sync_status, economic_order_number, previous.get("economic_order_number"))
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_single
existing = execute_query_single('SELECT customer_id,lines_json,notes,layout_number,sync_status FROM ordre_drafts WHERE id=%s', (draft_id,))
if not existing:
raise HTTPException(status_code=404, detail="Draft not found")
if (existing.get('sync_status') or '').lower() in {'exported','posted','paid'}:
content_changed = (
existing.get('customer_id') != request.customer_id
or (_safe_json_field(existing.get('lines_json')) or []) != request.lines
or (existing.get('notes') or None) != (request.notes or None)
or existing.get('layout_number') != request.layout_number
)
if content_changed:
raise HTTPException(409, 'Brug “Opdatér e-conomic-kladde”, så ændringen forhåndsvises og gemmes begge steder.')
await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id)
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(_snapshot_lines(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")
@router.post("/ordre/drafts/consolidate")
async def consolidate_ordre_drafts(payload: OrdreDraftConsolidateRequest, http_request: Request):
"""Consolidate two or more drafts for the same customer into one draft."""
try:
draft_ids = sorted(set(int(x) for x in payload.draft_ids if int(x) > 0))
if len(draft_ids) < 2:
raise HTTPException(status_code=400, detail="Select at least two drafts to consolidate")
placeholders = ",".join(["%s"] * len(draft_ids))
from app.core.database import execute_query, execute_query_single
rows = execute_query(
f"""
SELECT *
FROM ordre_drafts
WHERE id IN ({placeholders})
ORDER BY id ASC
""",
tuple(draft_ids),
) or []
if len(rows) != len(draft_ids):
raise HTTPException(status_code=404, detail="One or more drafts were not found")
customer_ids = {row.get("customer_id") for row in rows}
if len(customer_ids) != 1:
raise HTTPException(status_code=400, detail="Drafts must belong to the same customer")
customer_id = next(iter(customer_ids))
if customer_id is None:
raise HTTPException(status_code=400, detail="Drafts without customer_id cannot be consolidated")
blocked_statuses = {"exported", "posted", "paid"}
for row in rows:
sync_status = str(row.get("sync_status") or "pending").strip().lower()
if sync_status in blocked_statuses:
raise HTTPException(
status_code=400,
detail=f"Draft #{row.get('id')} has status '{sync_status}' and cannot be consolidated",
)
primary = rows[0]
secondary_ids = [int(row["id"]) for row in rows[1:]]
merged_lines: List[Dict[str, Any]] = []
for row in rows:
lines = _safe_json_field(row.get("lines_json")) or []
if isinstance(lines, list):
merged_lines.extend(lines)
notes_parts = [str(row.get("notes")).strip() for row in rows if row.get("notes")]
merged_notes = "\n\n".join(part for part in notes_parts if part)
aggregate_key = primary.get("invoice_aggregate_key") or f"consolidated-customer-{customer_id}"
keep_id = int(primary["id"])
execute_query(
"""
UPDATE ordre_drafts
SET lines_json = %s::jsonb,
notes = %s,
invoice_aggregate_key = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(
json.dumps(merged_lines, ensure_ascii=False),
merged_notes or None,
aggregate_key,
keep_id,
),
)
if secondary_ids:
delete_placeholders = ",".join(["%s"] * len(secondary_ids))
execute_query(
f"DELETE FROM ordre_drafts WHERE id IN ({delete_placeholders})",
tuple(secondary_ids),
)
updated = execute_query_single("SELECT * FROM ordre_drafts WHERE id = %s", (keep_id,))
_log_sync_event(
keep_id,
"drafts_consolidated",
primary.get("sync_status"),
primary.get("sync_status"),
{
"merged_from": draft_ids,
"kept_id": keep_id,
"customer_id": customer_id,
"line_count": len(merged_lines),
},
_get_user_id_from_request(http_request),
)
return {
"success": True,
"kept_draft_id": keep_id,
"merged_draft_ids": draft_ids,
"removed_draft_ids": secondary_ids,
"line_count": len(merged_lines),
"draft": updated,
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error consolidating ordre drafts: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to consolidate ordre drafts")