- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview. - Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities. - Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management. - Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions. - Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths. - Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management. - Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql). - Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql). - Added a script to move time tracking section in detail view (move_time_section.py). - Created a test script for subscription processing (test_subscription_processing.py).
281 lines
9.6 KiB
Python
281 lines
9.6 KiB
Python
import logging
|
|
import json
|
|
from datetime import datetime
|
|
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()
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
@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)
|
|
|
|
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(),
|
|
}
|
|
|
|
execute_query(
|
|
"""
|
|
UPDATE ordre_drafts
|
|
SET export_status_json = %s::jsonb,
|
|
last_exported_at = CURRENT_TIMESTAMP,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
""",
|
|
(json.dumps(existing_status, ensure_ascii=False), request.draft_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,
|
|
created_at, updated_at, last_exported_at
|
|
FROM ordre_drafts
|
|
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,
|
|
export_status_json,
|
|
updated_at
|
|
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, %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.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")
|