feat: Enhance vendor and customer linking functionality
- 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.
This commit is contained in:
parent
13dc1736b4
commit
8e8616c835
File diff suppressed because it is too large
Load Diff
@ -23,6 +23,42 @@ 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)
|
||||
|
||||
|
||||
# Pydantic Models
|
||||
class CustomerBase(BaseModel):
|
||||
name: str
|
||||
@ -517,6 +553,78 @@ async def get_customer_utility_company(customer_id: int):
|
||||
"supplier": supplier
|
||||
}
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/vendors")
|
||||
async def list_customer_vendors(customer_id: int):
|
||||
"""List vendors linked to a customer."""
|
||||
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
l.id,
|
||||
l.customer_id,
|
||||
l.vendor_id,
|
||||
l.relationship_type,
|
||||
l.created_at,
|
||||
l.updated_at,
|
||||
v.name AS vendor_name,
|
||||
v.email AS vendor_email,
|
||||
v.cvr_number AS vendor_cvr
|
||||
FROM customer_vendor_links l
|
||||
JOIN vendors v ON v.id = l.vendor_id
|
||||
WHERE l.customer_id = %s
|
||||
ORDER BY v.name ASC, l.id ASC
|
||||
""",
|
||||
(customer_id,),
|
||||
) or []
|
||||
return rows
|
||||
|
||||
|
||||
@router.post("/customers/{customer_id}/vendors/{vendor_id}")
|
||||
async def link_customer_to_vendor(customer_id: int, vendor_id: int, relationship_type: str = Query("supplier")):
|
||||
"""Create or update a customer-vendor link."""
|
||||
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
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")
|
||||
|
||||
rel = str(relationship_type or "supplier").strip().lower()
|
||||
if rel 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
|
||||
""",
|
||||
(customer_id, vendor_id, rel),
|
||||
)
|
||||
_ensure_customer_supplier_tag(int(customer_id))
|
||||
return row
|
||||
|
||||
|
||||
@router.delete("/customers/{customer_id}/vendors/{vendor_id}")
|
||||
async def unlink_customer_from_vendor(customer_id: int, vendor_id: int):
|
||||
"""Remove customer-vendor link."""
|
||||
deleted = execute_update(
|
||||
"DELETE FROM customer_vendor_links WHERE customer_id = %s AND vendor_id = %s",
|
||||
(customer_id, vendor_id),
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Link not found")
|
||||
return {"success": True, "customer_id": customer_id, "vendor_id": vendor_id}
|
||||
|
||||
@router.post("/customers")
|
||||
async def create_customer(customer: CustomerCreate):
|
||||
"""Create a new customer"""
|
||||
@ -1096,7 +1204,69 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
try:
|
||||
# Create contact
|
||||
normalized_email = (contact.email or "").strip().lower() or None
|
||||
existing_contact = None
|
||||
|
||||
# Prefer exact email match scoped to this customer, then global email match.
|
||||
if normalized_email:
|
||||
existing_contact = execute_query_single(
|
||||
"""
|
||||
SELECT c.*
|
||||
FROM contacts c
|
||||
JOIN contact_companies cc ON cc.contact_id = c.id
|
||||
WHERE cc.customer_id = %s
|
||||
AND LOWER(COALESCE(c.email, '')) = %s
|
||||
ORDER BY c.id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(customer_id, normalized_email),
|
||||
)
|
||||
if not existing_contact:
|
||||
existing_contact = execute_query_single(
|
||||
"""
|
||||
SELECT c.*
|
||||
FROM contacts c
|
||||
WHERE LOWER(COALESCE(c.email, '')) = %s
|
||||
ORDER BY c.id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(normalized_email,),
|
||||
)
|
||||
|
||||
# Fallback dedupe by full name within same customer when email is missing.
|
||||
if not existing_contact and not normalized_email:
|
||||
existing_contact = execute_query_single(
|
||||
"""
|
||||
SELECT c.*
|
||||
FROM contacts c
|
||||
JOIN contact_companies cc ON cc.contact_id = c.id
|
||||
WHERE cc.customer_id = %s
|
||||
AND LOWER(COALESCE(c.first_name, '')) = LOWER(%s)
|
||||
AND LOWER(COALESCE(c.last_name, '')) = LOWER(%s)
|
||||
ORDER BY c.id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(customer_id, contact.first_name, contact.last_name),
|
||||
)
|
||||
|
||||
if existing_contact:
|
||||
contact_id = int(existing_contact["id"])
|
||||
execute_update(
|
||||
"""
|
||||
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (contact_id, customer_id)
|
||||
DO UPDATE SET
|
||||
is_primary = contact_companies.is_primary OR EXCLUDED.is_primary,
|
||||
role = COALESCE(contact_companies.role, EXCLUDED.role)
|
||||
""",
|
||||
(contact_id, customer_id, bool(contact.is_primary), contact.role),
|
||||
)
|
||||
|
||||
logger.info("✅ Reused contact %s for customer %s", contact_id, customer_id)
|
||||
return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
|
||||
|
||||
# Create contact when no reusable match exists.
|
||||
contact_id = execute_insert(
|
||||
"""INSERT INTO contacts
|
||||
(first_name, last_name, email, phone, mobile, title, department)
|
||||
@ -1105,7 +1275,7 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
||||
(
|
||||
contact.first_name,
|
||||
contact.last_name,
|
||||
contact.email,
|
||||
normalized_email,
|
||||
contact.phone,
|
||||
contact.mobile,
|
||||
contact.title,
|
||||
@ -1114,11 +1284,12 @@ async def create_customer_contact(customer_id: int, contact: ContactCreate):
|
||||
)
|
||||
|
||||
# Link contact to customer
|
||||
execute_insert(
|
||||
execute_update(
|
||||
"""INSERT INTO contact_companies
|
||||
(contact_id, customer_id, is_primary, role)
|
||||
VALUES (%s, %s, %s, %s)""",
|
||||
(contact_id, customer_id, contact.is_primary, contact.role)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
|
||||
(contact_id, customer_id, bool(contact.is_primary), contact.role)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Created contact {contact_id} for customer {customer_id}")
|
||||
|
||||
@ -498,6 +498,32 @@
|
||||
<div id="customerTagsEmpty" class="text-muted small">Ingen tags tilføjet endnu.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="info-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="fw-bold mb-0">Leverandørrelationer</h5>
|
||||
<span class="badge bg-primary" id="customerVendorsCount">0</span>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-8">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="customerVendorSearch"
|
||||
placeholder="Søg leverandør (navn, CVR, domæne)"
|
||||
oninput="searchVendorsForCustomer(this.value)"
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<small class="text-muted">Knyt kunde til leverandør</small>
|
||||
</div>
|
||||
</div>
|
||||
<div id="customerVendorSearchResults" class="list-group mb-3" style="display:none;"></div>
|
||||
<div id="customerVendorLinksContainer" class="list-group mb-2"></div>
|
||||
<div id="customerVendorLinksEmpty" class="text-muted small">Ingen linked leverandører endnu.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1423,6 +1449,7 @@ async function loadCustomer() {
|
||||
|
||||
await loadUtilityCompany();
|
||||
await loadCustomerTags();
|
||||
await loadCustomerVendorLinks();
|
||||
|
||||
// Check data consistency
|
||||
await checkDataConsistency();
|
||||
@ -1433,6 +1460,141 @@ async function loadCustomer() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomerVendorLinks() {
|
||||
const container = document.getElementById('customerVendorLinksContainer');
|
||||
const empty = document.getElementById('customerVendorLinksEmpty');
|
||||
const countEl = document.getElementById('customerVendorsCount');
|
||||
|
||||
if (!container || !empty || !countEl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/vendors`);
|
||||
if (!response.ok) throw new Error('Kunne ikke hente leverandørlinks');
|
||||
const links = await response.json();
|
||||
const rows = Array.isArray(links) ? links : [];
|
||||
|
||||
countEl.textContent = String(rows.length);
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '';
|
||||
empty.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
empty.classList.add('d-none');
|
||||
container.innerHTML = rows.map((row) => `
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(row.vendor_name || `Vendor #${row.vendor_id}`)}</div>
|
||||
<div class="small text-muted">
|
||||
${row.vendor_cvr ? `CVR ${escapeHtml(row.vendor_cvr)} · ` : ''}
|
||||
${row.vendor_email ? escapeHtml(row.vendor_email) : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-light text-dark border">${escapeHtml(row.relationship_type || 'supplier')}</span>
|
||||
<a class="btn btn-sm btn-outline-primary" href="/vendors/${row.vendor_id}">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="unlinkVendorFromCustomer(${row.vendor_id})">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to load customer vendor links:', error);
|
||||
container.innerHTML = '';
|
||||
empty.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
let vendorSearchDebounce = null;
|
||||
async function searchVendorsForCustomer(query) {
|
||||
const resultsEl = document.getElementById('customerVendorSearchResults');
|
||||
if (!resultsEl) return;
|
||||
|
||||
const q = String(query || '').trim();
|
||||
if (!q) {
|
||||
resultsEl.style.display = 'none';
|
||||
resultsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (vendorSearchDebounce) window.clearTimeout(vendorSearchDebounce);
|
||||
vendorSearchDebounce = window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/vendors?search=${encodeURIComponent(q)}&limit=10`);
|
||||
if (!response.ok) throw new Error('Søgning fejlede');
|
||||
const vendors = await response.json();
|
||||
const rows = Array.isArray(vendors) ? vendors : [];
|
||||
|
||||
if (!rows.length) {
|
||||
resultsEl.innerHTML = '<div class="list-group-item text-muted">Ingen leverandører fundet</div>';
|
||||
resultsEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = rows.map((v) => `
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(v.name || '')}</div>
|
||||
<div class="small text-muted">${v.cvr_number ? `CVR ${escapeHtml(v.cvr_number)} · ` : ''}${v.email ? escapeHtml(v.email) : '-'}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" onclick="linkVendorToCustomerFromUI(${v.id})">
|
||||
<i class="bi bi-link-45deg me-1"></i>Link
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
resultsEl.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('Vendor search failed:', error);
|
||||
resultsEl.innerHTML = '<div class="list-group-item text-danger">Søgning fejlede</div>';
|
||||
resultsEl.style.display = 'block';
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
async function linkVendorToCustomerFromUI(vendorId) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}?relationship_type=supplier`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.detail || 'Kunne ikke linke leverandør');
|
||||
}
|
||||
|
||||
const input = document.getElementById('customerVendorSearch');
|
||||
const results = document.getElementById('customerVendorSearchResults');
|
||||
if (input) input.value = '';
|
||||
if (results) {
|
||||
results.innerHTML = '';
|
||||
results.style.display = 'none';
|
||||
}
|
||||
|
||||
await loadCustomerVendorLinks();
|
||||
await loadCustomerTags();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Kunne ikke linke leverandør');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkVendorFromCustomer(vendorId) {
|
||||
if (!confirm('Fjern link mellem kunde og leverandør?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/vendors/${vendorId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.detail || 'Kunne ikke fjerne link');
|
||||
}
|
||||
await loadCustomerVendorLinks();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Kunne ikke fjerne link');
|
||||
}
|
||||
}
|
||||
|
||||
function displayCustomer(customer) {
|
||||
// Update page title
|
||||
document.title = `${customer.name} - BMC Hub`;
|
||||
|
||||
@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
import unicodedata
|
||||
|
||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||
from app.services.email_processor_service import EmailProcessorService
|
||||
@ -20,6 +21,218 @@ router = APIRouter()
|
||||
|
||||
ALLOWED_SAG_EMAIL_RELATION_TYPES = {"mail"}
|
||||
|
||||
IGNORED_SENDER_DOMAINS = {
|
||||
"bmcnetworks.dk",
|
||||
"bmchub.local",
|
||||
"outlook.com",
|
||||
"hotmail.com",
|
||||
"gmail.com",
|
||||
"icloud.com",
|
||||
"yahoo.com",
|
||||
"live.com",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_domain(value: Optional[str]) -> str:
|
||||
domain = str(value or "").strip().lower()
|
||||
if not domain:
|
||||
return ""
|
||||
if domain.startswith("www."):
|
||||
return domain[4:]
|
||||
return domain
|
||||
|
||||
|
||||
def _extract_sender_domain(sender_email: Optional[str]) -> str:
|
||||
sender = str(sender_email or "").strip().lower()
|
||||
if "@" not in sender:
|
||||
return ""
|
||||
return _normalize_domain(sender.split("@", 1)[1])
|
||||
|
||||
|
||||
def _is_ignored_sender_domain(domain: str) -> bool:
|
||||
if not domain:
|
||||
return True
|
||||
return domain in IGNORED_SENDER_DOMAINS or "bmc" in domain
|
||||
|
||||
|
||||
def _upsert_domain_mapping(domain: str, customer_id: int, source: str = "manual") -> None:
|
||||
if not domain or not customer_id:
|
||||
return
|
||||
try:
|
||||
execute_update(
|
||||
"""
|
||||
INSERT INTO email_domain_customer_mappings (domain, customer_id, source)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (domain)
|
||||
DO UPDATE SET
|
||||
customer_id = EXCLUDED.customer_id,
|
||||
source = EXCLUDED.source,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(domain, customer_id, source),
|
||||
)
|
||||
except Exception as e:
|
||||
# Keep linking flow operational even if mapping table is not migrated yet.
|
||||
logger.warning("⚠️ Could not upsert domain mapping for %s: %s", domain, e)
|
||||
|
||||
|
||||
def _resolve_procurement_customer_id() -> Optional[int]:
|
||||
"""Resolve a fallback customer for supplier/procurement case creation."""
|
||||
bmc_row = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM customers
|
||||
WHERE is_active = true
|
||||
AND LOWER(name) LIKE %s
|
||||
ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id
|
||||
LIMIT 1
|
||||
""",
|
||||
("%bmc%", "%bmc networks%")
|
||||
)
|
||||
if bmc_row:
|
||||
return int(bmc_row["id"])
|
||||
|
||||
fallback = execute_query_single(
|
||||
"SELECT id FROM customers WHERE is_active = true ORDER BY id LIMIT 1"
|
||||
)
|
||||
if fallback:
|
||||
return int(fallback["id"])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_case_type(value: Optional[str]) -> str:
|
||||
raw = str(value or "").strip().lower()
|
||||
if not raw:
|
||||
return "support"
|
||||
normalized = unicodedata.normalize("NFKD", raw)
|
||||
ascii_value = normalized.encode("ascii", "ignore").decode("ascii").strip().lower()
|
||||
return ascii_value or raw
|
||||
|
||||
|
||||
def _is_supplier_case_type(case_type: Optional[str]) -> bool:
|
||||
value = _normalize_case_type(case_type)
|
||||
if value in {
|
||||
"indkob",
|
||||
"indkoeb",
|
||||
"supplier",
|
||||
"leverandor",
|
||||
"leverandoer",
|
||||
"vendor",
|
||||
"procurement",
|
||||
"purchase",
|
||||
}:
|
||||
return True
|
||||
return "indk" in value or "leverand" in value or "supplier" in value
|
||||
|
||||
|
||||
def _extract_domain_from_email(email: Optional[str]) -> str:
|
||||
sender = str(email or "").strip().lower()
|
||||
if "@" not in sender:
|
||||
return ""
|
||||
return _normalize_domain(sender.split("@", 1)[1])
|
||||
|
||||
|
||||
def _find_customer_for_vendor(vendor: Dict) -> Optional[int]:
|
||||
cvr = str(vendor.get("cvr_number") or "").strip()
|
||||
if cvr:
|
||||
row = execute_query_single(
|
||||
"SELECT id FROM customers WHERE cvr_number = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
|
||||
(cvr,),
|
||||
)
|
||||
if row:
|
||||
return int(row["id"])
|
||||
|
||||
email = str(vendor.get("email") or "").strip().lower()
|
||||
if email:
|
||||
row = execute_query_single(
|
||||
"SELECT id FROM customers WHERE LOWER(TRIM(email)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
|
||||
(email,),
|
||||
)
|
||||
if row:
|
||||
return int(row["id"])
|
||||
|
||||
domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email")))
|
||||
if domain:
|
||||
row = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM customers
|
||||
WHERE COALESCE(is_active, true) = true
|
||||
AND (
|
||||
LOWER(TRIM(COALESCE(email_domain, ''))) = %s
|
||||
OR LOWER(TRIM(COALESCE(email_domain, ''))) = %s
|
||||
)
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""",
|
||||
(domain, f"www.{domain}"),
|
||||
)
|
||||
if row:
|
||||
return int(row["id"])
|
||||
|
||||
name = str(vendor.get("name") or "").strip().lower()
|
||||
if name:
|
||||
row = execute_query_single(
|
||||
"SELECT id FROM customers WHERE LOWER(TRIM(name)) = %s AND COALESCE(is_active, true) = true ORDER BY id LIMIT 1",
|
||||
(name,),
|
||||
)
|
||||
if row:
|
||||
return int(row["id"])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_customer_from_vendor(vendor_id: Optional[int]) -> Optional[int]:
|
||||
if not vendor_id:
|
||||
return None
|
||||
|
||||
vendor = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, email, phone, address, cvr_number, domain, city, postal_code, country, website
|
||||
FROM vendors
|
||||
WHERE id = %s AND is_active = true
|
||||
""",
|
||||
(vendor_id,),
|
||||
)
|
||||
if not vendor:
|
||||
return None
|
||||
|
||||
existing_customer_id = _find_customer_for_vendor(vendor)
|
||||
if existing_customer_id:
|
||||
return existing_customer_id
|
||||
|
||||
name = str(vendor.get("name") or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
domain = _normalize_domain(vendor.get("domain") or _extract_domain_from_email(vendor.get("email"))) or None
|
||||
|
||||
try:
|
||||
created_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO customers (name, email, phone, address, cvr_number, email_domain, city, postal_code, country, website, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
name,
|
||||
vendor.get("email"),
|
||||
vendor.get("phone"),
|
||||
vendor.get("address"),
|
||||
vendor.get("cvr_number"),
|
||||
domain,
|
||||
vendor.get("city"),
|
||||
vendor.get("postal_code"),
|
||||
vendor.get("country") or "DK",
|
||||
vendor.get("website"),
|
||||
),
|
||||
)
|
||||
return int(created_id)
|
||||
except Exception:
|
||||
# Handle potential race/unique conflict by resolving again.
|
||||
return _find_customer_for_vendor(vendor)
|
||||
|
||||
|
||||
# Pydantic Models
|
||||
class EmailListItem(BaseModel):
|
||||
@ -225,6 +438,12 @@ class RewriteEmailTextResponse(BaseModel):
|
||||
context: Optional[str] = None
|
||||
|
||||
|
||||
class DomainMappingUpsertRequest(BaseModel):
|
||||
domain: str
|
||||
customer_id: int
|
||||
source: Optional[str] = "manual"
|
||||
|
||||
|
||||
@router.get("/emails/sag-options")
|
||||
async def get_sag_assignment_options():
|
||||
"""Return users and groups for SAG assignment controls in email UI."""
|
||||
@ -294,6 +513,222 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/emails/{email_id}/domain-customer-suggestion")
|
||||
async def get_domain_customer_suggestion(email_id: int):
|
||||
"""Suggest customer based on sender domain for mails without known contact/customer."""
|
||||
try:
|
||||
email_row = execute_query_single(
|
||||
"SELECT id, sender_email, customer_id FROM email_messages WHERE id = %s AND deleted_at IS NULL",
|
||||
(email_id,),
|
||||
)
|
||||
if not email_row:
|
||||
raise HTTPException(status_code=404, detail="Email not found")
|
||||
|
||||
if email_row.get("customer_id"):
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": None,
|
||||
"has_customer": True,
|
||||
"ignored": False,
|
||||
"suggestion": None,
|
||||
}
|
||||
|
||||
sender_email = str(email_row.get("sender_email") or "").strip().lower()
|
||||
if sender_email:
|
||||
contact_match = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
c.email_domain,
|
||||
c.cvr_number,
|
||||
ct.id AS contact_id,
|
||||
ct.first_name,
|
||||
ct.last_name
|
||||
FROM contacts ct
|
||||
JOIN contact_companies cc ON cc.contact_id = ct.id
|
||||
JOIN customers c ON c.id = cc.customer_id
|
||||
WHERE c.is_active = true
|
||||
AND LOWER(TRIM(COALESCE(ct.email, ''))) = %s
|
||||
ORDER BY cc.is_primary DESC, c.id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(sender_email,),
|
||||
)
|
||||
if contact_match:
|
||||
contact_name = " ".join(
|
||||
part for part in [contact_match.get("first_name"), contact_match.get("last_name")] if part
|
||||
).strip()
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": _extract_sender_domain(sender_email),
|
||||
"has_customer": False,
|
||||
"ignored": False,
|
||||
"suggestion": {
|
||||
"customer_id": contact_match["id"],
|
||||
"customer_name": contact_match["name"],
|
||||
"email_domain": contact_match.get("email_domain"),
|
||||
"cvr_number": contact_match.get("cvr_number"),
|
||||
"confidence": "high",
|
||||
"score": 110,
|
||||
"source": f"contact_email:{contact_name or sender_email}",
|
||||
},
|
||||
}
|
||||
|
||||
sender_domain = _extract_sender_domain(email_row.get("sender_email"))
|
||||
if not sender_domain:
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": None,
|
||||
"has_customer": False,
|
||||
"ignored": True,
|
||||
"reason": "no_domain",
|
||||
"suggestion": None,
|
||||
}
|
||||
|
||||
if _is_ignored_sender_domain(sender_domain):
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": sender_domain,
|
||||
"has_customer": False,
|
||||
"ignored": True,
|
||||
"reason": "ignored_domain",
|
||||
"suggestion": None,
|
||||
}
|
||||
|
||||
mapped = execute_query_single(
|
||||
"""
|
||||
SELECT c.id, c.name, c.email_domain, c.cvr_number, m.source
|
||||
FROM email_domain_customer_mappings m
|
||||
JOIN customers c ON c.id = m.customer_id
|
||||
WHERE m.domain = %s
|
||||
AND c.is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(sender_domain,),
|
||||
)
|
||||
|
||||
if mapped:
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": sender_domain,
|
||||
"has_customer": False,
|
||||
"ignored": False,
|
||||
"suggestion": {
|
||||
"customer_id": mapped["id"],
|
||||
"customer_name": mapped["name"],
|
||||
"email_domain": mapped.get("email_domain"),
|
||||
"cvr_number": mapped.get("cvr_number"),
|
||||
"confidence": "high",
|
||||
"score": 100,
|
||||
"source": f"mapping:{mapped.get('source') or 'manual'}",
|
||||
},
|
||||
}
|
||||
|
||||
exact = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, email_domain, cvr_number
|
||||
FROM customers
|
||||
WHERE is_active = true
|
||||
AND (
|
||||
LOWER(TRIM(email_domain)) = %s
|
||||
OR LOWER(TRIM(email_domain)) = %s
|
||||
)
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(sender_domain, f"www.{sender_domain}"),
|
||||
)
|
||||
|
||||
if exact:
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": sender_domain,
|
||||
"has_customer": False,
|
||||
"ignored": False,
|
||||
"suggestion": {
|
||||
"customer_id": exact["id"],
|
||||
"customer_name": exact["name"],
|
||||
"email_domain": exact.get("email_domain"),
|
||||
"cvr_number": exact.get("cvr_number"),
|
||||
"confidence": "high",
|
||||
"score": 95,
|
||||
"source": "exact_domain",
|
||||
},
|
||||
}
|
||||
|
||||
partial = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, email_domain, cvr_number
|
||||
FROM customers
|
||||
WHERE is_active = true
|
||||
AND COALESCE(email_domain, '') ILIKE %s
|
||||
ORDER BY name ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"%{sender_domain}%",),
|
||||
)
|
||||
|
||||
if partial:
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": sender_domain,
|
||||
"has_customer": False,
|
||||
"ignored": False,
|
||||
"suggestion": {
|
||||
"customer_id": partial["id"],
|
||||
"customer_name": partial["name"],
|
||||
"email_domain": partial.get("email_domain"),
|
||||
"cvr_number": partial.get("cvr_number"),
|
||||
"confidence": "medium",
|
||||
"score": 70,
|
||||
"source": "partial_domain",
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"email_id": email_id,
|
||||
"domain": sender_domain,
|
||||
"has_customer": False,
|
||||
"ignored": False,
|
||||
"suggestion": None,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error getting domain customer suggestion: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/domain-customer-mapping")
|
||||
async def upsert_domain_customer_mapping(payload: DomainMappingUpsertRequest):
|
||||
"""Persist trusted mapping from sender domain to customer."""
|
||||
try:
|
||||
domain = _normalize_domain(payload.domain)
|
||||
if not domain:
|
||||
raise HTTPException(status_code=400, detail="domain is required")
|
||||
|
||||
customer = execute_query_single(
|
||||
"SELECT id FROM customers WHERE id = %s AND is_active = true",
|
||||
(payload.customer_id,),
|
||||
)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
_upsert_domain_mapping(domain, int(payload.customer_id), payload.source or "manual")
|
||||
return {
|
||||
"success": True,
|
||||
"domain": domain,
|
||||
"customer_id": int(payload.customer_id),
|
||||
"source": payload.source or "manual",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error upserting domain mapping: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/rewrite-text", response_model=RewriteEmailTextResponse)
|
||||
async def rewrite_email_text(request: RewriteEmailTextRequest):
|
||||
"""Rewrite email/case text via Ollama using the text_rewrite prompt."""
|
||||
@ -608,6 +1043,16 @@ async def link_email(email_id: int, payload: Dict):
|
||||
query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
|
||||
execute_update(query, tuple(params))
|
||||
|
||||
customer_id = payload.get('customer_id')
|
||||
if customer_id:
|
||||
email_row = execute_query_single(
|
||||
"SELECT sender_email FROM email_messages WHERE id = %s",
|
||||
(email_id,),
|
||||
)
|
||||
sender_domain = _extract_sender_domain((email_row or {}).get("sender_email"))
|
||||
if sender_domain and not _is_ignored_sender_domain(sender_domain):
|
||||
_upsert_domain_mapping(sender_domain, int(customer_id), "auto_link")
|
||||
|
||||
logger.info(f"✅ Linked email {email_id}: {payload}")
|
||||
return {"success": True, "message": "Email linket"}
|
||||
|
||||
@ -630,13 +1075,59 @@ async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailReques
|
||||
raise HTTPException(status_code=404, detail="Email not found")
|
||||
|
||||
email_data = email_row[0]
|
||||
|
||||
# Idempotent safeguard: repeated clicks should return existing linked case.
|
||||
existing_sag_id = email_data.get('linked_case_id')
|
||||
if existing_sag_id:
|
||||
existing_sag = execute_query_single(
|
||||
"""
|
||||
SELECT id, titel, customer_id, status, template_key, priority, start_date, deadline, created_at
|
||||
FROM sag_sager
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(existing_sag_id,),
|
||||
)
|
||||
if existing_sag:
|
||||
execute_update(
|
||||
"""
|
||||
INSERT INTO sag_emails (sag_id, email_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (sag_id, email_id) DO NOTHING
|
||||
""",
|
||||
(existing_sag_id, email_id),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"email_id": email_id,
|
||||
"sag": existing_sag,
|
||||
"idempotent": True,
|
||||
"message": "E-mail er allerede knyttet til eksisterende SAG"
|
||||
}
|
||||
|
||||
requested_case_type = _normalize_case_type(payload.case_type)
|
||||
|
||||
customer_id = payload.customer_id or email_data.get('customer_id')
|
||||
if not customer_id and _is_supplier_case_type(requested_case_type):
|
||||
customer_id = _ensure_customer_from_vendor(email_data.get('supplier_id'))
|
||||
if not customer_id and _is_supplier_case_type(requested_case_type):
|
||||
customer_id = _resolve_procurement_customer_id()
|
||||
|
||||
if not customer_id:
|
||||
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
|
||||
|
||||
if not email_data.get('customer_id') and customer_id:
|
||||
execute_update(
|
||||
"UPDATE email_messages SET customer_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
|
||||
(customer_id, email_id),
|
||||
)
|
||||
|
||||
sender_domain = _extract_sender_domain(email_data.get("sender_email"))
|
||||
if sender_domain and not _is_ignored_sender_domain(sender_domain):
|
||||
_upsert_domain_mapping(sender_domain, int(customer_id), "supplier_auto")
|
||||
|
||||
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
|
||||
beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
|
||||
template_key = (payload.case_type or 'support').strip().lower()[:50]
|
||||
template_key = requested_case_type[:50]
|
||||
priority = (payload.priority or 'normal').strip().lower()
|
||||
|
||||
if priority not in {'low', 'normal', 'high', 'urgent'}:
|
||||
|
||||
@ -283,6 +283,62 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.quick-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.email-secondary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.email-secondary-actions .left,
|
||||
.email-secondary-actions .right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.domain-suggestion-card {
|
||||
border: 1px dashed rgba(15, 76, 117, 0.35);
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: rgba(15, 76, 117, 0.04);
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.domain-suggestion-card .title {
|
||||
font-size: 0.83rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.domain-suggestion-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.triage-priority-badge {
|
||||
border: 1px solid rgba(15, 76, 117, 0.22);
|
||||
background: rgba(15, 76, 117, 0.06);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachment-chip {
|
||||
max-width: 240px;
|
||||
overflow: hidden;
|
||||
@ -301,6 +357,17 @@
|
||||
border-color: #0a3a5c;
|
||||
}
|
||||
|
||||
.email-actions .btn-danger-soft {
|
||||
background: rgba(220, 53, 69, 0.08);
|
||||
color: #a72a37;
|
||||
border-color: rgba(220, 53, 69, 0.28);
|
||||
}
|
||||
|
||||
.email-actions .btn-danger-soft:hover {
|
||||
background: rgba(220, 53, 69, 0.14);
|
||||
color: #8e1f2d;
|
||||
}
|
||||
|
||||
.email-body {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
@ -1743,6 +1810,7 @@ function renderEmailList(emailList) {
|
||||
<div class="email-preview">${escapeHtml(preview)}</div>
|
||||
<div class="email-meta">
|
||||
${!email.is_read ? '<span class="unread-indicator"></span>' : ''}
|
||||
${getPriorityBadge(email)}
|
||||
<span class="classification-badge classification-${classification}">
|
||||
${formatClassification(classification)}
|
||||
</span>
|
||||
@ -1793,6 +1861,7 @@ async function loadEmailDetail(emailId) {
|
||||
|
||||
renderEmailDetail(email);
|
||||
renderEmailAnalysis(email);
|
||||
await maybeSuggestCustomerByDomain(email);
|
||||
|
||||
if (!email.is_read) {
|
||||
await markAsRead(emailId);
|
||||
@ -1805,6 +1874,176 @@ async function loadEmailDetail(emailId) {
|
||||
}
|
||||
}
|
||||
|
||||
function withActionLoading(button, loadingText = 'Arbejder...') {
|
||||
const btn = button || null;
|
||||
if (!btn) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>${loadingText}`;
|
||||
|
||||
return () => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDomain(value) {
|
||||
const domain = String(value || '').trim().toLowerCase();
|
||||
if (!domain) return '';
|
||||
return domain.startsWith('www.') ? domain.slice(4) : domain;
|
||||
}
|
||||
|
||||
function splitSenderName(name, email) {
|
||||
const raw = String(name || '').trim();
|
||||
if (!raw) {
|
||||
const fallback = String(email || '').split('@')[0] || 'Kontakt';
|
||||
return { firstName: fallback.slice(0, 40), lastName: 'Fra email' };
|
||||
}
|
||||
|
||||
const cleaned = raw.replace(/[<>]/g, '').trim();
|
||||
const parts = cleaned.split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 1) {
|
||||
return { firstName: parts[0].slice(0, 40), lastName: '-' };
|
||||
}
|
||||
return {
|
||||
firstName: parts[0].slice(0, 40),
|
||||
lastName: parts.slice(1).join(' ').slice(0, 60)
|
||||
};
|
||||
}
|
||||
|
||||
function setCaseCustomerSelection(customerId, customerName) {
|
||||
const hidden = document.getElementById('caseCustomerId');
|
||||
const input = document.getElementById('caseCustomerSearch');
|
||||
if (hidden) hidden.value = customerId ? String(customerId) : '';
|
||||
if (input && customerName) input.value = customerName;
|
||||
}
|
||||
|
||||
function renderDomainCustomerHint(html, visible = true) {
|
||||
const hint = document.getElementById('domainCustomerHint');
|
||||
if (!hint) return;
|
||||
hint.innerHTML = html || '';
|
||||
hint.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function linkEmailToCustomerQuick(customerId, customerName) {
|
||||
if (!currentEmailId || !customerId) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/emails/${currentEmailId}/link`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ customer_id: customerId })
|
||||
});
|
||||
if (!resp.ok) throw new Error('Kunne ikke linke kunden');
|
||||
|
||||
try {
|
||||
const senderEmail = String(document.querySelector('.sender-email')?.textContent || '').trim().toLowerCase();
|
||||
const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : '');
|
||||
if (domain) {
|
||||
await fetch('/api/v1/emails/domain-customer-mapping', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
customer_id: customerId,
|
||||
source: 'manual'
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (mappingError) {
|
||||
console.warn('Could not save domain mapping', mappingError);
|
||||
}
|
||||
|
||||
setCaseCustomerSelection(customerId, customerName);
|
||||
renderDomainCustomerHint(
|
||||
`<div class="title"><i class="bi bi-check-circle text-success me-1"></i>Firma linket</div>
|
||||
<div class="domain-suggestion-meta">Email er nu linket til ${escapeHtml(customerName || 'kunden')}.</div>`,
|
||||
true
|
||||
);
|
||||
showSuccess(`Linket til ${customerName}`);
|
||||
await loadEmailDetail(currentEmailId);
|
||||
} catch (error) {
|
||||
showError(error.message || 'Kunde-link fejlede');
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeSuggestCustomerByDomain(email) {
|
||||
if (!email || email.customer_id) {
|
||||
renderDomainCustomerHint('', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const senderEmail = String(email.sender_email || '').trim().toLowerCase();
|
||||
const domain = normalizeDomain(senderEmail.includes('@') ? senderEmail.split('@')[1] : '');
|
||||
if (!domain) {
|
||||
renderDomainCustomerHint('', false);
|
||||
return;
|
||||
}
|
||||
|
||||
renderDomainCustomerHint(
|
||||
`<div class="title"><i class="bi bi-search me-1"></i>Søger firma på domæne…</div>
|
||||
<div class="domain-suggestion-meta">Domæne: ${escapeHtml(domain)}</div>`,
|
||||
true
|
||||
);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/emails/${email.id}/domain-customer-suggestion`);
|
||||
if (!resp.ok) throw new Error('Domæneopslag fejlede');
|
||||
const payload = await resp.json();
|
||||
const best = payload?.suggestion
|
||||
? {
|
||||
id: payload.suggestion.customer_id,
|
||||
name: payload.suggestion.customer_name,
|
||||
email_domain: payload.suggestion.email_domain,
|
||||
cvr_number: payload.suggestion.cvr_number,
|
||||
source: payload.suggestion.source,
|
||||
score: payload.suggestion.score,
|
||||
confidence: payload.suggestion.confidence
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!best) {
|
||||
renderDomainCustomerHint(
|
||||
`<div class="title"><i class="bi bi-info-circle me-1"></i>Ingen firmamatch</div>
|
||||
<div class="domain-suggestion-meta">Ingen kunde fundet for ${escapeHtml(domain)}. Brug “Opret firma/kontakt”.</div>`,
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const confidence = best.confidence === 'high' ? 'Høj' : 'Mellem';
|
||||
renderDomainCustomerHint(
|
||||
`<div class="title"><i class="bi bi-building me-1"></i>Muligt firma fundet (${confidence} confidence)</div>
|
||||
<div class="domain-suggestion-meta">
|
||||
<strong>${escapeHtml(best.name)}</strong>
|
||||
${best.email_domain ? `• ${escapeHtml(best.email_domain)}` : ''}
|
||||
${best.cvr_number ? `• CVR ${escapeHtml(best.cvr_number)}` : ''}
|
||||
${best.source ? `• Kilde: ${escapeHtml(best.source)}` : ''}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" onclick="linkEmailToCustomerQuick(${best.id}, '${escapeHtml(best.name).replace(/'/g, "\\'")}')">
|
||||
<i class="bi bi-link-45deg me-1"></i>Link firma
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '').replace(/'/g, "\\'")}', '${escapeHtml(email.sender_email || '').replace(/'/g, "\\'")}')">
|
||||
<i class="bi bi-plus-circle me-1"></i>Opret ny
|
||||
</button>
|
||||
</div>`,
|
||||
true
|
||||
);
|
||||
|
||||
setCaseCustomerSelection(best.id, best.name);
|
||||
} catch (error) {
|
||||
renderDomainCustomerHint(
|
||||
`<div class="title"><i class="bi bi-exclamation-circle me-1"></i>Domæneopslag fejlede</div>
|
||||
<div class="domain-suggestion-meta">${escapeHtml(error.message || 'Ukendt fejl')}</div>`,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getClassificationActions(email) {
|
||||
const actions = [];
|
||||
|
||||
@ -1918,31 +2157,51 @@ function renderEmailDetail(email) {
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="email-actions d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-light border" onclick="archiveEmail()" title="Arkivér (e)">
|
||||
<i class="bi bi-archive"></i>
|
||||
<div class="email-actions">
|
||||
<div class="quick-actions-grid">
|
||||
<button class="btn btn-sm btn-primary" onclick="createSupplierInvoice(${email.id}, this)" title="Leverandørfaktura">
|
||||
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
|
||||
</button>
|
||||
<button class="btn btn-sm btn-light border" onclick="markAsSpam()" title="Marker som spam">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'support')" title="Kundesag">
|
||||
<i class="bi bi-folder-plus me-1"></i>Kundesag
|
||||
</button>
|
||||
<button class="btn btn-sm btn-light border" onclick="reprocessEmail()" title="Genbehandl (r)">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="createCaseQuick(${email.id}, 'bogholderi', 'Fakturasporgsmal')" title="Fakturaspørgsmål">
|
||||
<i class="bi bi-question-circle me-1"></i>Fakturaspørgsmål
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail()" title="Kør Workflows">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Workflows
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="markAsSpam(this)" title="Marker som spam">
|
||||
<i class="bi bi-slash-circle me-1"></i>Spam
|
||||
</button>
|
||||
${email.linked_case_id ? `
|
||||
<a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}" title="Åbn SAG-${email.linked_case_id}">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Sag
|
||||
</a>
|
||||
` : ''}
|
||||
<button class="btn btn-sm btn-light border text-danger" onclick="deleteEmail()" title="Slet">
|
||||
<i class="bi bi-trash"></i>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '').replace(/'/g, "\\'")}', '${escapeHtml(email.sender_email || '').replace(/'/g, "\\'")}')" title="Opret firma/kontakt">
|
||||
<i class="bi bi-building-add me-1"></i>Opret firma/kontakt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="email-secondary-actions">
|
||||
<div class="left">
|
||||
<button class="btn btn-sm btn-light border" onclick="archiveEmail(this)" title="Arkivér (e)">
|
||||
<i class="bi bi-archive"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-light border" onclick="reprocessEmail(this)" title="Genbehandl (r)">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary border" onclick="executeWorkflowsForEmail(this)" title="Kør workflows">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Workflows
|
||||
</button>
|
||||
${email.linked_case_id ? `
|
||||
<a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}" title="Åbn SAG-${email.linked_case_id}">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>SAG-${email.linked_case_id}
|
||||
</a>
|
||||
` : '<span class="triage-priority-badge">Ingen sag linket</span>'}
|
||||
</div>
|
||||
<div class="right">
|
||||
<button class="btn btn-sm btn-danger-soft" onclick="deleteEmail(this)" title="Slet">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${email.attachments && email.attachments.length > 0 ? `
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mt-2 pt-2 border-top">
|
||||
<span class="text-muted"><i class="bi bi-paperclip me-1"></i>${email.attachments.length} vedhæftning${email.attachments.length > 1 ? 'er' : ''}</span>
|
||||
${email.attachments.map(att => {
|
||||
const canPreview = canPreviewFile(att.content_type);
|
||||
@ -2033,7 +2292,7 @@ function renderEmailAnalysis(email) {
|
||||
<div class="analysis-card">
|
||||
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
|
||||
<div class="quick-action-row mb-3">
|
||||
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion()">
|
||||
<button class="btn btn-sm btn-primary" onclick="confirmSuggestion(this)">
|
||||
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
|
||||
@ -2068,6 +2327,7 @@ function renderEmailAnalysis(email) {
|
||||
<div id="caseCustomerResults" class="customer-search-results" style="display:none;"></div>
|
||||
</div>
|
||||
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
|
||||
<div id="domainCustomerHint" class="domain-suggestion-card" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="suggestion-field">
|
||||
@ -2113,7 +2373,7 @@ function renderEmailAnalysis(email) {
|
||||
</div>
|
||||
|
||||
<div class="quick-action-row quick-action-row-case mt-3">
|
||||
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
|
||||
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm(this)">
|
||||
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
|
||||
@ -2314,8 +2574,8 @@ async function searchSagerForCurrentEmail(query) {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmSuggestion() {
|
||||
createCaseFromCurrentForm();
|
||||
function confirmSuggestion(button = null) {
|
||||
createCaseFromCurrentForm(button);
|
||||
}
|
||||
|
||||
function getCaseFormPayload() {
|
||||
@ -2336,8 +2596,11 @@ function getCaseFormPayload() {
|
||||
};
|
||||
}
|
||||
|
||||
async function createCaseFromCurrentForm() {
|
||||
let caseCreateInFlight = false;
|
||||
|
||||
async function createCaseFromCurrentForm(button = null) {
|
||||
if (!currentEmailId) return;
|
||||
if (caseCreateInFlight) return;
|
||||
|
||||
const payload = getCaseFormPayload();
|
||||
if (!payload.customer_id) {
|
||||
@ -2345,6 +2608,9 @@ async function createCaseFromCurrentForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
caseCreateInFlight = true;
|
||||
const done = withActionLoading(button, 'Opretter...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
|
||||
method: 'POST',
|
||||
@ -2358,11 +2624,18 @@ async function createCaseFromCurrentForm() {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
|
||||
if (result.idempotent) {
|
||||
showInfo(`E-mail var allerede knyttet til SAG-${result.sag.id}`);
|
||||
} else {
|
||||
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
|
||||
}
|
||||
loadEmails();
|
||||
await loadEmailDetail(currentEmailId);
|
||||
} catch (error) {
|
||||
showError(error.message || 'Kunne ikke oprette sag');
|
||||
} finally {
|
||||
caseCreateInFlight = false;
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2582,8 +2855,9 @@ function formatEventType(eventType) {
|
||||
return labels[eventType] || eventType;
|
||||
}
|
||||
|
||||
async function archiveEmail() {
|
||||
async function archiveEmail(button = null) {
|
||||
if (!currentEmailId) return;
|
||||
const done = withActionLoading(button, 'Arkiverer...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/emails/${currentEmailId}?status=archived`, {
|
||||
@ -2599,14 +2873,18 @@ async function archiveEmail() {
|
||||
loadEmails();
|
||||
} catch (error) {
|
||||
showError('Kunne ikke arkivere email');
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsSpam() {
|
||||
async function markAsSpam(button = null) {
|
||||
if (!currentEmailId) return;
|
||||
|
||||
if (!confirm('Marker denne email som spam?')) return;
|
||||
|
||||
const done = withActionLoading(button, 'Spam...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/emails/${currentEmailId}/classify`, {
|
||||
method: 'PUT',
|
||||
@ -2623,17 +2901,16 @@ async function markAsSpam() {
|
||||
loadEmails();
|
||||
} catch (error) {
|
||||
showError('Kunne ikke markere som spam');
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
async function reprocessEmail() {
|
||||
async function reprocessEmail(button = null) {
|
||||
if (!currentEmailId) return;
|
||||
const done = withActionLoading(button, 'Genbehandler...');
|
||||
|
||||
try {
|
||||
const btn = event.target.closest('button');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
|
||||
const response = await fetch(`/api/v1/emails/${currentEmailId}/reprocess`, {
|
||||
method: 'POST'
|
||||
});
|
||||
@ -2647,18 +2924,17 @@ async function reprocessEmail() {
|
||||
} catch (error) {
|
||||
showError('Kunne ikke genbehandle email');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||
}
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEmail() {
|
||||
async function deleteEmail(button = null) {
|
||||
if (!currentEmailId) return;
|
||||
|
||||
if (!confirm('Slet denne email permanent?')) return;
|
||||
|
||||
const done = withActionLoading(button, 'Sletter...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/emails/${currentEmailId}`, {
|
||||
method: 'DELETE'
|
||||
@ -2672,11 +2948,86 @@ async function deleteEmail() {
|
||||
loadEmails();
|
||||
} catch (error) {
|
||||
showError('Kunne ikke slette email');
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
async function createCaseQuick(emailId, primaryType = 'support', secondaryLabel = '') {
|
||||
if (currentEmailId !== emailId) {
|
||||
await selectEmail(emailId);
|
||||
}
|
||||
|
||||
setPrimaryType(primaryType || 'support');
|
||||
const secondary = document.getElementById('caseSecondaryLabel');
|
||||
if (secondary && secondaryLabel) {
|
||||
secondary.value = secondaryLabel;
|
||||
}
|
||||
|
||||
const currentEmail = emails.find((item) => Number(item.id) === Number(emailId));
|
||||
if (currentEmail) {
|
||||
await maybeSuggestCustomerByDomain(currentEmail);
|
||||
}
|
||||
|
||||
const hasCustomer = !!document.getElementById('caseCustomerId')?.value;
|
||||
if (!hasCustomer) {
|
||||
showInfo('Ingen kunde valgt endnu. Vælg forslag fra domæne eller opret firma/kontakt først.');
|
||||
quickCreateCustomer(emailId, currentEmail?.sender_name || '', currentEmail?.sender_email || '');
|
||||
return;
|
||||
}
|
||||
|
||||
await createCaseFromCurrentForm();
|
||||
}
|
||||
|
||||
// Classification Action Handlers
|
||||
async function createSupplierInvoice(emailId) {
|
||||
const supplierInvoiceInFlightByEmail = new Set();
|
||||
|
||||
async function ensureSupplierCaseForEmail(email) {
|
||||
if (!email || !email.id) return null;
|
||||
if (email.linked_case_id) return Number(email.linked_case_id);
|
||||
|
||||
const subject = String(email.subject || '').trim();
|
||||
const sender = String(email.sender_email || '').trim();
|
||||
const payload = {
|
||||
case_type: 'indkoeb',
|
||||
relation_type: 'mail',
|
||||
priority: 'normal',
|
||||
titel: subject ? `Leverandørmail: ${subject}` : `Leverandørmail fra ${sender || 'ukendt afsender'}`,
|
||||
beskrivelse: [
|
||||
'Auto-oprettet fra leverandør email-behandling',
|
||||
`Afsender: ${sender || '-'}`,
|
||||
`Subject: ${subject || '-'}`
|
||||
].join('\n')
|
||||
};
|
||||
|
||||
if (email.customer_id) {
|
||||
payload.customer_id = Number(email.customer_id);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/emails/${email.id}/create-sag`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorPayload = await response.json().catch(() => ({}));
|
||||
const detail = errorPayload?.detail || 'Kunne ikke oprette relateret sag';
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result?.sag?.id ? Number(result.sag.id) : null;
|
||||
}
|
||||
|
||||
async function createSupplierInvoice(emailId, button = null) {
|
||||
if (supplierInvoiceInFlightByEmail.has(Number(emailId))) {
|
||||
showInfo('Leverandørfaktura behandles allerede for denne email...');
|
||||
return;
|
||||
}
|
||||
|
||||
supplierInvoiceInFlightByEmail.add(Number(emailId));
|
||||
const done = withActionLoading(button, 'Behandler...');
|
||||
try {
|
||||
console.log('📧 Behandler email...');
|
||||
|
||||
@ -2753,23 +3104,41 @@ async function createSupplierInvoice(emailId) {
|
||||
|
||||
// Show result
|
||||
if (successCount > 0) {
|
||||
showSuccess(`✅ ${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}`);
|
||||
let linkedSagId = null;
|
||||
|
||||
// Mark email as processed and move to Processed folder
|
||||
try {
|
||||
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (markResponse.ok) {
|
||||
console.log('✅ Email marked as processed and moved to Processed folder');
|
||||
// Reload email list to reflect changes
|
||||
loadEmails();
|
||||
} else {
|
||||
console.warn('⚠️ Could not mark email as processed');
|
||||
linkedSagId = await ensureSupplierCaseForEmail(email);
|
||||
if (linkedSagId) {
|
||||
email.linked_case_id = linkedSagId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error marking email as processed:', e);
|
||||
} catch (caseError) {
|
||||
console.error('Error creating supplier-related case:', caseError);
|
||||
showError(`Faktura uploadet, men sag-oprettelse fejlede: ${caseError.message}`);
|
||||
}
|
||||
|
||||
if (!linkedSagId) {
|
||||
// Fallback: keep prior behavior if case link was not created.
|
||||
try {
|
||||
const markResponse = await fetch(`/api/v1/emails/${emailId}/mark-processed`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!markResponse.ok) {
|
||||
console.warn('⚠️ Could not mark email as processed');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error marking email as processed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
`✅ ${successCount} faktura${successCount > 1 ? 'er' : ''} uploadet${errorCount > 0 ? ` (${errorCount} fejl)` : ''}` +
|
||||
(linkedSagId ? ` · SAG-${linkedSagId} oprettet/linket` : '')
|
||||
);
|
||||
|
||||
loadEmails();
|
||||
if (currentEmailId === emailId) {
|
||||
await loadEmailDetail(emailId);
|
||||
}
|
||||
|
||||
// Ask if user wants to go to supplier invoices page
|
||||
@ -2783,6 +3152,9 @@ async function createSupplierInvoice(emailId) {
|
||||
} catch (error) {
|
||||
console.error('Error creating supplier invoice:', error);
|
||||
showError('Kunne ikke behandle email: ' + error.message);
|
||||
} finally {
|
||||
supplierInvoiceInFlightByEmail.delete(Number(emailId));
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2818,11 +3190,16 @@ async function linkToCustomer(emailId) {
|
||||
// ─── Quick Create Customer ────────────────────────────────────────────────
|
||||
function quickCreateCustomer(emailId, senderName, senderEmail) {
|
||||
const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : '';
|
||||
const nameParts = splitSenderName(senderName, senderEmail);
|
||||
document.getElementById('qcEmailId').value = emailId;
|
||||
document.getElementById('qcCustomerName').value = senderName || '';
|
||||
document.getElementById('qcCustomerEmail').value = senderEmail || '';
|
||||
document.getElementById('qcCustomerDomain').value = senderDomain;
|
||||
document.getElementById('qcCustomerPhone').value = '';
|
||||
document.getElementById('qcCreateContact').checked = true;
|
||||
document.getElementById('qcContactFirstName').value = nameParts.firstName || '';
|
||||
document.getElementById('qcContactLastName').value = nameParts.lastName || '';
|
||||
document.getElementById('qcContactEmail').value = senderEmail || '';
|
||||
document.getElementById('qcCustomerStatus').textContent = '';
|
||||
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
|
||||
modal.show();
|
||||
@ -2834,25 +3211,64 @@ async function submitQuickCustomer() {
|
||||
const email = document.getElementById('qcCustomerEmail').value.trim();
|
||||
const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase();
|
||||
const phone = document.getElementById('qcCustomerPhone').value.trim();
|
||||
const createContact = !!document.getElementById('qcCreateContact').checked;
|
||||
const contactFirstName = document.getElementById('qcContactFirstName').value.trim();
|
||||
const contactLastName = document.getElementById('qcContactLastName').value.trim();
|
||||
const contactEmail = document.getElementById('qcContactEmail').value.trim();
|
||||
const statusEl = document.getElementById('qcCustomerStatus');
|
||||
|
||||
if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
|
||||
statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
|
||||
|
||||
try {
|
||||
// Create customer
|
||||
const custResp = await fetch('/api/v1/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email: email || null,
|
||||
email_domain: domain || null,
|
||||
phone: phone || null
|
||||
})
|
||||
});
|
||||
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
||||
const customer = await custResp.json();
|
||||
let customer = null;
|
||||
|
||||
// Idempotent fast path: try to reuse existing customer by domain/email before create.
|
||||
if (domain || email) {
|
||||
const probe = domain || email;
|
||||
const searchResp = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(probe)}&limit=20`);
|
||||
if (searchResp.ok) {
|
||||
const matches = await searchResp.json();
|
||||
const exactByDomain = (matches || []).find((row) => normalizeDomain(row.email_domain) === domain);
|
||||
const exactByEmail = !exactByDomain
|
||||
? (matches || []).find((row) => String(row.email || '').trim().toLowerCase() === email.toLowerCase())
|
||||
: null;
|
||||
customer = exactByDomain || exactByEmail || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customer) {
|
||||
const custResp = await fetch('/api/v1/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email: email || null,
|
||||
email_domain: domain || null,
|
||||
phone: phone || null
|
||||
})
|
||||
});
|
||||
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
||||
customer = await custResp.json();
|
||||
}
|
||||
|
||||
if (createContact && contactFirstName && contactLastName) {
|
||||
const contactResp = await fetch(`/api/v1/customers/${customer.id}/contacts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
first_name: contactFirstName,
|
||||
last_name: contactLastName,
|
||||
email: contactEmail || null,
|
||||
is_primary: true,
|
||||
role: 'primary'
|
||||
})
|
||||
});
|
||||
|
||||
if (!contactResp.ok) {
|
||||
console.warn('Contact creation failed for quick flow', await contactResp.text());
|
||||
}
|
||||
}
|
||||
|
||||
// Link email
|
||||
await fetch(`/api/v1/emails/${emailId}/link`, {
|
||||
@ -2862,7 +3278,7 @@ async function submitQuickCustomer() {
|
||||
});
|
||||
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
|
||||
showSuccess(`Kunde "${name}" oprettet og linket`);
|
||||
showSuccess(`Kunde "${customer.name || name}" linket`);
|
||||
loadEmailDetail(parseInt(emailId));
|
||||
} catch (e) {
|
||||
statusEl.className = 'text-danger small';
|
||||
@ -3254,6 +3670,26 @@ function getCaseBadge(email) {
|
||||
return `<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`;
|
||||
}
|
||||
|
||||
function getPriorityBadge(email) {
|
||||
const sender = String(email.sender_email || '').toLowerCase();
|
||||
const domain = normalizeDomain(sender.includes('@') ? sender.split('@')[1] : '');
|
||||
const hasCustomer = !!email.customer_id;
|
||||
|
||||
if (!hasCustomer && domain && !['gmail.com', 'hotmail.com', 'outlook.com', 'icloud.com'].includes(domain)) {
|
||||
return '<span class="badge bg-danger-subtle text-danger-emphasis ms-1">Ingen kontakt</span>';
|
||||
}
|
||||
|
||||
if ((email.classification || '').toLowerCase() === 'invoice') {
|
||||
return '<span class="badge bg-info-subtle text-info-emphasis ms-1">Mulig leverandørfaktura</span>';
|
||||
}
|
||||
|
||||
if (!domain) {
|
||||
return '<span class="badge bg-warning-subtle text-warning-emphasis ms-1">Ukendt domæne</span>';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getFileIcon(contentType) {
|
||||
if (contentType?.includes('pdf')) return 'pdf';
|
||||
if (contentType?.includes('image')) return 'image';
|
||||
@ -4056,7 +4492,7 @@ async function deleteWorkflow(id) {
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWorkflowsForEmail() {
|
||||
async function executeWorkflowsForEmail(button = null) {
|
||||
if (!currentEmailId) {
|
||||
alert('Ingen email valgt');
|
||||
return;
|
||||
@ -4064,6 +4500,8 @@ async function executeWorkflowsForEmail() {
|
||||
|
||||
if (!confirm('Vil du køre workflows for denne email?')) return;
|
||||
|
||||
const done = withActionLoading(button, 'Kører...');
|
||||
|
||||
try {
|
||||
showNotification('Kører workflows...', 'info');
|
||||
|
||||
@ -4100,6 +4538,8 @@ async function executeWorkflowsForEmail() {
|
||||
} catch (error) {
|
||||
console.error('Error executing workflows:', error);
|
||||
showNotification('❌ Kunne ikke køre workflows', 'danger');
|
||||
} finally {
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4885,6 +5325,26 @@ async function uploadEmailFiles(files) {
|
||||
<label class="form-label fw-semibold">Telefon</label>
|
||||
<input type="text" class="form-control" id="qcCustomerPhone">
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="qcCreateContact" checked>
|
||||
<label class="form-check-label" for="qcCreateContact">
|
||||
Opret også primær kontakt
|
||||
</label>
|
||||
</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Kontakt fornavn</label>
|
||||
<input type="text" class="form-control" id="qcContactFirstName" placeholder="Fornavn">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label fw-semibold">Kontakt efternavn</label>
|
||||
<input type="text" class="form-control" id="qcContactLastName" placeholder="Efternavn">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Kontakt email</label>
|
||||
<input type="email" class="form-control" id="qcContactEmail" placeholder="kontakt@firma.dk">
|
||||
</div>
|
||||
<div id="qcCustomerStatus"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@ -148,7 +148,7 @@ def get_dashboard_status() -> Dict[str, int]:
|
||||
FROM sag_sager
|
||||
WHERE deleted_at IS NULL
|
||||
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||
"""
|
||||
)
|
||||
)
|
||||
@ -262,7 +262,7 @@ def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]
|
||||
AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP)
|
||||
AND (l.status IS NULL OR l.status != 'dismissed')
|
||||
ORDER BY
|
||||
CASE LOWER(COALESCE(r.priority, 'normal'))
|
||||
CASE LOWER(COALESCE(r.priority::text, 'normal'))
|
||||
WHEN 'urgent' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'normal' THEN 3
|
||||
@ -386,7 +386,7 @@ def build_bottom_bar_state(
|
||||
FROM sag_sager
|
||||
WHERE deleted_at IS NULL
|
||||
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||
AND LOWER(COALESCE(priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||
ORDER BY updated_at DESC NULLS LAST, id DESC
|
||||
LIMIT 5
|
||||
"""
|
||||
@ -447,7 +447,7 @@ def build_bottom_bar_state(
|
||||
u.user_id,
|
||||
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
|
||||
COUNT(s.id)::int AS open_cases,
|
||||
COUNT(CASE WHEN LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
|
||||
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
|
||||
FROM users u
|
||||
LEFT JOIN sag_sager s
|
||||
ON s.ansvarlig_bruger_id = u.user_id
|
||||
@ -473,7 +473,7 @@ def build_bottom_bar_state(
|
||||
json_build_object(
|
||||
'id', t.id,
|
||||
'title', t.titel,
|
||||
'priority', COALESCE(t.priority, 'normal'),
|
||||
'priority', COALESCE(t.priority::text, 'normal'),
|
||||
'deadline', t.deadline
|
||||
)
|
||||
ORDER BY
|
||||
@ -531,7 +531,7 @@ def build_bottom_bar_state(
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
|
||||
AND LOWER(COALESCE(s.priority, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||
AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
|
||||
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
|
||||
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
|
||||
LIMIT 8
|
||||
|
||||
@ -1104,6 +1104,23 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
if "customer_id" in updates:
|
||||
updates["customer_id"] = _coerce_optional_int(updates.get("customer_id"), "customer_id")
|
||||
_validate_customer_id(updates["customer_id"])
|
||||
if "supplier_flow_type" in updates:
|
||||
flow_type = str(updates.get("supplier_flow_type") or "").strip().lower()
|
||||
if flow_type and flow_type not in {"varekob", "ydelse"}:
|
||||
raise HTTPException(status_code=400, detail="supplier_flow_type must be varekob or ydelse")
|
||||
updates["supplier_flow_type"] = flow_type or None
|
||||
if "supplier_flow_confidence" in updates:
|
||||
conf = updates.get("supplier_flow_confidence")
|
||||
if conf in (None, ""):
|
||||
updates["supplier_flow_confidence"] = None
|
||||
else:
|
||||
try:
|
||||
conf_num = float(conf)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="supplier_flow_confidence must be a number between 0 and 1")
|
||||
if conf_num < 0 or conf_num > 1:
|
||||
raise HTTPException(status_code=400, detail="supplier_flow_confidence must be between 0 and 1")
|
||||
updates["supplier_flow_confidence"] = conf_num
|
||||
|
||||
# Build dynamic update query
|
||||
allowed_fields = [
|
||||
@ -1120,6 +1137,8 @@ async def update_sag(sag_id: int, updates: dict):
|
||||
"deferred_until",
|
||||
"deferred_until_case_id",
|
||||
"deferred_until_status",
|
||||
"supplier_flow_type",
|
||||
"supplier_flow_confidence",
|
||||
]
|
||||
set_clauses = []
|
||||
params = []
|
||||
|
||||
@ -2678,6 +2678,11 @@
|
||||
<i class="bi bi-basket3 me-2"></i>Varekøb & Salg
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="supplier-tab" data-bs-toggle="tab" data-bs-target="#supplier" type="button" role="tab" data-module-tab="supplier" onclick="forceCaseTabActivation('supplier', this)">
|
||||
<i class="bi bi-receipt-cutoff me-2"></i>Leverandør
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="timetracking-tab" data-bs-toggle="tab" data-bs-target="#timetracking" type="button" role="tab" onclick="forceCaseTabActivation('timetracking', this)">
|
||||
<i class="bi bi-clock-history me-2"></i>Tidsforbrug
|
||||
@ -6112,6 +6117,136 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leverandør Tab -->
|
||||
<div class="tab-pane fade" id="supplier" role="tabpanel" tabindex="0" data-module="supplier" data-has-content="unknown" style="display:none;">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-8 col-12">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 text-primary"><i class="bi bi-diagram-3 me-2"></i>Leverandørflow Type</h6>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="saveSupplierFlowType()">
|
||||
<i class="bi bi-save me-1"></i>Gem type
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Type</label>
|
||||
<select class="form-select" id="supplierFlowTypeSelect" onchange="toggleSupplierTypePanels()">
|
||||
<option value="">Vælg type...</option>
|
||||
<option value="varekob" {% if case.supplier_flow_type == 'varekob' %}selected{% endif %}>Varekøb</option>
|
||||
<option value="ydelse" {% if case.supplier_flow_type == 'ydelse' %}selected{% endif %}>Købt ydelse</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">AI confidence</label>
|
||||
<input type="number" class="form-control" id="supplierFlowConfidenceInput" min="0" max="1" step="0.01" value="{{ case.supplier_flow_confidence if case.supplier_flow_confidence is not none else '' }}" placeholder="0.00 - 1.00">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Forslag</label>
|
||||
<div id="supplierFlowSuggestion" class="small text-muted border rounded p-2">Ingen forslag endnu</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3" id="supplierVarekobPanel" style="display:none;">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 text-primary"><i class="bi bi-box-seam me-2"></i>VAREKØB handlinger pr. linje</h6>
|
||||
<span class="badge bg-light text-dark border" id="supplierPurchaseLinesCount">0 linjer</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0" style="vertical-align: middle;">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-3">Beskrivelse</th>
|
||||
<th>Beløb</th>
|
||||
<th>Handling</th>
|
||||
<th class="text-end pe-3">Gem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="supplierPurchaseLinesBody">
|
||||
<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3" id="supplierYdelsePanel" style="display:none;">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0 text-primary"><i class="bi bi-tools me-2"></i>YDELSE handling</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Ydelsestype</label>
|
||||
<select class="form-select" id="supplierServiceType">
|
||||
<option value="husleje">Husleje</option>
|
||||
<option value="rengoering">Rengøring</option>
|
||||
<option value="konsulent">Konsulent</option>
|
||||
<option value="abonnement">Abonnement</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Periode fra</label>
|
||||
<input type="date" class="form-control" id="supplierServiceFrom">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Periode til</label>
|
||||
<input type="date" class="form-control" id="supplierServiceTo">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Beløb</label>
|
||||
<input type="number" min="0" step="0.01" class="form-control" id="supplierServiceAmount" placeholder="0,00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3 flex-wrap">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('direkte_omkostning')">
|
||||
<i class="bi bi-cash-stack me-1"></i>Direkte omkostning
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('fordel_kunder')">
|
||||
<i class="bi bi-diagram-2 me-1"></i>Fordel på kunder
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="registerSupplierServiceHandling('gentagende_ydelse')">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Gentagende ydelse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4 col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 text-primary"><i class="bi bi-receipt me-2"></i>Leverandørfakturaer</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadSupplierModule()">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-3">Faktura</th>
|
||||
<th>Beløb</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end pe-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="supplierInvoicesBody">
|
||||
<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tidsforbrug Tab -->
|
||||
<div class="tab-pane fade" id="timetracking" role="tabpanel" tabindex="0" data-has-content="unknown" style="display:none;">
|
||||
<div id="timeActiveBanner" class="alert alert-warning d-none d-flex justify-content-between align-items-center" role="alert">
|
||||
@ -7527,6 +7662,313 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const supplierCaseId = {{ case.id }};
|
||||
|
||||
function supplierStatusBadge(status) {
|
||||
const value = String(status || '').toLowerCase();
|
||||
if (value === 'godkendt') return '<span class="badge bg-primary-subtle text-primary-emphasis">Godkendt</span>';
|
||||
if (value === 'betalt') return '<span class="badge bg-success-subtle text-success-emphasis">Betalt</span>';
|
||||
if (value === 'afvist') return '<span class="badge bg-danger-subtle text-danger-emphasis">Afvist</span>';
|
||||
return '<span class="badge bg-warning-subtle text-warning-emphasis">Modtaget</span>';
|
||||
}
|
||||
|
||||
function inferSupplierFlowSuggestion() {
|
||||
const purchaseItems = (saleItemsCache || []).filter((item) => (item.type || '').toLowerCase() === 'purchase');
|
||||
if (purchaseItems.length > 0) {
|
||||
return { type: 'varekob', confidence: 0.87, reason: 'Sag har indkøbslinjer fra leverandørflow' };
|
||||
}
|
||||
|
||||
const purchaseText = (saleItemsCache || [])
|
||||
.filter((item) => (item.type || '').toLowerCase() === 'purchase')
|
||||
.map((item) => String(item.description || '').toLowerCase())
|
||||
.join(' ');
|
||||
|
||||
if (/(abonnement|hosting|licens|service|konsulent|rengoering|husleje)/.test(purchaseText)) {
|
||||
return { type: 'ydelse', confidence: 0.76, reason: 'Beskrivelser matcher ydelsesmønstre' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderSupplierSuggestion() {
|
||||
const suggestionEl = document.getElementById('supplierFlowSuggestion');
|
||||
if (!suggestionEl) return;
|
||||
|
||||
const suggestion = inferSupplierFlowSuggestion();
|
||||
if (!suggestion) {
|
||||
suggestionEl.innerHTML = '<span class="text-muted">Ingen sikker AI-indikation endnu</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
suggestionEl.innerHTML = `
|
||||
<div><strong>Forslag:</strong> ${suggestion.type === 'varekob' ? 'Varekøb' : 'Købt ydelse'}</div>
|
||||
<div class="small text-muted">Confidence: ${(suggestion.confidence * 100).toFixed(0)}% · ${suggestion.reason}</div>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" onclick="applySupplierSuggestion('${suggestion.type}', ${suggestion.confidence})">
|
||||
<i class="bi bi-magic me-1"></i>Anvend forslag
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function applySupplierSuggestion(type, confidence) {
|
||||
const typeEl = document.getElementById('supplierFlowTypeSelect');
|
||||
const confEl = document.getElementById('supplierFlowConfidenceInput');
|
||||
if (typeEl) typeEl.value = type;
|
||||
if (confEl) confEl.value = Number(confidence).toFixed(2);
|
||||
toggleSupplierTypePanels();
|
||||
}
|
||||
|
||||
function toggleSupplierTypePanels() {
|
||||
const type = document.getElementById('supplierFlowTypeSelect')?.value || '';
|
||||
const varekobPanel = document.getElementById('supplierVarekobPanel');
|
||||
const ydelsePanel = document.getElementById('supplierYdelsePanel');
|
||||
if (varekobPanel) varekobPanel.style.display = type === 'varekob' ? '' : 'none';
|
||||
if (ydelsePanel) ydelsePanel.style.display = type === 'ydelse' ? '' : 'none';
|
||||
if (type === 'varekob') {
|
||||
renderSupplierPurchasePurposeRows();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSupplierFlowType() {
|
||||
const type = document.getElementById('supplierFlowTypeSelect')?.value || null;
|
||||
const confidenceRaw = document.getElementById('supplierFlowConfidenceInput')?.value || null;
|
||||
|
||||
const payload = {
|
||||
supplier_flow_type: type,
|
||||
supplier_flow_confidence: confidenceRaw === null || confidenceRaw === '' ? null : Number(confidenceRaw)
|
||||
};
|
||||
|
||||
const res = await fetch(`/api/v1/sag/${supplierCaseId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
alert('Kunne ikke gemme leverandørflow type');
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Leverandørflow type gemt');
|
||||
toggleSupplierTypePanels();
|
||||
}
|
||||
|
||||
function renderSupplierPurchasePurposeRows() {
|
||||
const body = document.getElementById('supplierPurchaseLinesBody');
|
||||
const countBadge = document.getElementById('supplierPurchaseLinesCount');
|
||||
if (!body) return;
|
||||
|
||||
const purchases = (saleItemsCache || []).filter((item) => (item.type || '').toLowerCase() === 'purchase');
|
||||
if (countBadge) countBadge.textContent = `${purchases.length} linjer`;
|
||||
|
||||
if (!purchases.length) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Ingen indkøbslinjer fundet på denne sag</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = purchases.map((item) => `
|
||||
<tr>
|
||||
<td class="ps-3">${item.description || '-'}</td>
|
||||
<td>${formatCurrency(item.amount || 0)}</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" id="supplierPurpose-${item.id}">
|
||||
<option value="">Vælg...</option>
|
||||
<option value="salg" ${item.purchase_purpose === 'salg' ? 'selected' : ''}>Videresalg</option>
|
||||
<option value="asset" ${item.purchase_purpose === 'asset' ? 'selected' : ''}>Opret som Asset</option>
|
||||
<option value="intern_brug" ${item.purchase_purpose === 'intern_brug' ? 'selected' : ''}>Intern brug</option>
|
||||
<option value="projekt_omkostning" ${item.purchase_purpose === 'projekt_omkostning' ? 'selected' : ''}>Forbrug/omkostning</option>
|
||||
<option value="lager" ${item.purchase_purpose === 'lager' ? 'selected' : ''}>Lager</option>
|
||||
<option value="retur_reklamation" ${item.purchase_purpose === 'retur_reklamation' ? 'selected' : ''}>Retur/reklamation</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="saveSupplierPurchasePurpose(${item.id})">
|
||||
<i class="bi bi-save"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function saveSupplierPurchasePurpose(itemId) {
|
||||
const purpose = document.getElementById(`supplierPurpose-${itemId}`)?.value || null;
|
||||
const res = await fetch(`/api/v1/sag/${supplierCaseId}/sale-items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'purchase',
|
||||
purchase_purpose: purpose
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
alert('Kunne ikke gemme linjehandling');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadVarekobSalg();
|
||||
renderSupplierPurchasePurposeRows();
|
||||
}
|
||||
|
||||
async function registerSupplierServiceHandling(actionCode) {
|
||||
const serviceType = document.getElementById('supplierServiceType')?.value || '';
|
||||
const fromDate = document.getElementById('supplierServiceFrom')?.value || '';
|
||||
const toDate = document.getElementById('supplierServiceTo')?.value || '';
|
||||
const amount = document.getElementById('supplierServiceAmount')?.value || '';
|
||||
|
||||
const content = [
|
||||
'Supplier ydelse-handling registreret',
|
||||
`Handling: ${actionCode}`,
|
||||
`Type: ${serviceType}`,
|
||||
`Periode: ${fromDate || '-'} -> ${toDate || '-'}`,
|
||||
`Beløb: ${amount || '-'} DKK`,
|
||||
].join('\n');
|
||||
|
||||
const res = await fetch(`/api/v1/sag/${supplierCaseId}/kommentarer`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
indhold: content,
|
||||
er_system_besked: true,
|
||||
forfatter: 'Supplier Flow'
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
alert('Kunne ikke registrere ydelseshandling');
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Ydelseshandling registreret på sagen');
|
||||
}
|
||||
|
||||
async function supplierApproveInvoice(invoiceId) {
|
||||
const approvedBy = prompt('Hvem godkender?', 'System');
|
||||
if (!approvedBy) return;
|
||||
|
||||
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approved_by: approvedBy })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
alert(`Kunne ikke godkende faktura: ${msg}`);
|
||||
return;
|
||||
}
|
||||
await loadSupplierModule();
|
||||
}
|
||||
|
||||
async function supplierRejectInvoice(invoiceId) {
|
||||
const rejectedBy = prompt('Hvem afviser?', 'System');
|
||||
if (!rejectedBy) return;
|
||||
const reason = prompt('Årsag til afvisning (valgfri):', '') || null;
|
||||
|
||||
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rejected_by: rejectedBy, reason })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
alert(`Kunne ikke afvise faktura: ${msg}`);
|
||||
return;
|
||||
}
|
||||
await loadSupplierModule();
|
||||
}
|
||||
|
||||
async function supplierMarkPaid(invoiceId) {
|
||||
const amountRaw = prompt('Betalingsbeløb (tom = restbeløb):', '');
|
||||
const amount = amountRaw && amountRaw.trim() !== '' ? Number(amountRaw) : null;
|
||||
if (amountRaw && (!Number.isFinite(amount) || amount <= 0)) {
|
||||
alert('Ugyldigt beløb');
|
||||
return;
|
||||
}
|
||||
|
||||
const method = prompt('Betalingsmetode (fx bank):', '') || null;
|
||||
const reference = prompt('Betalingsreference (valgfri):', '') || null;
|
||||
const paidBy = prompt('Registreret af:', 'System') || 'System';
|
||||
|
||||
const res = await fetch(`/api/v1/supplier-invoices/${invoiceId}/mark-paid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
payment_method: method,
|
||||
payment_reference: reference,
|
||||
paid_by: paidBy
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
alert(`Kunne ikke registrere betaling: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadSupplierModule();
|
||||
}
|
||||
|
||||
function renderSupplierInvoices(items) {
|
||||
const body = document.getElementById('supplierInvoicesBody');
|
||||
if (!body) return;
|
||||
|
||||
if (!items || !items.length) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Ingen leverandørfakturaer på sagen</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
body.innerHTML = items.map((inv) => {
|
||||
const statusV2 = inv.status_v2 || 'modtaget';
|
||||
return `
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<div class="fw-semibold">${inv.invoice_number || `ID-${inv.id}`}</div>
|
||||
<div class="small text-muted">${inv.invoice_date || '-'}</div>
|
||||
</td>
|
||||
<td>${formatCurrency(inv.total_amount || 0)}</td>
|
||||
<td>${supplierStatusBadge(statusV2)}</td>
|
||||
<td class="text-end pe-3">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-primary" onclick="supplierApproveInvoice(${inv.id})" title="Godkend"><i class="bi bi-check2"></i></button>
|
||||
<button class="btn btn-outline-danger" onclick="supplierRejectInvoice(${inv.id})" title="Afvis"><i class="bi bi-x"></i></button>
|
||||
<button class="btn btn-outline-success" onclick="supplierMarkPaid(${inv.id})" title="Registrer betaling"><i class="bi bi-cash"></i></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadSupplierModule() {
|
||||
const body = document.getElementById('supplierInvoicesBody');
|
||||
if (body) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Indlæser...</td></tr>';
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/supplier-invoices?sag_id=${supplierCaseId}`);
|
||||
if (!res.ok) throw new Error('Kunne ikke hente leverandørfakturaer');
|
||||
const items = await res.json();
|
||||
renderSupplierInvoices(items || []);
|
||||
renderSupplierSuggestion();
|
||||
toggleSupplierTypePanels();
|
||||
setModuleContentState('supplier', (items || []).length > 0);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (body) {
|
||||
body.innerHTML = '<tr><td colspan="4" class="text-center py-4 text-muted">Kunne ikke hente leverandørfakturaer</td></tr>';
|
||||
}
|
||||
setModuleContentState('supplier', true);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSupplierModule();
|
||||
toggleSupplierTypePanels();
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const timeCaseId = {{ case.id }};
|
||||
let timeV1EntriesById = {};
|
||||
|
||||
@ -49,6 +49,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sag-table tbody tr:nth-child(even) {
|
||||
background: rgba(15, 76, 117, 0.04);
|
||||
}
|
||||
|
||||
.sag-table tbody tr:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
@ -264,30 +268,61 @@
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.8rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(0,0,0,0.03);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 0.35px;
|
||||
}
|
||||
|
||||
.owner-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.owner-avatar {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.owner-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@ -322,13 +357,13 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid" style="max-width: none; padding-top: 2rem;">
|
||||
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
|
||||
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h1 style="margin: 0; color: var(--accent);">
|
||||
<i class="bi bi-list-check me-2"></i>Sager
|
||||
<i class="bi bi-list-check"></i>
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-primary" onclick="window.location.href='/sag/varekob-salg'">
|
||||
@ -350,10 +385,6 @@
|
||||
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'åben')|list|length }}</div>
|
||||
<div class="stat-label">Åbne</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ sager|selectattr('status', 'equalto', 'lukket')|list|length }}</div>
|
||||
<div class="stat-label">Lukkede</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search & Filters -->
|
||||
@ -461,8 +492,19 @@
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||
{{ sag.priority if sag.priority else 'normal' }}
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.ansvarlig_navn if sag.ansvarlig_navn else '-' }}
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'">
|
||||
{% if sag.ansvarlig_navn %}
|
||||
{% set owner_name = sag.ansvarlig_navn.strip() %}
|
||||
{% set owner_parts = owner_name.split() %}
|
||||
<div class="owner-cell">
|
||||
<span class="owner-avatar">
|
||||
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
|
||||
</span>
|
||||
<span class="owner-name">{{ owner_name }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
||||
@ -528,8 +570,19 @@
|
||||
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
|
||||
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.ansvarlig_navn if related_sag.ansvarlig_navn else '-' }}
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'">
|
||||
{% if related_sag.ansvarlig_navn %}
|
||||
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
|
||||
{% set owner_parts = owner_name.split() %}
|
||||
<div class="owner-cell">
|
||||
<span class="owner-avatar">
|
||||
{{ ((owner_parts[0][0] if owner_parts|length > 0 else owner_name[0]) ~ (owner_parts[1][0] if owner_parts|length > 1 else ''))|upper }}
|
||||
</span>
|
||||
<span class="owner-name">{{ owner_name }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
||||
|
||||
@ -279,6 +279,7 @@
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-detail-line {
|
||||
display: none;
|
||||
margin-top: 0.5rem;
|
||||
background: transparent;
|
||||
padding: 0 0.5rem;
|
||||
@ -287,12 +288,19 @@
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.global-bottom-bar.is-expanded .bb-detail-line {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.global-bottom-bar:not(.is-expanded) .bb-header {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.global-bottom-bar:not(.is-expanded) .bb-header::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-sheet-panel {
|
||||
@ -419,14 +427,14 @@
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-header {
|
||||
.global-bottom-bar.is-expanded .bb-header {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.4rem;
|
||||
}
|
||||
|
||||
.global-bottom-bar .bb-zone-left,
|
||||
.global-bottom-bar .bb-zone-center,
|
||||
.global-bottom-bar .bb-zone-right {
|
||||
.global-bottom-bar.is-expanded .bb-zone-left,
|
||||
.global-bottom-bar.is-expanded .bb-zone-center,
|
||||
.global-bottom-bar.is-expanded .bb-zone-right {
|
||||
flex: 1 1 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@ -145,22 +145,138 @@ def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> O
|
||||
if hub_customer_id is None:
|
||||
return None
|
||||
|
||||
mapped = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM tmodule_customers
|
||||
WHERE hub_customer_id = %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
)
|
||||
if not mapped:
|
||||
return None
|
||||
def _get_existing_mapping() -> Optional[int]:
|
||||
mapped = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM tmodule_customers
|
||||
WHERE hub_customer_id = %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
)
|
||||
if not mapped:
|
||||
return None
|
||||
try:
|
||||
return int(mapped.get("id")) if mapped.get("id") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
mapped_id = _get_existing_mapping()
|
||||
if mapped_id is not None:
|
||||
return mapped_id
|
||||
|
||||
# Auto-link fallback: try targeted linking by economic number/name before failing.
|
||||
try:
|
||||
return int(mapped.get("id")) if mapped.get("id") is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
hub_customer = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, economic_customer_number
|
||||
FROM customers
|
||||
WHERE id = %s
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
) or {}
|
||||
|
||||
economic_number = hub_customer.get("economic_customer_number")
|
||||
customer_name = str(hub_customer.get("name") or "").strip()
|
||||
|
||||
if economic_number not in (None, ""):
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE tmodule_customers
|
||||
SET hub_customer_id = %s,
|
||||
economic_customer_number = COALESCE(economic_customer_number, %s),
|
||||
updated_at = NOW()
|
||||
WHERE hub_customer_id IS NULL
|
||||
AND economic_customer_number = %s
|
||||
""",
|
||||
(hub_customer_id, economic_number, economic_number),
|
||||
)
|
||||
|
||||
if customer_name:
|
||||
execute_update(
|
||||
"""
|
||||
UPDATE tmodule_customers
|
||||
SET hub_customer_id = %s,
|
||||
updated_at = NOW()
|
||||
WHERE hub_customer_id IS NULL
|
||||
AND LOWER(TRIM(COALESCE(name, ''))) = LOWER(TRIM(%s))
|
||||
""",
|
||||
(hub_customer_id, customer_name),
|
||||
)
|
||||
|
||||
# Last fallback: run global relink function if available.
|
||||
try:
|
||||
execute_query("SELECT * FROM link_tmodule_customers_to_hub()")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as link_error:
|
||||
logger.warning("⚠️ Auto-link fallback for hub customer %s failed: %s", hub_customer_id, link_error)
|
||||
|
||||
mapped_id = _get_existing_mapping()
|
||||
if mapped_id is not None:
|
||||
return mapped_id
|
||||
|
||||
# Last-resort fallback: create a local tmodule customer stub from Hub customer.
|
||||
# This keeps timer registration operational even if vTiger sync/linking has not run yet.
|
||||
try:
|
||||
hub_customer = execute_query_single(
|
||||
"""
|
||||
SELECT id, name, email, economic_customer_number
|
||||
FROM customers
|
||||
WHERE id = %s
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
) or {}
|
||||
|
||||
hub_name = str(hub_customer.get("name") or "").strip()
|
||||
if hub_name:
|
||||
synthetic_vtiger_id = f"HUB-{hub_customer_id}"
|
||||
created = execute_query(
|
||||
"""
|
||||
INSERT INTO tmodule_customers (
|
||||
vtiger_id,
|
||||
name,
|
||||
email,
|
||||
hub_customer_id,
|
||||
economic_customer_number,
|
||||
vtiger_data,
|
||||
sync_hash,
|
||||
updated_at,
|
||||
last_synced_at
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s,
|
||||
%s::jsonb,
|
||||
%s,
|
||||
NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (vtiger_id)
|
||||
DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
email = COALESCE(EXCLUDED.email, tmodule_customers.email),
|
||||
hub_customer_id = EXCLUDED.hub_customer_id,
|
||||
economic_customer_number = COALESCE(EXCLUDED.economic_customer_number, tmodule_customers.economic_customer_number),
|
||||
updated_at = NOW(),
|
||||
last_synced_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
synthetic_vtiger_id,
|
||||
hub_name,
|
||||
hub_customer.get("email"),
|
||||
hub_customer_id,
|
||||
hub_customer.get("economic_customer_number"),
|
||||
'{"source":"hub_fallback"}',
|
||||
f"hub-fallback-{hub_customer_id}",
|
||||
),
|
||||
) or []
|
||||
if created and created[0].get("id") is not None:
|
||||
return int(created[0]["id"])
|
||||
except Exception as create_error:
|
||||
logger.warning("⚠️ Could not create tmodule customer fallback for hub customer %s: %s", hub_customer_id, create_error)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
116
app/vendors/backend/router.py
vendored
116
app/vendors/backend/router.py
vendored
@ -5,14 +5,56 @@ 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
|
||||
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"),
|
||||
@ -172,3 +214,75 @@ async def delete_vendor(vendor_id: int):
|
||||
|
||||
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}
|
||||
|
||||
164
app/vendors/frontend/vendor_detail.html
vendored
164
app/vendors/frontend/vendor_detail.html
vendored
@ -131,6 +131,9 @@
|
||||
<a class="nav-link" href="#fakturaer" data-tab="fakturaer">
|
||||
<i class="bi bi-receipt me-2"></i>Fakturaer
|
||||
</a>
|
||||
<a class="nav-link" href="#kunder" data-tab="kunder">
|
||||
<i class="bi bi-building me-2"></i>Kunder
|
||||
</a>
|
||||
<a class="nav-link" href="#aktivitet" data-tab="aktivitet">
|
||||
<i class="bi bi-clock-history me-2"></i>Aktivitet
|
||||
</a>
|
||||
@ -224,6 +227,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kunder Tab -->
|
||||
<div class="tab-pane fade" id="kunder">
|
||||
<div class="card p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0 fw-bold">Linked kunder</h5>
|
||||
<span class="badge bg-primary" id="vendorCustomersCount">0</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-8">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="vendorCustomerSearch"
|
||||
placeholder="Søg kunde (navn, email, CVR)"
|
||||
oninput="searchCustomersForVendor(this.value)"
|
||||
>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<small class="text-muted">Link leverandør til kunde</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vendorCustomerSearchResults" class="list-group mb-3" style="display:none;"></div>
|
||||
<div id="vendorCustomersList" class="list-group mb-2"></div>
|
||||
<div id="vendorCustomersEmpty" class="text-muted">Ingen linked kunder endnu.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktivitet Tab -->
|
||||
<div class="tab-pane fade" id="aktivitet">
|
||||
<div class="card p-4">
|
||||
@ -340,12 +372,144 @@ async function loadVendor() {
|
||||
}
|
||||
const vendor = await response.json();
|
||||
displayVendor(vendor);
|
||||
await loadVendorCustomers();
|
||||
} catch (error) {
|
||||
console.error('Error loading vendor:', error);
|
||||
document.getElementById('vendorName').textContent = 'Fejl ved indlæsning';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVendorCustomers() {
|
||||
const listEl = document.getElementById('vendorCustomersList');
|
||||
const emptyEl = document.getElementById('vendorCustomersEmpty');
|
||||
const countEl = document.getElementById('vendorCustomersCount');
|
||||
if (!listEl || !emptyEl || !countEl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/vendors/${vendorId}/customers`);
|
||||
if (!response.ok) throw new Error('Kunne ikke hente linked kunder');
|
||||
const links = await response.json();
|
||||
const rows = Array.isArray(links) ? links : [];
|
||||
|
||||
countEl.textContent = String(rows.length);
|
||||
if (!rows.length) {
|
||||
listEl.innerHTML = '';
|
||||
emptyEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl.classList.add('d-none');
|
||||
listEl.innerHTML = rows.map((row) => `
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(row.customer_name || `Kunde #${row.customer_id}`)}</div>
|
||||
<div class="small text-muted">${row.customer_cvr ? `CVR ${escapeHtml(row.customer_cvr)} · ` : ''}${row.customer_email ? escapeHtml(row.customer_email) : '-'}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-light text-dark border">${escapeHtml(row.relationship_type || 'supplier')}</span>
|
||||
<a class="btn btn-sm btn-outline-primary" href="/customers/${row.customer_id}">
|
||||
<i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="unlinkCustomerFromVendor(${row.customer_id})">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('Error loading vendor customers:', error);
|
||||
listEl.innerHTML = '';
|
||||
emptyEl.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
let customerSearchDebounce = null;
|
||||
async function searchCustomersForVendor(query) {
|
||||
const resultsEl = document.getElementById('vendorCustomerSearchResults');
|
||||
if (!resultsEl) return;
|
||||
|
||||
const q = String(query || '').trim();
|
||||
if (!q) {
|
||||
resultsEl.style.display = 'none';
|
||||
resultsEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (customerSearchDebounce) window.clearTimeout(customerSearchDebounce);
|
||||
customerSearchDebounce = window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers?search=${encodeURIComponent(q)}&limit=10&offset=0`);
|
||||
if (!response.ok) throw new Error('Søgning fejlede');
|
||||
const payload = await response.json();
|
||||
const customers = Array.isArray(payload?.customers) ? payload.customers : [];
|
||||
|
||||
if (!customers.length) {
|
||||
resultsEl.innerHTML = '<div class="list-group-item text-muted">Ingen kunder fundet</div>';
|
||||
resultsEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = customers.map((c) => `
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div class="fw-semibold">${escapeHtml(c.name || '')}</div>
|
||||
<div class="small text-muted">${c.cvr_number ? `CVR ${escapeHtml(c.cvr_number)} · ` : ''}${c.email ? escapeHtml(c.email) : '-'}</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" onclick="linkCustomerToVendorFromUI(${c.id})">
|
||||
<i class="bi bi-link-45deg me-1"></i>Link
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
resultsEl.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('Customer search failed:', error);
|
||||
resultsEl.innerHTML = '<div class="list-group-item text-danger">Søgning fejlede</div>';
|
||||
resultsEl.style.display = 'block';
|
||||
}
|
||||
}, 220);
|
||||
}
|
||||
|
||||
async function linkCustomerToVendorFromUI(customerId) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/vendors/${vendorId}/customers`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ customer_id: customerId, relationship_type: 'supplier' })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.detail || 'Kunne ikke linke kunde');
|
||||
}
|
||||
|
||||
const input = document.getElementById('vendorCustomerSearch');
|
||||
const results = document.getElementById('vendorCustomerSearchResults');
|
||||
if (input) input.value = '';
|
||||
if (results) {
|
||||
results.innerHTML = '';
|
||||
results.style.display = 'none';
|
||||
}
|
||||
await loadVendorCustomers();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Kunne ikke linke kunde');
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkCustomerFromVendor(customerId) {
|
||||
if (!confirm('Fjern link mellem leverandør og kunde?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/vendors/${vendorId}/customers/${customerId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.detail || 'Kunne ikke fjerne link');
|
||||
}
|
||||
await loadVendorCustomers();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Kunne ikke fjerne link');
|
||||
}
|
||||
}
|
||||
|
||||
function displayVendor(vendor) {
|
||||
// Header
|
||||
document.getElementById('vendorName').textContent = vendor.name;
|
||||
|
||||
@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS user_module_preferences (
|
||||
|
||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||
VALUES
|
||||
('bottom_bar_enabled', 'false', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false)
|
||||
('bottom_bar_enabled', 'true', 'bottom_bar', 'Enable or disable bottom bar globally', 'boolean', false)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Default role access: admins, managers and technicians enabled. viewers disabled.
|
||||
|
||||
33
migrations/170_email_domain_customer_mappings.sql
Normal file
33
migrations/170_email_domain_customer_mappings.sql
Normal file
@ -0,0 +1,33 @@
|
||||
-- Migration 170: Trusted sender-domain to customer mappings for email triage
|
||||
-- Created: 2026-04-12
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_domain_customer_mappings (
|
||||
domain VARCHAR(255) PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
source VARCHAR(50) NOT NULL DEFAULT 'manual',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_domain_customer_mappings_customer_id
|
||||
ON email_domain_customer_mappings(customer_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_email_domain_customer_mappings_source
|
||||
ON email_domain_customer_mappings(source);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_email_domain_customer_mappings_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_email_domain_customer_mappings_updated_at ON email_domain_customer_mappings;
|
||||
CREATE TRIGGER trigger_email_domain_customer_mappings_updated_at
|
||||
BEFORE UPDATE ON email_domain_customer_mappings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_email_domain_customer_mappings_updated_at();
|
||||
|
||||
COMMENT ON TABLE email_domain_customer_mappings IS 'Trusted mappings from sender email domain to customer for fast auto-linking in email triage';
|
||||
COMMENT ON COLUMN email_domain_customer_mappings.source IS 'manual, auto_link, import, etc.';
|
||||
84
migrations/171_supplier_invoice_v2_lifecycle.sql
Normal file
84
migrations/171_supplier_invoice_v2_lifecycle.sql
Normal file
@ -0,0 +1,84 @@
|
||||
-- Migration 171: Supplier invoice v2 lifecycle (status, approval, split payments, event log)
|
||||
-- Created: 2026-04-12
|
||||
|
||||
ALTER TABLE supplier_invoices
|
||||
ADD COLUMN IF NOT EXISTS workflow_status_v2 VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS rejected_by VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS rejection_reason TEXT,
|
||||
ADD COLUMN IF NOT EXISTS payment_method VARCHAR(100);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoices_workflow_status_v2'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoices
|
||||
ADD CONSTRAINT chk_supplier_invoices_workflow_status_v2
|
||||
CHECK (workflow_status_v2 IN ('modtaget', 'godkendt', 'betalt', 'afvist'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Backfill v2 statuses from legacy status values.
|
||||
UPDATE supplier_invoices
|
||||
SET workflow_status_v2 = CASE
|
||||
WHEN status IN ('approved', 'sent_to_economic') THEN 'godkendt'
|
||||
WHEN status = 'paid' THEN 'betalt'
|
||||
WHEN status IN ('cancelled', 'credited', 'rejected') THEN 'afvist'
|
||||
ELSE 'modtaget'
|
||||
END
|
||||
WHERE workflow_status_v2 IS NULL;
|
||||
|
||||
ALTER TABLE supplier_invoices
|
||||
ALTER COLUMN workflow_status_v2 SET DEFAULT 'modtaget';
|
||||
|
||||
ALTER TABLE supplier_invoices
|
||||
ALTER COLUMN workflow_status_v2 SET NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_workflow_status_v2
|
||||
ON supplier_invoices(workflow_status_v2);
|
||||
|
||||
-- Split payments on supplier invoices.
|
||||
CREATE TABLE IF NOT EXISTS supplier_invoice_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||
payment_date DATE NOT NULL,
|
||||
amount DECIMAL(15, 2) NOT NULL CHECK (amount > 0),
|
||||
currency VARCHAR(10) NOT NULL DEFAULT 'DKK',
|
||||
payment_method VARCHAR(100),
|
||||
payment_reference VARCHAR(100),
|
||||
notes TEXT,
|
||||
paid_by VARCHAR(255),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_payments_invoice_id
|
||||
ON supplier_invoice_payments(supplier_invoice_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_payments_date
|
||||
ON supplier_invoice_payments(payment_date);
|
||||
|
||||
-- Event log / outbox-style table for later webhook activation.
|
||||
CREATE TABLE IF NOT EXISTS supplier_invoice_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(60) NOT NULL,
|
||||
from_status VARCHAR(20),
|
||||
to_status VARCHAR(20),
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
webhook_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_events_invoice_id
|
||||
ON supplier_invoice_events(supplier_invoice_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_events_webhook_status
|
||||
ON supplier_invoice_events(webhook_status);
|
||||
|
||||
COMMENT ON COLUMN supplier_invoices.workflow_status_v2 IS 'v2 lifecycle: modtaget, godkendt, betalt, afvist';
|
||||
COMMENT ON TABLE supplier_invoice_payments IS 'Partial/split payments for supplier invoices';
|
||||
COMMENT ON TABLE supplier_invoice_events IS 'Supplier invoice event log prepared for future webhook/outbox processing';
|
||||
39
migrations/172_sag_supplier_flow_type.sql
Normal file
39
migrations/172_sag_supplier_flow_type.sql
Normal file
@ -0,0 +1,39 @@
|
||||
-- Migration 172: Supplier flow type fields on cases
|
||||
-- Created: 2026-04-12
|
||||
|
||||
ALTER TABLE sag_sager
|
||||
ADD COLUMN IF NOT EXISTS supplier_flow_type VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS supplier_flow_confidence NUMERIC(4,3);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_sag_sager_supplier_flow_type'
|
||||
) THEN
|
||||
ALTER TABLE sag_sager
|
||||
ADD CONSTRAINT chk_sag_sager_supplier_flow_type
|
||||
CHECK (supplier_flow_type IS NULL OR supplier_flow_type IN ('varekob', 'ydelse'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_sag_sager_supplier_flow_confidence'
|
||||
) THEN
|
||||
ALTER TABLE sag_sager
|
||||
ADD CONSTRAINT chk_sag_sager_supplier_flow_confidence
|
||||
CHECK (supplier_flow_confidence IS NULL OR (supplier_flow_confidence >= 0 AND supplier_flow_confidence <= 1));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sag_sager_supplier_flow_type
|
||||
ON sag_sager(supplier_flow_type)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
COMMENT ON COLUMN sag_sager.supplier_flow_type IS 'Supplier module type selection: varekob or ydelse';
|
||||
COMMENT ON COLUMN sag_sager.supplier_flow_confidence IS 'AI suggestion confidence (0-1) for supplier flow type';
|
||||
68
migrations/173_supplier_invoice_flow_metadata.sql
Normal file
68
migrations/173_supplier_invoice_flow_metadata.sql
Normal file
@ -0,0 +1,68 @@
|
||||
-- Migration 173: Supplier invoice flow metadata
|
||||
-- Created: 2026-04-13
|
||||
|
||||
ALTER TABLE supplier_invoices
|
||||
ADD COLUMN IF NOT EXISTS supplier_flow_type VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS tags_json JSONB DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS linked_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS linked_order_id BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS linked_order_source VARCHAR(50);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoices_supplier_flow_type'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoices
|
||||
ADD CONSTRAINT chk_supplier_invoices_supplier_flow_type
|
||||
CHECK (
|
||||
supplier_flow_type IS NULL
|
||||
OR supplier_flow_type IN ('varekob', 'ydelse')
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoices_linked_order_source'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoices
|
||||
ADD CONSTRAINT chk_supplier_invoices_linked_order_source
|
||||
CHECK (
|
||||
linked_order_source IS NULL
|
||||
OR linked_order_source IN ('tmodule_orders', 'webshop_orders')
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_supplier_flow_type
|
||||
ON supplier_invoices(supplier_flow_type)
|
||||
WHERE supplier_flow_type IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_source_email_id
|
||||
ON supplier_invoices(source_email_id)
|
||||
WHERE source_email_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_linked_customer_id
|
||||
ON supplier_invoices(linked_customer_id)
|
||||
WHERE linked_customer_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_linked_order_id
|
||||
ON supplier_invoices(linked_order_id)
|
||||
WHERE linked_order_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoices_tags_json
|
||||
ON supplier_invoices USING GIN (tags_json);
|
||||
|
||||
COMMENT ON COLUMN supplier_invoices.supplier_flow_type IS 'Flow type: varekob or ydelse';
|
||||
COMMENT ON COLUMN supplier_invoices.tags_json IS 'Tag list as JSON array';
|
||||
COMMENT ON COLUMN supplier_invoices.source_email_id IS 'Source email message id';
|
||||
COMMENT ON COLUMN supplier_invoices.linked_customer_id IS 'Linked customer context';
|
||||
COMMENT ON COLUMN supplier_invoices.linked_order_id IS 'Linked order id';
|
||||
COMMENT ON COLUMN supplier_invoices.linked_order_source IS 'Order table source: tmodule_orders or webshop_orders';
|
||||
88
migrations/174_supplier_invoice_line_handling.sql
Normal file
88
migrations/174_supplier_invoice_line_handling.sql
Normal file
@ -0,0 +1,88 @@
|
||||
-- Migration 174: Supplier invoice line handling fields
|
||||
-- Created: 2026-04-13
|
||||
|
||||
ALTER TABLE supplier_invoice_lines
|
||||
ADD COLUMN IF NOT EXISTS handling_type VARCHAR(40),
|
||||
ADD COLUMN IF NOT EXISTS target_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS target_sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS target_employee_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS requires_serial BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS serial_number VARCHAR(120),
|
||||
ADD COLUMN IF NOT EXISTS asset_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS resale_ready BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoice_lines_handling_type'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoice_lines
|
||||
ADD CONSTRAINT chk_supplier_invoice_lines_handling_type
|
||||
CHECK (
|
||||
handling_type IS NULL
|
||||
OR handling_type IN (
|
||||
'fakturer_videre',
|
||||
'asset',
|
||||
'intern_brug',
|
||||
'projekt_omkostning',
|
||||
'lager',
|
||||
'retur_reklamation'
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoice_lines_serial_requirement'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoice_lines
|
||||
ADD CONSTRAINT chk_supplier_invoice_lines_serial_requirement
|
||||
CHECK (
|
||||
requires_serial = FALSE
|
||||
OR (serial_number IS NOT NULL AND btrim(serial_number) <> '')
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_handling_type
|
||||
ON supplier_invoice_lines(handling_type)
|
||||
WHERE handling_type IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_customer_id
|
||||
ON supplier_invoice_lines(target_customer_id)
|
||||
WHERE target_customer_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_sag_id
|
||||
ON supplier_invoice_lines(target_sag_id)
|
||||
WHERE target_sag_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_target_employee_id
|
||||
ON supplier_invoice_lines(target_employee_id)
|
||||
WHERE target_employee_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_asset_id
|
||||
ON supplier_invoice_lines(asset_id)
|
||||
WHERE asset_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_resale_ready
|
||||
ON supplier_invoice_lines(resale_ready)
|
||||
WHERE resale_ready = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_serial_number
|
||||
ON supplier_invoice_lines(serial_number)
|
||||
WHERE serial_number IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN supplier_invoice_lines.handling_type IS 'Line handling: fakturer_videre, asset, intern_brug, projekt_omkostning, lager, retur_reklamation';
|
||||
COMMENT ON COLUMN supplier_invoice_lines.target_customer_id IS 'Customer target for line allocation';
|
||||
COMMENT ON COLUMN supplier_invoice_lines.target_sag_id IS 'Case target for line allocation';
|
||||
COMMENT ON COLUMN supplier_invoice_lines.target_employee_id IS 'Employee target for line allocation';
|
||||
COMMENT ON COLUMN supplier_invoice_lines.requires_serial IS 'Whether serial number is required';
|
||||
COMMENT ON COLUMN supplier_invoice_lines.serial_number IS 'Serial number when required';
|
||||
COMMENT ON COLUMN supplier_invoice_lines.asset_id IS 'Linked hardware asset id';
|
||||
COMMENT ON COLUMN supplier_invoice_lines.resale_ready IS 'Whether line is ready for resale flow';
|
||||
48
migrations/175_supplier_invoice_attachments.sql
Normal file
48
migrations/175_supplier_invoice_attachments.sql
Normal file
@ -0,0 +1,48 @@
|
||||
-- Migration 175: Supplier invoice attachments
|
||||
-- Created: 2026-04-13
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supplier_invoice_attachments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||
source_type VARCHAR(30) NOT NULL DEFAULT 'upload',
|
||||
source_email_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
|
||||
source_email_attachment_id INTEGER REFERENCES email_attachments(id) ON DELETE SET NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(120),
|
||||
file_path TEXT,
|
||||
size_bytes BIGINT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoice_attachments_source_type'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoice_attachments
|
||||
ADD CONSTRAINT chk_supplier_invoice_attachments_source_type
|
||||
CHECK (source_type IN ('email', 'upload', 'manual'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_supplier_invoice_id
|
||||
ON supplier_invoice_attachments(supplier_invoice_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_email_id
|
||||
ON supplier_invoice_attachments(source_email_id)
|
||||
WHERE source_email_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_email_attachment_id
|
||||
ON supplier_invoice_attachments(source_email_attachment_id)
|
||||
WHERE source_email_attachment_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_attachments_source_type
|
||||
ON supplier_invoice_attachments(source_type);
|
||||
|
||||
COMMENT ON TABLE supplier_invoice_attachments IS 'Attachments linked to supplier invoices';
|
||||
COMMENT ON COLUMN supplier_invoice_attachments.source_type IS 'Attachment source: email, upload, or manual';
|
||||
COMMENT ON COLUMN supplier_invoice_attachments.source_email_id IS 'Source email id when attachment came from email';
|
||||
COMMENT ON COLUMN supplier_invoice_attachments.source_email_attachment_id IS 'Source email attachment id when available';
|
||||
42
migrations/176_supplier_invoice_relations.sql
Normal file
42
migrations/176_supplier_invoice_relations.sql
Normal file
@ -0,0 +1,42 @@
|
||||
-- Migration 176: Supplier invoice generic relations
|
||||
-- Created: 2026-04-13
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supplier_invoice_relations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||
relation_type VARCHAR(40) NOT NULL,
|
||||
relation_id BIGINT NOT NULL,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
note TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoice_relations_relation_type'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoice_relations
|
||||
ADD CONSTRAINT chk_supplier_invoice_relations_relation_type
|
||||
CHECK (
|
||||
relation_type IN ('sag', 'kunde', 'ordre', 'asset', 'reklamation_sag', 'email')
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_invoice_relations_type_id
|
||||
ON supplier_invoice_relations(supplier_invoice_id, relation_type, relation_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_relations_lookup
|
||||
ON supplier_invoice_relations(relation_type, relation_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_invoice_relations_primary_per_type
|
||||
ON supplier_invoice_relations(supplier_invoice_id, relation_type)
|
||||
WHERE is_primary = TRUE;
|
||||
|
||||
COMMENT ON TABLE supplier_invoice_relations IS 'Generic relation map from supplier invoices to related entities';
|
||||
COMMENT ON COLUMN supplier_invoice_relations.relation_type IS 'Relation type: sag, kunde, ordre, asset, reklamation_sag, email';
|
||||
COMMENT ON COLUMN supplier_invoice_relations.relation_id IS 'Id of the related entity';
|
||||
COMMENT ON COLUMN supplier_invoice_relations.is_primary IS 'Primary relation flag within relation type';
|
||||
85
migrations/177_supplier_invoice_reminders.sql
Normal file
85
migrations/177_supplier_invoice_reminders.sql
Normal file
@ -0,0 +1,85 @@
|
||||
-- Migration 177: Supplier invoice reminders
|
||||
-- Created: 2026-04-13
|
||||
|
||||
CREATE TABLE IF NOT EXISTS supplier_invoice_reminders (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
supplier_invoice_id INTEGER NOT NULL REFERENCES supplier_invoices(id) ON DELETE CASCADE,
|
||||
reminder_type VARCHAR(30) NOT NULL,
|
||||
remind_at TIMESTAMP NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
sent_at TIMESTAMP,
|
||||
channel VARCHAR(20) NOT NULL DEFAULT 'in_app',
|
||||
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoice_reminders_type'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoice_reminders
|
||||
ADD CONSTRAINT chk_supplier_invoice_reminders_type
|
||||
CHECK (reminder_type IN ('due_soon', 'overdue', 'manual'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoice_reminders_status'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoice_reminders
|
||||
ADD CONSTRAINT chk_supplier_invoice_reminders_status
|
||||
CHECK (status IN ('pending', 'sent', 'cancelled', 'failed'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_supplier_invoice_reminders_channel'
|
||||
) THEN
|
||||
ALTER TABLE supplier_invoice_reminders
|
||||
ADD CONSTRAINT chk_supplier_invoice_reminders_channel
|
||||
CHECK (channel IN ('in_app', 'email'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_supplier_invoice_id
|
||||
ON supplier_invoice_reminders(supplier_invoice_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_remind_at
|
||||
ON supplier_invoice_reminders(remind_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_status
|
||||
ON supplier_invoice_reminders(status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_reminders_payload_json
|
||||
ON supplier_invoice_reminders USING GIN (payload_json);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_supplier_invoice_reminders_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_supplier_invoice_reminders_updated_at ON supplier_invoice_reminders;
|
||||
CREATE TRIGGER trg_supplier_invoice_reminders_updated_at
|
||||
BEFORE UPDATE ON supplier_invoice_reminders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_supplier_invoice_reminders_updated_at();
|
||||
|
||||
COMMENT ON TABLE supplier_invoice_reminders IS 'Scheduled reminders for supplier invoice follow-up';
|
||||
COMMENT ON COLUMN supplier_invoice_reminders.reminder_type IS 'due_soon, overdue, or manual';
|
||||
COMMENT ON COLUMN supplier_invoice_reminders.status IS 'pending, sent, cancelled, or failed';
|
||||
COMMENT ON COLUMN supplier_invoice_reminders.channel IS 'Delivery channel: in_app or email';
|
||||
81
migrations/178_customer_vendor_links.sql
Normal file
81
migrations/178_customer_vendor_links.sql
Normal file
@ -0,0 +1,81 @@
|
||||
-- Migration 178: Link customers and vendors (many-to-many)
|
||||
-- Created: 2026-04-13
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customer_vendor_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
vendor_id INTEGER NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
|
||||
relationship_type VARCHAR(50) NOT NULL DEFAULT 'supplier',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (customer_id, vendor_id)
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'chk_customer_vendor_links_relationship_type'
|
||||
) THEN
|
||||
ALTER TABLE customer_vendor_links
|
||||
ADD CONSTRAINT chk_customer_vendor_links_relationship_type
|
||||
CHECK (relationship_type IN ('supplier', 'reseller', 'partner'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_customer_id
|
||||
ON customer_vendor_links(customer_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_vendor_id
|
||||
ON customer_vendor_links(vendor_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_vendor_links_relationship_type
|
||||
ON customer_vendor_links(relationship_type);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_customer_vendor_links_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_customer_vendor_links_updated_at ON customer_vendor_links;
|
||||
CREATE TRIGGER trg_customer_vendor_links_updated_at
|
||||
BEFORE UPDATE ON customer_vendor_links
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_customer_vendor_links_updated_at();
|
||||
|
||||
-- Backfill links by CVR first (most reliable)
|
||||
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
|
||||
SELECT c.id, v.id, 'supplier'
|
||||
FROM customers c
|
||||
JOIN vendors v
|
||||
ON regexp_replace(COALESCE(c.cvr_number, ''), '\\D', '', 'g') <> ''
|
||||
AND regexp_replace(COALESCE(v.cvr_number, ''), '\\D', '', 'g') <> ''
|
||||
AND regexp_replace(COALESCE(c.cvr_number, ''), '\\D', '', 'g') = regexp_replace(COALESCE(v.cvr_number, ''), '\\D', '', 'g')
|
||||
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
|
||||
|
||||
-- Backfill by exact email match
|
||||
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
|
||||
SELECT c.id, v.id, 'supplier'
|
||||
FROM customers c
|
||||
JOIN vendors v
|
||||
ON LOWER(TRIM(COALESCE(c.email, ''))) <> ''
|
||||
AND LOWER(TRIM(COALESCE(v.email, ''))) <> ''
|
||||
AND LOWER(TRIM(COALESCE(c.email, ''))) = LOWER(TRIM(COALESCE(v.email, '')))
|
||||
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
|
||||
|
||||
-- Backfill by exact normalized name match
|
||||
INSERT INTO customer_vendor_links (customer_id, vendor_id, relationship_type)
|
||||
SELECT c.id, v.id, 'supplier'
|
||||
FROM customers c
|
||||
JOIN vendors v
|
||||
ON LOWER(TRIM(COALESCE(c.name, ''))) <> ''
|
||||
AND LOWER(TRIM(COALESCE(v.name, ''))) <> ''
|
||||
AND LOWER(TRIM(COALESCE(c.name, ''))) = LOWER(TRIM(COALESCE(v.name, '')))
|
||||
ON CONFLICT (customer_id, vendor_id) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE customer_vendor_links IS 'Links customers to vendors to support entities that are both customer and supplier';
|
||||
COMMENT ON COLUMN customer_vendor_links.relationship_type IS 'supplier, reseller, or partner';
|
||||
Loading…
Reference in New Issue
Block a user