bmc_hub/app/vendors/backend/router.py

353 lines
13 KiB
Python
Raw Normal View History

"""
Vendors API Router
Endpoints for managing suppliers and vendors
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
from pydantic import BaseModel
from app.models.schemas import Vendor, VendorCreate, VendorUpdate
from app.core.database import execute_query, execute_query_single, execute_update
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
def _ensure_customer_supplier_tag(customer_id: int) -> None:
"""Ensure linked customers are tagged as suppliers."""
try:
tag = execute_query_single(
"SELECT id FROM tags WHERE LOWER(name) = 'supplier' AND type = 'category' LIMIT 1"
)
if tag and tag.get("id") is not None:
tag_id = int(tag["id"])
else:
created = execute_query_single(
"""
INSERT INTO tags (name, type, description, color, is_active)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, type)
DO UPDATE SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
RETURNING id
""",
("Supplier", "category", "Customer also acts as supplier", "#0f4c75", True),
)
tag_id = int(created["id"]) if created and created.get("id") is not None else None
if not tag_id:
return
execute_query(
"""
INSERT INTO entity_tags (entity_type, entity_id, tag_id)
VALUES (%s, %s, %s)
ON CONFLICT (entity_type, entity_id, tag_id) DO NOTHING
""",
("customer", customer_id, tag_id),
)
except Exception as tag_error:
logger.warning("⚠️ Could not ensure supplier tag for customer %s: %s", customer_id, tag_error)
class VendorCustomerLinkCreate(BaseModel):
customer_id: int
relationship_type: Optional[str] = "supplier"
@router.get("/vendors", response_model=List[Vendor], tags=["Vendors"])
async def list_vendors(
search: Optional[str] = Query(None, description="Search by name, CVR, or domain"),
category: Optional[str] = Query(None, description="Filter by category"),
is_active: Optional[bool] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100)
):
"""Get list of vendors with optional filtering"""
query = "SELECT * FROM vendors WHERE 1=1"
params = []
if search:
query += " AND (name ILIKE %s OR cvr_number ILIKE %s OR domain ILIKE %s)"
search_param = f"%{search}%"
params.extend([search_param, search_param, search_param])
if category:
query += " AND category = %s"
params.append(category)
if is_active is not None:
query += " AND is_active = %s"
params.append(is_active)
query += " ORDER BY name LIMIT %s OFFSET %s"
params.extend([limit, skip])
result = execute_query(query, tuple(params))
return result or []
@router.get("/vendors/{vendor_id}", response_model=Vendor, tags=["Vendors"])
async def get_vendor(vendor_id: int):
"""Get vendor by ID"""
query = "SELECT * FROM vendors WHERE id = %s"
result = execute_query(query, (vendor_id,))
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Vendor not found")
return result[0]
@router.get("/vendors/{vendor_id}/invoices", tags=["Vendors"])
async def get_vendor_invoices(vendor_id: int):
"""Return booked invoices together with invoices that only exist as extractions."""
if not execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)):
raise HTTPException(status_code=404, detail="Vendor not found")
rows = execute_query(
"""
WITH booked AS (
SELECT si.id, si.invoice_number, si.invoice_date, si.due_date,
si.total_amount, si.currency,
CASE
WHEN si.status IN ('cancelled', 'credited', 'rejected') THEN si.status
WHEN si.paid_date IS NOT NULL THEN 'paid'
WHEN si.due_date < CURRENT_DATE AND si.paid_date IS NULL THEN 'overdue'
ELSE si.status
END AS status,
'supplier_invoice'::text AS source_type,
si.extraction_id, e.file_id, si.created_at AS sort_date
FROM supplier_invoices si
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
WHERE si.vendor_id = %s
),
latest_extractions AS (
SELECT DISTINCT ON (TRIM(e.document_id))
NULL::integer AS id, e.document_id AS invoice_number,
e.document_date AS invoice_date, e.due_date, e.total_amount,
e.currency, COALESCE(run.status, e.status, file.status, 'extracted') AS status,
'extraction'::text AS source_type,
e.extraction_id, e.file_id,
COALESCE(run.processed_at, e.created_at) AS sort_date
FROM extractions e
LEFT JOIN incoming_files file ON file.file_id = e.file_id
LEFT JOIN LATERAL (
SELECT sync.status, sync.processed_at
FROM internet_connections_invoice_sync_runs sync
WHERE sync.extraction_id = e.extraction_id
OR (
sync.invoice_number = e.document_id
AND COALESCE(sync.vendor_name, '') = COALESCE(e.vendor_name, '')
)
ORDER BY sync.processed_at DESC, sync.id DESC
LIMIT 1
) run ON TRUE
WHERE e.vendor_matched_id = %s
AND NULLIF(TRIM(e.document_id), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM supplier_invoices si
WHERE si.vendor_id = %s
AND (si.extraction_id = e.extraction_id OR si.invoice_number = e.document_id)
)
ORDER BY TRIM(e.document_id), e.created_at DESC, e.extraction_id DESC
)
SELECT * FROM booked
UNION ALL
SELECT * FROM latest_extractions
ORDER BY sort_date DESC NULLS LAST, invoice_date DESC NULLS LAST
""",
(vendor_id, vendor_id, vendor_id),
)
return rows or []
@router.post("/vendors", response_model=Vendor, tags=["Vendors"])
async def create_vendor(vendor: VendorCreate):
"""Create a new vendor"""
try:
query = """
INSERT INTO vendors (
name, cvr_number, email, phone, address, postal_code, city,
website, domain, email_pattern, category, priority, notes, is_active
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
vendor.name, vendor.cvr_number, vendor.email, vendor.phone,
vendor.address, vendor.postal_code, vendor.city, vendor.website,
vendor.domain, vendor.email_pattern, vendor.category, vendor.priority,
vendor.notes, vendor.is_active
)
result = execute_query(query, params)
if not result or len(result) == 0:
raise HTTPException(status_code=500, detail="Failed to create vendor")
logger.info(f"✅ Created vendor: {vendor.name}")
return result[0]
except Exception as e:
logger.error(f"❌ Error creating vendor: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/vendors/{vendor_id}", response_model=Vendor, tags=["Vendors"])
async def update_vendor(vendor_id: int, vendor: VendorUpdate):
"""Update an existing vendor"""
# Check if vendor exists
existing = execute_query("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not existing:
raise HTTPException(status_code=404, detail="Vendor not found")
# Build update query
update_fields = []
params = []
if vendor.name is not None:
update_fields.append("name = %s")
params.append(vendor.name)
if vendor.cvr_number is not None:
update_fields.append("cvr_number = %s")
params.append(vendor.cvr_number)
if vendor.email is not None:
update_fields.append("email = %s")
params.append(vendor.email)
if vendor.phone is not None:
update_fields.append("phone = %s")
params.append(vendor.phone)
if vendor.address is not None:
update_fields.append("address = %s")
params.append(vendor.address)
if vendor.postal_code is not None:
update_fields.append("postal_code = %s")
params.append(vendor.postal_code)
if vendor.city is not None:
update_fields.append("city = %s")
params.append(vendor.city)
if vendor.website is not None:
update_fields.append("website = %s")
params.append(vendor.website)
if vendor.domain is not None:
update_fields.append("domain = %s")
params.append(vendor.domain)
if vendor.email_pattern is not None:
update_fields.append("email_pattern = %s")
params.append(vendor.email_pattern)
if vendor.category is not None:
update_fields.append("category = %s")
params.append(vendor.category)
if vendor.priority is not None:
update_fields.append("priority = %s")
params.append(vendor.priority)
if vendor.notes is not None:
update_fields.append("notes = %s")
params.append(vendor.notes)
if vendor.is_active is not None:
update_fields.append("is_active = %s")
params.append(vendor.is_active)
if not update_fields:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(vendor_id)
query = f"UPDATE vendors SET {', '.join(update_fields)} WHERE id = %s RETURNING *"
try:
result = execute_query(query, tuple(params))
if not result:
raise HTTPException(status_code=500, detail="Failed to update vendor")
logger.info(f"✅ Updated vendor: {vendor_id}")
return result[0]
except Exception as e:
logger.error(f"❌ Error updating vendor: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/vendors/{vendor_id}", tags=["Vendors"])
async def delete_vendor(vendor_id: int):
"""Soft delete a vendor (set is_active = false)"""
query = "UPDATE vendors SET is_active = false WHERE id = %s RETURNING id"
result = execute_query(query, (vendor_id,))
if not result:
raise HTTPException(status_code=404, detail="Vendor not found")
logger.info(f"✅ Deleted vendor: {vendor_id}")
return {"message": "Vendor deleted successfully"}
@router.get("/vendors/{vendor_id}/customers", tags=["Vendors"])
async def list_vendor_customers(vendor_id: int):
"""List customers linked to a vendor."""
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
rows = execute_query(
"""
SELECT
l.id,
l.customer_id,
l.vendor_id,
l.relationship_type,
l.created_at,
l.updated_at,
c.name AS customer_name,
c.email AS customer_email,
c.cvr_number AS customer_cvr
FROM customer_vendor_links l
JOIN customers c ON c.id = l.customer_id
WHERE l.vendor_id = %s
ORDER BY c.name ASC, l.id ASC
""",
(vendor_id,),
) or []
return rows
@router.post("/vendors/{vendor_id}/customers", tags=["Vendors"])
async def link_vendor_to_customer(vendor_id: int, payload: VendorCustomerLinkCreate):
"""Create link between vendor and customer."""
vendor = execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,))
if not vendor:
raise HTTPException(status_code=404, detail="Vendor not found")
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
relationship_type = str(payload.relationship_type or "supplier").strip().lower()
if relationship_type not in {"supplier", "reseller", "partner"}:
raise HTTPException(status_code=400, detail="relationship_type must be supplier, reseller, or partner")
row = execute_query_single(
"""
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
VALUES (%s, %s, %s)
ON CONFLICT (customer_id, vendor_id)
DO UPDATE SET
relationship_type = EXCLUDED.relationship_type,
updated_at = CURRENT_TIMESTAMP
RETURNING id, customer_id, vendor_id, relationship_type, created_at, updated_at
""",
(payload.customer_id, vendor_id, relationship_type),
)
_ensure_customer_supplier_tag(int(payload.customer_id))
return row
@router.delete("/vendors/{vendor_id}/customers/{customer_id}", tags=["Vendors"])
async def unlink_vendor_from_customer(vendor_id: int, customer_id: int):
"""Delete link between vendor and customer."""
deleted = execute_update(
"DELETE FROM customer_vendor_links WHERE vendor_id = %s AND customer_id = %s",
(vendor_id, customer_id),
)
if not deleted:
raise HTTPException(status_code=404, detail="Link not found")
return {"success": True, "vendor_id": vendor_id, "customer_id": customer_id}