- Added migration to create economic_change_requests table for tracking change requests for customers and products. - Introduced new permissions for requesting and approving changes in e-conomic. - Developed frontend JavaScript functionality for managing economic catalog, including handling change requests and displaying their statuses. - Created browser tests to validate UI interactions related to product management and change requests. - Added unit tests for backend logic to ensure proper handling of product numbers, price rules, and economic write policies.
726 lines
27 KiB
Python
726 lines
27 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"}
|
|
|
|
|
|
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
|
|
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}$')
|
|
|
|
|
|
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)
|
|
|
|
|
|
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():
|
|
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')
|
|
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,
|
|
)
|
|
|
|
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 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 * 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:
|
|
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 = '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:
|
|
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")
|