- Added endpoints to link and unlink customers to vendors, including validation for relationship types. - Implemented a UI for managing linked customers in the vendor detail view. - Introduced a search feature for customers when linking to vendors. - Updated database schema to support customer-vendor relationships with necessary constraints and indices. - Added migration scripts for new tables and fields related to supplier invoices and customer-vendor links. - Modified bottom bar visibility in the frontend for improved user experience.
289 lines
10 KiB
Python
289 lines
10 KiB
Python
"""
|
|
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.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}
|