Compare commits
No commits in common. "8710f7f7986b2f0768b154bb2d2703a740745c23" and "70a01db4228648a21c458e7fc197425fd7268bc1" have entirely different histories.
8710f7f798
...
70a01db422
13
.env.example
13
.env.example
@ -70,19 +70,6 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
|||||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# ALSO Cloud Marketplace Integration (Optional)
|
|
||||||
# =====================================================
|
|
||||||
ALSO_ENABLED=false
|
|
||||||
ALSO_API_BASE_URL=
|
|
||||||
ALSO_API_KEY=
|
|
||||||
ALSO_API_SECRET=
|
|
||||||
ALSO_TIMEOUT_SECONDS=20
|
|
||||||
|
|
||||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede importer/sync
|
|
||||||
ALSO_READ_ONLY=true
|
|
||||||
ALSO_DRY_RUN=true
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# FedEx Integration (Optional)
|
# FedEx Integration (Optional)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@ -87,20 +87,6 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here
|
|||||||
ECONOMIC_READ_ONLY=true
|
ECONOMIC_READ_ONLY=true
|
||||||
ECONOMIC_DRY_RUN=true
|
ECONOMIC_DRY_RUN=true
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# ALSO Cloud Marketplace Integration - Production (Optional)
|
|
||||||
# =====================================================
|
|
||||||
ALSO_ENABLED=false
|
|
||||||
ALSO_API_BASE_URL=
|
|
||||||
ALSO_API_KEY=
|
|
||||||
ALSO_API_SECRET=
|
|
||||||
ALSO_TIMEOUT_SECONDS=20
|
|
||||||
|
|
||||||
# 🚨 SAFETY SWITCHES
|
|
||||||
# Start ALTID med begge sat til true i ny production deployment!
|
|
||||||
ALSO_READ_ONLY=true
|
|
||||||
ALSO_DRY_RUN=true
|
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# FedEx Integration - Production
|
# FedEx Integration - Production
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@ -273,10 +273,6 @@ class AnyDeskIdAdd(BaseModel):
|
|||||||
label: Optional[str] = None
|
label: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MenuPreferencesUpdate(BaseModel):
|
|
||||||
hidden_menu_keys: list[str] = []
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/anydesk-ids")
|
@router.get("/me/anydesk-ids")
|
||||||
async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)):
|
async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)):
|
||||||
rows = execute_query(
|
rows = execute_query(
|
||||||
@ -310,73 +306,3 @@ async def delete_my_anydesk_id(entry_id: int, current_user: dict = Depends(get_c
|
|||||||
if not rows:
|
if not rows:
|
||||||
raise HTTPException(status_code=404, detail="Ikke fundet")
|
raise HTTPException(status_code=404, detail="Ikke fundet")
|
||||||
return {"message": "Slettet"}
|
return {"message": "Slettet"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/menu-preferences")
|
|
||||||
async def get_my_menu_preferences(current_user: dict = Depends(get_current_user)):
|
|
||||||
"""Get current user's menu visibility preferences."""
|
|
||||||
try:
|
|
||||||
rows = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT menu_key
|
|
||||||
FROM user_menu_preferences
|
|
||||||
WHERE user_id = %s
|
|
||||||
AND visible = FALSE
|
|
||||||
ORDER BY menu_key ASC
|
|
||||||
""",
|
|
||||||
(current_user["id"],),
|
|
||||||
) or []
|
|
||||||
return {"hidden_menu_keys": [str(r.get("menu_key") or "") for r in rows if r.get("menu_key")]}
|
|
||||||
except Exception as exc:
|
|
||||||
if "user_menu_preferences" in str(exc):
|
|
||||||
logger.warning("⚠️ user_menu_preferences table not found; returning defaults")
|
|
||||||
return {"hidden_menu_keys": []}
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me/menu-preferences")
|
|
||||||
async def update_my_menu_preferences(
|
|
||||||
payload: MenuPreferencesUpdate,
|
|
||||||
current_user: dict = Depends(get_current_user)
|
|
||||||
):
|
|
||||||
"""Replace current user's hidden menu keys."""
|
|
||||||
keys = []
|
|
||||||
seen = set()
|
|
||||||
for raw in payload.hidden_menu_keys or []:
|
|
||||||
key = str(raw or "").strip().lower()
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
if len(key) > 120:
|
|
||||||
continue
|
|
||||||
if any(ch for ch in key if not (ch.isalnum() or ch in {"-", "_"})):
|
|
||||||
continue
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
keys.append(key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
execute_query(
|
|
||||||
"DELETE FROM user_menu_preferences WHERE user_id = %s",
|
|
||||||
(current_user["id"],),
|
|
||||||
)
|
|
||||||
|
|
||||||
for key in keys:
|
|
||||||
execute_query(
|
|
||||||
"""
|
|
||||||
INSERT INTO user_menu_preferences (user_id, menu_key, visible)
|
|
||||||
VALUES (%s, %s, FALSE)
|
|
||||||
ON CONFLICT (user_id, menu_key)
|
|
||||||
DO UPDATE SET visible = EXCLUDED.visible, updated_at = NOW()
|
|
||||||
""",
|
|
||||||
(current_user["id"], key),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"message": "Menuindstillinger gemt", "hidden_menu_keys": keys}
|
|
||||||
except Exception as exc:
|
|
||||||
if "user_menu_preferences" in str(exc):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=409,
|
|
||||||
detail="Menuindstillinger er ikke klar endnu. Kør migration 191 først.",
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|||||||
@ -507,218 +507,6 @@ async def get_related_contacts(contact_id: int):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts/{contact_id}/cases")
|
|
||||||
async def get_contact_cases(contact_id: int):
|
|
||||||
"""Get cases linked directly to a contact and cases from the contact's primary company."""
|
|
||||||
try:
|
|
||||||
contact_row = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
(
|
|
||||||
SELECT cu.id
|
|
||||||
FROM contact_companies cc
|
|
||||||
JOIN customers cu ON cu.id = cc.customer_id
|
|
||||||
WHERE cc.contact_id = c.id
|
|
||||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
|
||||||
LIMIT 1
|
|
||||||
) AS company_id,
|
|
||||||
(
|
|
||||||
SELECT cu.name
|
|
||||||
FROM contact_companies cc
|
|
||||||
JOIN customers cu ON cu.id = cc.customer_id
|
|
||||||
WHERE cc.contact_id = c.id
|
|
||||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
|
||||||
LIMIT 1
|
|
||||||
) AS company_name
|
|
||||||
FROM contacts c
|
|
||||||
WHERE c.id = %s
|
|
||||||
""",
|
|
||||||
(contact_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not contact_row:
|
|
||||||
raise HTTPException(status_code=404, detail="Contact not found")
|
|
||||||
|
|
||||||
company_id = contact_row[0].get("company_id")
|
|
||||||
|
|
||||||
contact_cases = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
s.titel,
|
|
||||||
s.status,
|
|
||||||
s.customer_id,
|
|
||||||
cu.name AS customer_name,
|
|
||||||
s.created_at,
|
|
||||||
s.updated_at
|
|
||||||
FROM sag_sager s
|
|
||||||
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
|
||||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
||||||
WHERE sk.contact_id = %s
|
|
||||||
AND s.deleted_at IS NULL
|
|
||||||
AND sk.deleted_at IS NULL
|
|
||||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
||||||
LIMIT 10
|
|
||||||
""",
|
|
||||||
(contact_id,),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
company_cases = []
|
|
||||||
if company_id:
|
|
||||||
company_cases = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
s.titel,
|
|
||||||
s.status,
|
|
||||||
s.customer_id,
|
|
||||||
cu.name AS customer_name,
|
|
||||||
s.created_at,
|
|
||||||
s.updated_at
|
|
||||||
FROM sag_sager s
|
|
||||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
||||||
WHERE s.customer_id = %s
|
|
||||||
AND s.deleted_at IS NULL
|
|
||||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
||||||
LIMIT 10
|
|
||||||
""",
|
|
||||||
(company_id,),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
return {
|
|
||||||
"contact": {
|
|
||||||
"id": contact_row[0]["id"],
|
|
||||||
"company_id": company_id,
|
|
||||||
"company_name": contact_row[0].get("company_name"),
|
|
||||||
},
|
|
||||||
"contact_cases": contact_cases,
|
|
||||||
"company_cases": company_cases,
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get cases for contact {contact_id}: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts/{contact_id}/case-context")
|
|
||||||
async def get_contact_case_context(contact_id: int):
|
|
||||||
"""Get case suggestions for a contact: contact cases, company cases and related contacts."""
|
|
||||||
try:
|
|
||||||
contact_rows = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.first_name,
|
|
||||||
c.last_name,
|
|
||||||
c.email,
|
|
||||||
c.phone,
|
|
||||||
c.mobile,
|
|
||||||
c.title,
|
|
||||||
c.department,
|
|
||||||
c.is_active,
|
|
||||||
c.created_at,
|
|
||||||
c.updated_at,
|
|
||||||
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
|
||||||
FROM contacts c
|
|
||||||
LEFT JOIN contact_companies cc ON c.id = cc.contact_id
|
|
||||||
LEFT JOIN customers cu ON cc.customer_id = cu.id
|
|
||||||
WHERE c.id = %s
|
|
||||||
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
|
|
||||||
""",
|
|
||||||
(contact_id,),
|
|
||||||
) or []
|
|
||||||
if not contact_rows:
|
|
||||||
return {"contact_cases": [], "company_cases": [], "related_contacts": []}
|
|
||||||
|
|
||||||
customer_ids = get_contact_customer_ids(contact_id)
|
|
||||||
placeholders = ",".join(["%s"] * len(customer_ids)) if customer_ids else ""
|
|
||||||
|
|
||||||
contact_cases = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
s.titel,
|
|
||||||
s.status,
|
|
||||||
s.customer_id,
|
|
||||||
cu.name AS customer_name,
|
|
||||||
s.created_at,
|
|
||||||
s.updated_at
|
|
||||||
FROM sag_sager s
|
|
||||||
INNER JOIN sag_kontakter sk ON s.id = sk.sag_id
|
|
||||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
||||||
WHERE sk.contact_id = %s
|
|
||||||
AND s.deleted_at IS NULL
|
|
||||||
AND sk.deleted_at IS NULL
|
|
||||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
||||||
LIMIT 10
|
|
||||||
""",
|
|
||||||
(contact_id,),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
company_cases = []
|
|
||||||
related_contacts = []
|
|
||||||
if customer_ids:
|
|
||||||
company_cases = execute_query(
|
|
||||||
f"""
|
|
||||||
SELECT
|
|
||||||
s.id,
|
|
||||||
s.titel,
|
|
||||||
s.status,
|
|
||||||
s.customer_id,
|
|
||||||
cu.name AS customer_name,
|
|
||||||
s.created_at,
|
|
||||||
s.updated_at
|
|
||||||
FROM sag_sager s
|
|
||||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
|
||||||
WHERE s.customer_id IN ({placeholders})
|
|
||||||
AND s.deleted_at IS NULL
|
|
||||||
ORDER BY COALESCE(s.updated_at, s.created_at) DESC
|
|
||||||
LIMIT 10
|
|
||||||
""",
|
|
||||||
tuple(customer_ids),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
related_contacts = execute_query(
|
|
||||||
f"""
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.first_name,
|
|
||||||
c.last_name,
|
|
||||||
c.email,
|
|
||||||
c.phone,
|
|
||||||
c.mobile,
|
|
||||||
c.title,
|
|
||||||
c.department,
|
|
||||||
c.is_active,
|
|
||||||
c.created_at,
|
|
||||||
c.updated_at,
|
|
||||||
ARRAY_AGG(DISTINCT cu.name ORDER BY cu.name) FILTER (WHERE cu.name IS NOT NULL) AS company_names
|
|
||||||
FROM contacts c
|
|
||||||
JOIN contact_companies cc ON c.id = cc.contact_id
|
|
||||||
JOIN customers cu ON cc.customer_id = cu.id
|
|
||||||
WHERE cc.customer_id IN ({placeholders})
|
|
||||||
AND c.id <> %s
|
|
||||||
AND c.is_active = TRUE
|
|
||||||
GROUP BY c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, c.department, c.is_active, c.created_at, c.updated_at
|
|
||||||
ORDER BY c.last_name, c.first_name
|
|
||||||
LIMIT 10
|
|
||||||
""",
|
|
||||||
tuple(customer_ids + [contact_id]),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
return {
|
|
||||||
"contact_cases": contact_cases,
|
|
||||||
"company_cases": company_cases,
|
|
||||||
"related_contacts": related_contacts,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get case context for contact {contact_id}: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts/{contact_id}/subscriptions")
|
@router.get("/contacts/{contact_id}/subscriptions")
|
||||||
async def get_contact_subscriptions(contact_id: int):
|
async def get_contact_subscriptions(contact_id: int):
|
||||||
customer_id = get_primary_customer_id(contact_id)
|
customer_id = get_primary_customer_id(contact_id)
|
||||||
|
|||||||
@ -315,15 +315,6 @@ class Settings(BaseSettings):
|
|||||||
FEDEX_BASE_URL: str = ""
|
FEDEX_BASE_URL: str = ""
|
||||||
FEDEX_TIMEOUT_SECONDS: int = 20
|
FEDEX_TIMEOUT_SECONDS: int = 20
|
||||||
|
|
||||||
# ALSO Cloud Marketplace Integration
|
|
||||||
ALSO_ENABLED: bool = False
|
|
||||||
ALSO_READ_ONLY: bool = True
|
|
||||||
ALSO_DRY_RUN: bool = True
|
|
||||||
ALSO_API_BASE_URL: str = ""
|
|
||||||
ALSO_API_KEY: str = ""
|
|
||||||
ALSO_API_SECRET: str = ""
|
|
||||||
ALSO_TIMEOUT_SECONDS: int = 20
|
|
||||||
|
|
||||||
# Bottom bar module
|
# Bottom bar module
|
||||||
BOTTOM_BAR_ENABLED: bool = False
|
BOTTOM_BAR_ENABLED: bool = False
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ Adapted from OmniSync for BMC Hub
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -1780,139 +1780,3 @@ async def get_subscription_billing_matrix(
|
|||||||
logger.error(f"❌ Error generating billing matrix: {e}")
|
logger.error(f"❌ Error generating billing matrix: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/customers/{customer_id}/acmp")
|
|
||||||
async def get_customer_acmp_overview(
|
|
||||||
customer_id: int,
|
|
||||||
months: int = Query(default=6, ge=1, le=24, description="Months for trend data"),
|
|
||||||
):
|
|
||||||
"""Return ACMP (ALSO Cloud Marketplace) detail and statistics for one customer."""
|
|
||||||
try:
|
|
||||||
customer = execute_query_single("SELECT id, name FROM customers WHERE id = %s", (customer_id,))
|
|
||||||
if not customer:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
|
||||||
|
|
||||||
table_check = execute_query_single("SELECT to_regclass('public.also_import_lines') AS table_name")
|
|
||||||
if not table_check or not table_check.get("table_name"):
|
|
||||||
return {
|
|
||||||
"customer_id": customer_id,
|
|
||||||
"customer_name": customer.get("name"),
|
|
||||||
"available": False,
|
|
||||||
"message": "ACMP data is not available yet (missing also_import_lines table)",
|
|
||||||
"summary": {},
|
|
||||||
"status_breakdown": [],
|
|
||||||
"products": [],
|
|
||||||
"monthly": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
summary = execute_query_single(
|
|
||||||
"""
|
|
||||||
WITH scoped AS (
|
|
||||||
SELECT *
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE matched_customer_id = %s
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
COUNT(*) AS line_count,
|
|
||||||
COUNT(DISTINCT COALESCE(material_number, product_name, 'ukendt')) AS distinct_products,
|
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
|
|
||||||
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
|
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
|
|
||||||
COUNT(*) FILTER (WHERE queue_status = 'ready_for_approval') AS pending_approvals,
|
|
||||||
COUNT(*) FILTER (WHERE queue_status = 'error') AS error_count,
|
|
||||||
COALESCE(
|
|
||||||
SUM(COALESCE(total_price, sales_price, 0)) FILTER (
|
|
||||||
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
|
|
||||||
),
|
|
||||||
0
|
|
||||||
) AS revenue_month,
|
|
||||||
COALESCE(
|
|
||||||
SUM(COALESCE(cost_amount, 0)) FILTER (
|
|
||||||
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
|
|
||||||
),
|
|
||||||
0
|
|
||||||
) AS cost_month
|
|
||||||
FROM scoped
|
|
||||||
""",
|
|
||||||
(customer_id,),
|
|
||||||
) or {}
|
|
||||||
|
|
||||||
status_breakdown = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT queue_status, COUNT(*)::INTEGER AS count
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE matched_customer_id = %s
|
|
||||||
GROUP BY queue_status
|
|
||||||
ORDER BY count DESC, queue_status ASC
|
|
||||||
""",
|
|
||||||
(customer_id,),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
products = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
COALESCE(material_number, 'N/A') AS material_number,
|
|
||||||
COALESCE(vendor, 'N/A') AS vendor,
|
|
||||||
COALESCE(product_name, 'Ukendt produkt') AS product_name,
|
|
||||||
COUNT(*)::INTEGER AS line_count,
|
|
||||||
COALESCE(SUM(COALESCE(billable_parameters, 1)), 0) AS quantity_total,
|
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
|
|
||||||
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
|
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
|
|
||||||
MAX(billing_start) AS last_billing_start,
|
|
||||||
MAX(created_at) AS last_seen_at
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE matched_customer_id = %s
|
|
||||||
GROUP BY COALESCE(material_number, 'N/A'), COALESCE(vendor, 'N/A'), COALESCE(product_name, 'Ukendt produkt')
|
|
||||||
ORDER BY revenue_total DESC, quantity_total DESC
|
|
||||||
LIMIT 200
|
|
||||||
""",
|
|
||||||
(customer_id,),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
monthly = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
TO_CHAR(date_trunc('month', COALESCE(billing_start::timestamp, created_at)), 'YYYY-MM') AS month,
|
|
||||||
COALESCE(SUM(COALESCE(billable_parameters, 1)), 0) AS quantity_total,
|
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
|
|
||||||
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
|
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
|
|
||||||
COUNT(*)::INTEGER AS lines
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE matched_customer_id = %s
|
|
||||||
AND date_trunc('month', COALESCE(billing_start::timestamp, created_at)) >= date_trunc('month', CURRENT_DATE) - ((%s - 1) * INTERVAL '1 month')
|
|
||||||
GROUP BY date_trunc('month', COALESCE(billing_start::timestamp, created_at))
|
|
||||||
ORDER BY month ASC
|
|
||||||
""",
|
|
||||||
(customer_id, months),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
response: Dict[str, Any] = {
|
|
||||||
"customer_id": customer_id,
|
|
||||||
"customer_name": customer.get("name"),
|
|
||||||
"available": True,
|
|
||||||
"summary": {
|
|
||||||
"line_count": int(summary.get("line_count") or 0),
|
|
||||||
"distinct_products": int(summary.get("distinct_products") or 0),
|
|
||||||
"revenue_total": summary.get("revenue_total") or 0,
|
|
||||||
"cost_total": summary.get("cost_total") or 0,
|
|
||||||
"margin_total": summary.get("margin_total") or 0,
|
|
||||||
"revenue_month": summary.get("revenue_month") or 0,
|
|
||||||
"cost_month": summary.get("cost_month") or 0,
|
|
||||||
"margin_month": (summary.get("revenue_month") or 0) - (summary.get("cost_month") or 0),
|
|
||||||
"pending_approvals": int(summary.get("pending_approvals") or 0),
|
|
||||||
"error_count": int(summary.get("error_count") or 0),
|
|
||||||
},
|
|
||||||
"status_breakdown": status_breakdown,
|
|
||||||
"products": products,
|
|
||||||
"monthly": monthly,
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("❌ Error fetching ACMP overview for customer %s: %s", customer_id, e)
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|||||||
@ -62,24 +62,6 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acmp-stat-card {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(15, 76, 117, 0.14);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acmp-stat-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.acmp-stat-value {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -243,123 +225,6 @@
|
|||||||
.btn-edit-customer:hover i {
|
.btn-edit-customer:hover i {
|
||||||
transform: rotate(-15deg) scale(1.1);
|
transform: rotate(-15deg) scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.contacts-panel {
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid rgba(15, 76, 117, 0.14);
|
|
||||||
border-radius: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 10px 28px rgba(15, 76, 117, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-toolbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.65rem;
|
|
||||||
margin-bottom: 0.9rem;
|
|
||||||
padding: 0.78rem 0.9rem;
|
|
||||||
background: linear-gradient(135deg, rgba(15, 76, 117, 0.08) 0%, rgba(15, 76, 117, 0.02) 100%);
|
|
||||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-search {
|
|
||||||
max-width: 460px;
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-search .form-control,
|
|
||||||
.contacts-search .input-group-text {
|
|
||||||
border-color: rgba(15, 76, 117, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table thead th {
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
background: rgba(15, 76, 117, 0.06);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
|
||||||
padding: 0.82rem 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table tbody td {
|
|
||||||
border-color: rgba(0, 0, 0, 0.06);
|
|
||||||
padding: 0.85rem 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .contact-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .contact-name-link {
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px solid transparent;
|
|
||||||
transition: color 0.15s ease, border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .contact-name-link:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: rgba(15, 76, 117, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .contact-email a {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .contact-number {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .contact-phone-wrap,
|
|
||||||
.contacts-table .contact-mobile-wrap {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .btn-voip {
|
|
||||||
border-color: rgba(25, 135, 84, 0.6);
|
|
||||||
color: #198754;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .contact-phone-wrap .btn,
|
|
||||||
.contacts-table .contact-mobile-wrap .btn {
|
|
||||||
padding: 0.18rem 0.52rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-table .primary-pill {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.contacts-toolbar {
|
|
||||||
padding: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contacts-search {
|
|
||||||
max-width: none;
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#contactsContainer {
|
|
||||||
min-width: 920px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -477,11 +342,6 @@
|
|||||||
<i class="bi bi-table"></i>Abonnements Matrix
|
<i class="bi bi-table"></i>Abonnements Matrix
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#acmp">
|
|
||||||
<i class="bi bi-cloud"></i>ACMP
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#locations">
|
<a class="nav-link" data-bs-toggle="tab" href="#locations">
|
||||||
<i class="bi bi-geo-alt"></i>Lokationer
|
<i class="bi bi-geo-alt"></i>Lokationer
|
||||||
@ -517,11 +377,6 @@
|
|||||||
<i class="bi bi-mic"></i>Samtaler
|
<i class="bi bi-mic"></i>Samtaler
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#drift">
|
|
||||||
<i class="bi bi-broadcast"></i>Drift
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -695,47 +550,31 @@
|
|||||||
<!-- Contacts Tab -->
|
<!-- Contacts Tab -->
|
||||||
<div class="tab-pane fade" id="contacts">
|
<div class="tab-pane fade" id="contacts">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
|
||||||
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
|
|
||||||
<small class="text-muted">Direkte kontaktoplysninger for denne kunde</small>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="showAddContactModal()">
|
<button class="btn btn-primary btn-sm" onclick="showAddContactModal()">
|
||||||
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="contacts-toolbar">
|
<div class="table-responsive" id="contactsContainer">
|
||||||
<div class="input-group contacts-search">
|
<table class="table table-hover align-middle mb-0">
|
||||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
<thead class="table-light">
|
||||||
<input type="search" class="form-control" id="contactsSearchInput" placeholder="Søg i navn, titel, email eller nummer">
|
<tr>
|
||||||
</div>
|
<th>Navn</th>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<th>Titel</th>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="contactsOnlyCallableToggle">Kun med nummer</button>
|
<th>Email</th>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="contactsClearFilters">Nulstil</button>
|
<th>Telefon</th>
|
||||||
<span class="badge text-bg-light border" id="contactsResultCount">0</span>
|
<th>Mobil</th>
|
||||||
</div>
|
<th>Primær</th>
|
||||||
</div>
|
</tr>
|
||||||
<div class="contacts-panel">
|
</thead>
|
||||||
<div class="table-responsive" id="contactsContainer">
|
<tbody>
|
||||||
<table class="table table-hover align-middle contacts-table">
|
<tr>
|
||||||
<thead>
|
<td colspan="6" class="text-center py-4">
|
||||||
<tr>
|
<div class="spinner-border text-primary"></div>
|
||||||
<th>Navn</th>
|
</td>
|
||||||
<th>Titel</th>
|
</tr>
|
||||||
<th>Email</th>
|
</tbody>
|
||||||
<th>Telefon</th>
|
</table>
|
||||||
<th>Mobil</th>
|
|
||||||
<th>Primær</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center py-4">
|
|
||||||
<div class="spinner-border text-primary"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -942,105 +781,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ACMP Tab -->
|
|
||||||
<div class="tab-pane fade" id="acmp">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="fw-bold mb-0">
|
|
||||||
<i class="bi bi-cloud me-2"></i>ACMP Detaljer
|
|
||||||
<small class="text-muted fw-normal">(ALSO Cloud Marketplace)</small>
|
|
||||||
</h5>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="loadAcmpOverview()" title="Opdater ACMP-data">
|
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="acmpLoading" class="text-center py-5">
|
|
||||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
|
||||||
<p class="text-muted mt-2">Henter ACMP-data...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="acmpEmpty" class="text-center py-5" style="display:none;">
|
|
||||||
<p class="text-muted mb-0">Ingen ACMP-data for denne kunde endnu</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="acmpContainer" style="display:none;">
|
|
||||||
<div class="row g-3 mb-3">
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<div class="acmp-stat-card">
|
|
||||||
<div class="acmp-stat-label">Månedens omsætning</div>
|
|
||||||
<div class="acmp-stat-value" id="acmpRevenueMonth">0 kr.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<div class="acmp-stat-card">
|
|
||||||
<div class="acmp-stat-label">Månedens dækningsbidrag</div>
|
|
||||||
<div class="acmp-stat-value" id="acmpMarginMonth">0 kr.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<div class="acmp-stat-card">
|
|
||||||
<div class="acmp-stat-label">Produkter</div>
|
|
||||||
<div class="acmp-stat-value" id="acmpDistinctProducts">0</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-3 col-md-6">
|
|
||||||
<div class="acmp-stat-card">
|
|
||||||
<div class="acmp-stat-label">Ventende godkendelser</div>
|
|
||||||
<div class="acmp-stat-value" id="acmpPendingApprovals">0</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="info-card">
|
|
||||||
<h6 class="fw-bold mb-3">Statusfordeling</h6>
|
|
||||||
<div id="acmpStatusBreakdown" class="d-flex flex-wrap gap-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="info-card">
|
|
||||||
<h6 class="fw-bold mb-3">Månedlig udvikling (seneste 6 mdr)</h6>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Måned</th>
|
|
||||||
<th class="text-end">Oms.</th>
|
|
||||||
<th class="text-end">DB</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="acmpMonthlyRows"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h6 class="fw-bold mb-0">Produkter</h6>
|
|
||||||
<small class="text-muted">Top 200 pr. omsætning</small>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm table-hover mb-0">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Produkt</th>
|
|
||||||
<th>Materiale</th>
|
|
||||||
<th>Vendor</th>
|
|
||||||
<th class="text-end">Antal</th>
|
|
||||||
<th class="text-end">Omsætning</th>
|
|
||||||
<th class="text-end">DB</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="acmpProductsRows"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Locations Tab -->
|
<!-- Locations Tab -->
|
||||||
<div class="tab-pane fade" id="locations">
|
<div class="tab-pane fade" id="locations">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
@ -1205,49 +945,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Drift Tab -->
|
|
||||||
<div class="tab-pane fade" id="drift">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h5 class="fw-bold mb-0">Drift historik</h5>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" onclick="loadCustomerDrift()">
|
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-6 col-lg-3">
|
|
||||||
<div class="card border-0 shadow-sm h-100"><div class="card-body"><div class="small text-muted">Aktive</div><div class="fs-4 fw-bold" id="customerDriftActive">0</div></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-lg-3">
|
|
||||||
<div class="card border-0 shadow-sm h-100"><div class="card-body"><div class="small text-muted">Kritiske</div><div class="fs-4 fw-bold text-danger" id="customerDriftCritical">0</div></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-lg-3">
|
|
||||||
<div class="card border-0 shadow-sm h-100"><div class="card-body"><div class="small text-muted">Godkendte</div><div class="fs-4 fw-bold text-warning" id="customerDriftAcknowledged">0</div></div></div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-lg-3">
|
|
||||||
<div class="card border-0 shadow-sm h-100"><div class="card-body"><div class="small text-muted">Løste</div><div class="fs-4 fw-bold text-success" id="customerDriftResolved">0</div></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Severity</th>
|
|
||||||
<th>Enhed</th>
|
|
||||||
<th>Besked</th>
|
|
||||||
<th>Start</th>
|
|
||||||
<th>Kilde</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="customerDriftEventsBody">
|
|
||||||
<tr><td colspan="6" class="text-muted">Åbn fanen for at hente drift-data...</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1781,14 +1478,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load drift when tab is shown
|
|
||||||
const driftTab = document.querySelector('a[href="#drift"]');
|
|
||||||
if (driftTab) {
|
|
||||||
driftTab.addEventListener('shown.bs.tab', () => {
|
|
||||||
loadCustomerDrift();
|
|
||||||
}, { once: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load Nextcloud status when tab is shown
|
// Load Nextcloud status when tab is shown
|
||||||
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
|
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
|
||||||
if (nextcloudTab) {
|
if (nextcloudTab) {
|
||||||
@ -2080,13 +1769,12 @@ function displayCustomer(customer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCustomerCallNumber(number) {
|
function renderCustomerCallNumber(number) {
|
||||||
const clean = normalizePhoneValue(number);
|
const clean = String(number || '').trim();
|
||||||
if (!clean) return '-';
|
if (!clean) return '-';
|
||||||
return `
|
return `
|
||||||
<div class="d-flex gap-2 align-items-center justify-content-end flex-wrap">
|
<div class="d-flex gap-2 align-items-center justify-content-end flex-wrap">
|
||||||
<span>${escapeHtml(clean)}</span>
|
<span>${escapeHtml(clean)}</span>
|
||||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="customerDetailCallViaYealink('${escapeHtml(clean)}')">Ring op</button>
|
<button type="button" class="btn btn-sm btn-outline-success" onclick="customerDetailCallViaYealink('${escapeHtml(clean)}')">Ring op</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(clean)}', '', null)">SMS</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -2107,8 +1795,8 @@ async function ensureCustomerDetailCurrentUserId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function customerDetailCallViaYealink(number) {
|
async function customerDetailCallViaYealink(number) {
|
||||||
const clean = normalizePhoneValue(number);
|
const clean = String(number || '').trim();
|
||||||
if (!clean) {
|
if (!clean || clean === '-') {
|
||||||
alert('Intet gyldigt nummer at ringe til');
|
alert('Intet gyldigt nummer at ringe til');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2898,196 +2586,11 @@ function displayUtilityCompany(payload) {
|
|||||||
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
|
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePhoneValue(value) {
|
|
||||||
const clean = String(value || '').trim();
|
|
||||||
if (!clean) return '';
|
|
||||||
|
|
||||||
const lowered = clean.toLowerCase();
|
|
||||||
if (clean === '-' || clean === '—' || lowered === 'n/a' || lowered === 'null' || lowered === 'none') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require at least one digit to treat value as a callable/SMS-capable number.
|
|
||||||
if (!/[0-9]/.test(clean)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let customerContactsData = [];
|
|
||||||
let contactsSearchQuery = '';
|
|
||||||
let contactsOnlyCallable = false;
|
|
||||||
let contactsFilterControlsInitialized = false;
|
|
||||||
|
|
||||||
function getContactDisplayName(contact) {
|
|
||||||
const firstName = String(contact.first_name || '').trim();
|
|
||||||
const lastName = String(contact.last_name || '').trim();
|
|
||||||
return [firstName, lastName].filter(Boolean).join(' ') || String(contact.name || '').trim() || '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContactPhoneValue(contact) {
|
|
||||||
return normalizePhoneValue(contact.phone);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContactMobileValue(contact) {
|
|
||||||
return normalizePhoneValue(contact.mobile || contact.mobile_phone);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContactsRows(contacts) {
|
|
||||||
return contacts.map(contact => {
|
|
||||||
const displayName = getContactDisplayName(contact);
|
|
||||||
const contactId = Number(contact.id) || null;
|
|
||||||
const mobileValue = getContactMobileValue(contact);
|
|
||||||
const phoneValue = getContactPhoneValue(contact);
|
|
||||||
const titleValue = String(contact.title || contact.role || '').trim();
|
|
||||||
const nameCell = contactId
|
|
||||||
? `<a class="contact-name-link" href="/contacts/${contactId}">${escapeHtml(displayName)}</a>`
|
|
||||||
: escapeHtml(displayName);
|
|
||||||
|
|
||||||
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
|
|
||||||
const phone = phoneValue
|
|
||||||
? `<div class="contact-phone-wrap"><span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></span><button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${escapeHtml(phoneValue)}"><i class="bi bi-telephone-outbound me-1"></i>VOIP</button></div>`
|
|
||||||
: '—';
|
|
||||||
const mobile = mobileValue
|
|
||||||
? `<div class="contact-mobile-wrap"><span class="contact-number"><a href="tel:${mobileValue}">${escapeHtml(mobileValue)}</a></span><button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${escapeHtml(mobileValue)}"><i class="bi bi-telephone-outbound me-1"></i>VOIP</button><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(mobileValue)}', '${escapeHtml(displayName)}', ${contact.id || 'null'})">SMS</button></div>`
|
|
||||||
: '—';
|
|
||||||
const title = titleValue ? escapeHtml(titleValue) : '—';
|
|
||||||
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary primary-pill">Primær</span>' : '—';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td class="contact-name">${nameCell}</td>
|
|
||||||
<td>${title}</td>
|
|
||||||
<td class="contact-email">${email}</td>
|
|
||||||
<td class="contact-number">${phone}</td>
|
|
||||||
<td>${mobile}</td>
|
|
||||||
<td>${primaryBadge}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilteredContacts() {
|
|
||||||
const query = contactsSearchQuery.toLowerCase();
|
|
||||||
return customerContactsData.filter(contact => {
|
|
||||||
const displayName = getContactDisplayName(contact);
|
|
||||||
const phoneValue = getContactPhoneValue(contact);
|
|
||||||
const mobileValue = getContactMobileValue(contact);
|
|
||||||
const searchable = [
|
|
||||||
displayName,
|
|
||||||
String(contact.title || contact.role || ''),
|
|
||||||
String(contact.email || ''),
|
|
||||||
phoneValue,
|
|
||||||
mobileValue
|
|
||||||
].join(' ').toLowerCase();
|
|
||||||
|
|
||||||
const matchesQuery = !query || searchable.includes(query);
|
|
||||||
const matchesCallable = !contactsOnlyCallable || Boolean(phoneValue || mobileValue);
|
|
||||||
return matchesQuery && matchesCallable;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateContactsResultCount(current, total) {
|
|
||||||
const countEl = document.getElementById('contactsResultCount');
|
|
||||||
if (!countEl) return;
|
|
||||||
countEl.textContent = `${current} / ${total}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindContactVoipButtons() {
|
|
||||||
document.querySelectorAll('.js-contact-voip').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const number = String(btn.getAttribute('data-number') || '').trim();
|
|
||||||
customerDetailCallViaYealink(number);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderContactsFromState() {
|
|
||||||
const container = document.getElementById('contactsContainer');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const renderContactsTable = (bodyHtml) => `
|
|
||||||
<table class="table table-hover align-middle contacts-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Navn</th>
|
|
||||||
<th>Titel</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Telefon</th>
|
|
||||||
<th>Mobil</th>
|
|
||||||
<th>Primær</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
${bodyHtml}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (!customerContactsData.length) {
|
|
||||||
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
|
|
||||||
updateContactsResultCount(0, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredContacts = getFilteredContacts();
|
|
||||||
updateContactsResultCount(filteredContacts.length, customerContactsData.length);
|
|
||||||
|
|
||||||
if (!filteredContacts.length) {
|
|
||||||
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter matcher søgningen</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = renderContactsTable(buildContactsRows(filteredContacts));
|
|
||||||
bindContactVoipButtons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function initContactsFilterControls() {
|
|
||||||
if (contactsFilterControlsInitialized) return;
|
|
||||||
|
|
||||||
const searchInput = document.getElementById('contactsSearchInput');
|
|
||||||
const callableBtn = document.getElementById('contactsOnlyCallableToggle');
|
|
||||||
const clearBtn = document.getElementById('contactsClearFilters');
|
|
||||||
|
|
||||||
if (!searchInput || !callableBtn || !clearBtn) return;
|
|
||||||
|
|
||||||
const syncCallableBtn = () => {
|
|
||||||
callableBtn.classList.toggle('btn-outline-secondary', !contactsOnlyCallable);
|
|
||||||
callableBtn.classList.toggle('btn-primary', contactsOnlyCallable);
|
|
||||||
};
|
|
||||||
|
|
||||||
searchInput.addEventListener('input', (event) => {
|
|
||||||
contactsSearchQuery = String(event.target.value || '').trim();
|
|
||||||
renderContactsFromState();
|
|
||||||
});
|
|
||||||
|
|
||||||
callableBtn.addEventListener('click', () => {
|
|
||||||
contactsOnlyCallable = !contactsOnlyCallable;
|
|
||||||
syncCallableBtn();
|
|
||||||
renderContactsFromState();
|
|
||||||
});
|
|
||||||
|
|
||||||
clearBtn.addEventListener('click', () => {
|
|
||||||
contactsSearchQuery = '';
|
|
||||||
contactsOnlyCallable = false;
|
|
||||||
searchInput.value = '';
|
|
||||||
syncCallableBtn();
|
|
||||||
renderContactsFromState();
|
|
||||||
});
|
|
||||||
|
|
||||||
syncCallableBtn();
|
|
||||||
contactsFilterControlsInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadContacts() {
|
async function loadContacts() {
|
||||||
const container = document.getElementById('contactsContainer');
|
const container = document.getElementById('contactsContainer');
|
||||||
|
container.innerHTML = `
|
||||||
initContactsFilterControls();
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
const renderContactsTable = (bodyHtml) => `
|
|
||||||
<table class="table table-hover align-middle contacts-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th>Navn</th>
|
<th>Navn</th>
|
||||||
<th>Titel</th>
|
<th>Titel</th>
|
||||||
@ -3098,30 +2601,64 @@ async function loadContacts() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${bodyHtml}
|
<tr>
|
||||||
|
<td colspan="6" class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
${renderContactsTable(`
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="text-center py-4">
|
|
||||||
<div class="spinner-border text-primary"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`)}
|
|
||||||
`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
||||||
const contacts = await response.json();
|
const contacts = await response.json();
|
||||||
customerContactsData = Array.isArray(contacts) ? contacts : [];
|
|
||||||
renderContactsFromState();
|
if (!contacts || contacts.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = contacts.map(contact => {
|
||||||
|
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
|
||||||
|
const phone = contact.phone ? `<a href="tel:${contact.phone}">${escapeHtml(contact.phone)}</a>` : '—';
|
||||||
|
const mobile = contact.mobile
|
||||||
|
? `<div class="d-flex align-items-center gap-2 flex-wrap"><a href="tel:${contact.mobile}">${escapeHtml(contact.mobile)}</a><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(contact.mobile)}', '${escapeHtml(contact.name || '')}', ${contact.id || 'null'})">SMS</button></div>`
|
||||||
|
: '—';
|
||||||
|
const title = contact.title ? escapeHtml(contact.title) : '—';
|
||||||
|
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold">${escapeHtml(contact.name || '-') }</td>
|
||||||
|
<td>${title}</td>
|
||||||
|
<td>${email}</td>
|
||||||
|
<td>${phone}</td>
|
||||||
|
<td>${mobile}</td>
|
||||||
|
<td>${primaryBadge}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Telefon</th>
|
||||||
|
<th>Mobil</th>
|
||||||
|
<th>Primær</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load contacts:', error);
|
console.error('Failed to load contacts:', error);
|
||||||
customerContactsData = [];
|
|
||||||
updateContactsResultCount(0, 0);
|
|
||||||
container.innerHTML = '<div class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
|
container.innerHTML = '<div class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4109,61 +3646,6 @@ async function loadActivity() {
|
|||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCustomerDrift() {
|
|
||||||
const body = document.getElementById('customerDriftEventsBody');
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
body.innerHTML = '<tr><td colspan="6" class="text-muted">Indlæser drift-data...</td></tr>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [summaryRes, eventsRes] = await Promise.all([
|
|
||||||
fetch(`/api/v1/drift/customers/${customerId}/summary`, { credentials: 'include' }),
|
|
||||||
fetch(`/api/v1/drift/customers/${customerId}/events?limit=100`, { credentials: 'include' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!summaryRes.ok) throw new Error(`Summary HTTP ${summaryRes.status}`);
|
|
||||||
if (!eventsRes.ok) throw new Error(`Events HTTP ${eventsRes.status}`);
|
|
||||||
|
|
||||||
const summary = await summaryRes.json();
|
|
||||||
const events = await eventsRes.json();
|
|
||||||
|
|
||||||
document.getElementById('customerDriftActive').textContent = Number(summary.active || 0);
|
|
||||||
document.getElementById('customerDriftCritical').textContent = Number(summary.critical || 0);
|
|
||||||
document.getElementById('customerDriftAcknowledged').textContent = Number(summary.acknowledged || 0);
|
|
||||||
document.getElementById('customerDriftResolved').textContent = Number(summary.resolved || 0);
|
|
||||||
|
|
||||||
if (!Array.isArray(events) || events.length === 0) {
|
|
||||||
body.innerHTML = '<tr><td colspan="6" class="text-muted">Ingen drift-hændelser fundet for kunden.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.innerHTML = events.map(event => {
|
|
||||||
const status = String(event.status || 'new');
|
|
||||||
const statusClass = status === 'active' ? 'danger' : (status === 'acknowledged' ? 'warning' : 'success');
|
|
||||||
const severity = String(event.severity || 'info');
|
|
||||||
const severityClass = severity === 'critical' ? 'danger' : (severity === 'warning' ? 'warning' : 'secondary');
|
|
||||||
const startText = event.started ? new Date(event.started).toLocaleString('da-DK') : '-';
|
|
||||||
const sourceLink = event.source_link
|
|
||||||
? `<a href="${event.source_link}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right"></i></a>`
|
|
||||||
: '-';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td><span class="badge bg-${statusClass}">${status}</span></td>
|
|
||||||
<td><span class="badge bg-${severityClass}">${severity}</span></td>
|
|
||||||
<td>${event.device || '-'}</td>
|
|
||||||
<td class="text-muted">${event.message || '-'}</td>
|
|
||||||
<td>${startText}</td>
|
|
||||||
<td>${sourceLink}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load customer drift:', error);
|
|
||||||
body.innerHTML = '<tr><td colspan="6" class="text-danger">Kunne ikke indlæse drift-data.</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCustomerKontakt() {
|
async function loadCustomerKontakt() {
|
||||||
const container = document.getElementById('customerKontaktContainer');
|
const container = document.getElementById('customerKontaktContainer');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@ -5154,80 +4636,6 @@ function displayInternalComment(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAcmpOverview() {
|
|
||||||
const loading = document.getElementById('acmpLoading');
|
|
||||||
const container = document.getElementById('acmpContainer');
|
|
||||||
const empty = document.getElementById('acmpEmpty');
|
|
||||||
|
|
||||||
loading.style.display = 'block';
|
|
||||||
container.style.display = 'none';
|
|
||||||
empty.style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/v1/customers/${customerId}/acmp?months=6`);
|
|
||||||
const payload = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(payload.detail || 'Kunne ikke hente ACMP-data');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.available || (!payload.products || payload.products.length === 0)) {
|
|
||||||
empty.style.display = 'block';
|
|
||||||
loading.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderAcmpOverview(payload);
|
|
||||||
container.style.display = 'block';
|
|
||||||
loading.style.display = 'none';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load ACMP overview:', error);
|
|
||||||
loading.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-circle me-2"></i>${error.message}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAcmpOverview(payload) {
|
|
||||||
const summary = payload.summary || {};
|
|
||||||
|
|
||||||
document.getElementById('acmpRevenueMonth').textContent = formatDKK(Number(summary.revenue_month || 0));
|
|
||||||
document.getElementById('acmpMarginMonth').textContent = formatDKK(Number(summary.margin_month || 0));
|
|
||||||
document.getElementById('acmpDistinctProducts').textContent = Number(summary.distinct_products || 0).toLocaleString('da-DK');
|
|
||||||
document.getElementById('acmpPendingApprovals').textContent = Number(summary.pending_approvals || 0).toLocaleString('da-DK');
|
|
||||||
|
|
||||||
const statusContainer = document.getElementById('acmpStatusBreakdown');
|
|
||||||
const statusRows = payload.status_breakdown || [];
|
|
||||||
statusContainer.innerHTML = statusRows.length > 0
|
|
||||||
? statusRows.map(row => `<span class="badge text-bg-light border">${escapeHtml(row.queue_status)}: ${Number(row.count || 0).toLocaleString('da-DK')}</span>`).join('')
|
|
||||||
: '<span class="text-muted">Ingen statusdata</span>';
|
|
||||||
|
|
||||||
const monthlyRows = document.getElementById('acmpMonthlyRows');
|
|
||||||
const monthly = payload.monthly || [];
|
|
||||||
monthlyRows.innerHTML = monthly.length > 0
|
|
||||||
? monthly.map(row => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(row.month || '-')}</td>
|
|
||||||
<td class="text-end">${formatDKK(Number(row.revenue_total || 0))}</td>
|
|
||||||
<td class="text-end">${formatDKK(Number(row.margin_total || 0))}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('')
|
|
||||||
: '<tr><td colspan="3" class="text-center text-muted">Ingen månedlige data</td></tr>';
|
|
||||||
|
|
||||||
const productsRows = document.getElementById('acmpProductsRows');
|
|
||||||
const products = payload.products || [];
|
|
||||||
productsRows.innerHTML = products.length > 0
|
|
||||||
? products.map(row => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(row.product_name || '-')}</td>
|
|
||||||
<td>${escapeHtml(row.material_number || '-')}</td>
|
|
||||||
<td>${escapeHtml(row.vendor || '-')}</td>
|
|
||||||
<td class="text-end">${Number(row.quantity_total || 0).toLocaleString('da-DK')}</td>
|
|
||||||
<td class="text-end">${formatDKK(Number(row.revenue_total || 0))}</td>
|
|
||||||
<td class="text-end">${formatDKK(Number(row.margin_total || 0))}</td>
|
|
||||||
</tr>
|
|
||||||
`).join('')
|
|
||||||
: '<tr><td colspan="6" class="text-center text-muted">Ingen produkter fundet</td></tr>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function editInternalComment() {
|
function editInternalComment() {
|
||||||
const commentText = document.getElementById('commentText').textContent;
|
const commentText = document.getElementById('commentText').textContent;
|
||||||
const commentInput = document.getElementById('internalCommentInput');
|
const commentInput = document.getElementById('internalCommentInput');
|
||||||
@ -5442,17 +4850,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const acmpTab = document.querySelector('a[href="#acmp"]');
|
|
||||||
if (acmpTab) {
|
|
||||||
acmpTab.addEventListener('shown.bs.tab', () => {
|
|
||||||
const loading = document.getElementById('acmpLoading');
|
|
||||||
const container = document.getElementById('acmpContainer');
|
|
||||||
if (loading && container && loading.style.display !== 'none' && container.style.display === 'none') {
|
|
||||||
loadAcmpOverview();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,114 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, Request
|
|
||||||
|
|
||||||
from app.modules.also.backend.service import also_service
|
|
||||||
from app.modules.also.models.schemas import (
|
|
||||||
AlsoApproveResult,
|
|
||||||
AlsoCompanyMappingUpsert,
|
|
||||||
AlsoDifferenceItem,
|
|
||||||
AlsoDashboardSummaryResponse,
|
|
||||||
AlsoImportJobCreate,
|
|
||||||
AlsoImportJobResponse,
|
|
||||||
AlsoImportLinesRequest,
|
|
||||||
AlsoProductMappingUpsert,
|
|
||||||
AlsoQueueApproveRequest,
|
|
||||||
AlsoQueueLineResponse,
|
|
||||||
AlsoQueueProcessRequest,
|
|
||||||
AlsoQueueProcessResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _user_id_from_request(request: Request) -> Optional[int]:
|
|
||||||
raw_user_id = getattr(request.state, "user_id", None)
|
|
||||||
if raw_user_id is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(raw_user_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/also/config")
|
|
||||||
async def also_config() -> dict:
|
|
||||||
return also_service.get_config()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/also/import-jobs", response_model=AlsoImportJobResponse)
|
|
||||||
async def create_import_job(payload: AlsoImportJobCreate, request: Request):
|
|
||||||
return also_service.create_import_job(payload, _user_id_from_request(request))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/also/import-jobs", response_model=list[AlsoImportJobResponse])
|
|
||||||
async def list_import_jobs(
|
|
||||||
status: Optional[str] = Query(default=None),
|
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
|
||||||
):
|
|
||||||
return also_service.list_import_jobs(status=status, limit=limit)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/also/import-jobs/{job_id}", response_model=AlsoImportJobResponse)
|
|
||||||
async def get_import_job(job_id: int):
|
|
||||||
return also_service.get_import_job(job_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/also/import-jobs/{job_id}/lines")
|
|
||||||
async def import_lines(job_id: int, payload: AlsoImportLinesRequest):
|
|
||||||
return also_service.import_lines(job_id=job_id, payload=payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/also/queue", response_model=list[AlsoQueueLineResponse])
|
|
||||||
async def get_queue(
|
|
||||||
status: Optional[str] = Query(default=None),
|
|
||||||
limit: int = Query(default=200, ge=1, le=1000),
|
|
||||||
):
|
|
||||||
return also_service.get_queue(status=status, limit=limit)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/also/dashboard/summary", response_model=AlsoDashboardSummaryResponse)
|
|
||||||
async def get_dashboard_summary():
|
|
||||||
return also_service.get_dashboard_summary()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/also/dashboard/differences", response_model=list[AlsoDifferenceItem])
|
|
||||||
async def get_dashboard_differences(limit: int = Query(default=50, ge=1, le=200)):
|
|
||||||
return also_service.get_monthly_differences(limit=limit)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/also/queue/run-matching", response_model=AlsoQueueProcessResult)
|
|
||||||
async def run_matching(payload: AlsoQueueProcessRequest):
|
|
||||||
return also_service.run_matching(
|
|
||||||
import_job_id=payload.import_job_id,
|
|
||||||
line_ids=payload.line_ids,
|
|
||||||
limit=payload.limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/also/queue/run-validation", response_model=AlsoQueueProcessResult)
|
|
||||||
async def run_validation(payload: AlsoQueueProcessRequest):
|
|
||||||
return also_service.run_validation(
|
|
||||||
import_job_id=payload.import_job_id,
|
|
||||||
line_ids=payload.line_ids,
|
|
||||||
limit=payload.limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/also/queue/approve", response_model=AlsoApproveResult)
|
|
||||||
async def approve_queue(payload: AlsoQueueApproveRequest, request: Request):
|
|
||||||
return also_service.approve_lines_to_drafts(
|
|
||||||
import_job_id=payload.import_job_id,
|
|
||||||
line_ids=payload.line_ids,
|
|
||||||
approved_by_user_id=_user_id_from_request(request),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/also/mappings/company")
|
|
||||||
async def upsert_company_mapping(payload: AlsoCompanyMappingUpsert):
|
|
||||||
return also_service.upsert_company_mapping(payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/also/mappings/product")
|
|
||||||
async def upsert_product_mapping(payload: AlsoProductMappingUpsert):
|
|
||||||
return also_service.upsert_product_mapping(payload)
|
|
||||||
@ -1,907 +0,0 @@
|
|||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.database import execute_query, execute_query_single, table_has_column
|
|
||||||
from app.modules.also.models.schemas import (
|
|
||||||
AlsoCompanyMappingUpsert,
|
|
||||||
AlsoImportJobCreate,
|
|
||||||
AlsoImportLinesRequest,
|
|
||||||
AlsoProductMappingUpsert,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_default(value: Any) -> Any:
|
|
||||||
if isinstance(value, Decimal):
|
|
||||||
return float(value)
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return value.isoformat()
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _json_dumps(value: Any) -> str:
|
|
||||||
return json.dumps(value, ensure_ascii=False, default=_json_default)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalized_text(value: Optional[Any]) -> str:
|
|
||||||
return str(value or "").strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _to_decimal(value: Any, default: Decimal = Decimal("0")) -> Decimal:
|
|
||||||
if value is None or value == "":
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return Decimal(str(value))
|
|
||||||
except Exception:
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_cvr(vat_value: Optional[str]) -> str:
|
|
||||||
digits = re.sub(r"[^0-9]", "", _normalized_text(vat_value))
|
|
||||||
return digits
|
|
||||||
|
|
||||||
|
|
||||||
def _line_hash(job_id: int, line_payload: Dict[str, Any]) -> str:
|
|
||||||
source_ref = _normalized_text(line_payload.get("source_line_ref"))
|
|
||||||
if source_ref:
|
|
||||||
key = f"{job_id}|{source_ref}"
|
|
||||||
else:
|
|
||||||
key = "|".join(
|
|
||||||
[
|
|
||||||
str(job_id),
|
|
||||||
_normalized_text(line_payload.get("company")).lower(),
|
|
||||||
_normalized_text(line_payload.get("customer_id")),
|
|
||||||
_normalized_text(line_payload.get("material_number")),
|
|
||||||
_normalized_text(line_payload.get("vendor")).lower(),
|
|
||||||
_normalized_text(line_payload.get("billing_start")),
|
|
||||||
_normalized_text(line_payload.get("total_price")),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoService:
|
|
||||||
@property
|
|
||||||
def enabled(self) -> bool:
|
|
||||||
return bool(settings.ALSO_ENABLED)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def read_only(self) -> bool:
|
|
||||||
return bool(settings.ALSO_READ_ONLY)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dry_run(self) -> bool:
|
|
||||||
return bool(settings.ALSO_DRY_RUN)
|
|
||||||
|
|
||||||
def _assert_enabled(self) -> None:
|
|
||||||
if not self.enabled:
|
|
||||||
raise HTTPException(status_code=503, detail="ALSO integration is disabled")
|
|
||||||
|
|
||||||
def get_config(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"enabled": self.enabled,
|
|
||||||
"read_only": self.read_only,
|
|
||||||
"dry_run": self.dry_run,
|
|
||||||
"api_base_url": settings.ALSO_API_BASE_URL,
|
|
||||||
"preferred_import_order": ["api", "xml", "json_export", "xml_export", "csv"],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _resolve_customer_match(self, line: Dict[str, Any]) -> Optional[int]:
|
|
||||||
also_company_id = _normalized_text(line.get("also_company_id"))
|
|
||||||
if also_company_id:
|
|
||||||
mapped = execute_query_single(
|
|
||||||
"""
|
|
||||||
SELECT customer_id
|
|
||||||
FROM also_company_mapping
|
|
||||||
WHERE also_company_id = %s AND is_active = true
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(also_company_id,),
|
|
||||||
)
|
|
||||||
if mapped and mapped.get("customer_id"):
|
|
||||||
return int(mapped["customer_id"])
|
|
||||||
|
|
||||||
external_customer_id = _normalized_text(line.get("customer_id"))
|
|
||||||
if external_customer_id:
|
|
||||||
row = execute_query_single(
|
|
||||||
"SELECT id FROM customers WHERE vtiger_id = %s LIMIT 1",
|
|
||||||
(external_customer_id,),
|
|
||||||
)
|
|
||||||
if row and row.get("id"):
|
|
||||||
return int(row["id"])
|
|
||||||
|
|
||||||
vat = _normalize_cvr(line.get("vat"))
|
|
||||||
if vat:
|
|
||||||
cvr_column = "cvr_number" if table_has_column("customers", "cvr_number") else None
|
|
||||||
if cvr_column:
|
|
||||||
row = execute_query_single(
|
|
||||||
f"SELECT id FROM customers WHERE REPLACE(REPLACE(COALESCE({cvr_column}, ''), ' ', ''), '-', '') = %s LIMIT 1",
|
|
||||||
(vat,),
|
|
||||||
)
|
|
||||||
if row and row.get("id"):
|
|
||||||
return int(row["id"])
|
|
||||||
|
|
||||||
company_name = _normalized_text(line.get("company"))
|
|
||||||
if company_name:
|
|
||||||
row = execute_query_single(
|
|
||||||
"SELECT id FROM customers WHERE LOWER(name) = LOWER(%s) LIMIT 1",
|
|
||||||
(company_name,),
|
|
||||||
)
|
|
||||||
if row and row.get("id"):
|
|
||||||
return int(row["id"])
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _resolve_product_match(self, line: Dict[str, Any]) -> Optional[int]:
|
|
||||||
material_number = _normalized_text(line.get("material_number"))
|
|
||||||
vendor = _normalized_text(line.get("vendor"))
|
|
||||||
product_name = _normalized_text(line.get("product_name"))
|
|
||||||
|
|
||||||
if material_number and vendor:
|
|
||||||
mapped = execute_query_single(
|
|
||||||
"""
|
|
||||||
SELECT hub_product_id
|
|
||||||
FROM also_product_mapping
|
|
||||||
WHERE material_number = %s
|
|
||||||
AND LOWER(vendor) = LOWER(%s)
|
|
||||||
AND is_active = true
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(material_number, vendor),
|
|
||||||
)
|
|
||||||
if mapped and mapped.get("hub_product_id"):
|
|
||||||
return int(mapped["hub_product_id"])
|
|
||||||
|
|
||||||
if material_number:
|
|
||||||
supplier = execute_query_single(
|
|
||||||
"""
|
|
||||||
SELECT product_id
|
|
||||||
FROM product_suppliers
|
|
||||||
WHERE supplier_sku = %s
|
|
||||||
AND (
|
|
||||||
%s = '' OR LOWER(COALESCE(supplier_name, '')) = LOWER(%s) OR LOWER(COALESCE(supplier_code, '')) = LOWER(%s)
|
|
||||||
)
|
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(material_number, vendor, vendor, vendor),
|
|
||||||
)
|
|
||||||
if supplier and supplier.get("product_id"):
|
|
||||||
return int(supplier["product_id"])
|
|
||||||
|
|
||||||
if product_name:
|
|
||||||
row = execute_query_single(
|
|
||||||
"SELECT id FROM products WHERE LOWER(name) = LOWER(%s) LIMIT 1",
|
|
||||||
(product_name,),
|
|
||||||
)
|
|
||||||
if row and row.get("id"):
|
|
||||||
return int(row["id"])
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _derive_queue_status(self, matched_customer_id: Optional[int], matched_product_id: Optional[int], has_errors: bool) -> str:
|
|
||||||
if has_errors:
|
|
||||||
return "error"
|
|
||||||
if matched_customer_id and matched_product_id:
|
|
||||||
return "ready_for_approval"
|
|
||||||
if not matched_customer_id and matched_product_id:
|
|
||||||
return "matching_customers"
|
|
||||||
if matched_customer_id and not matched_product_id:
|
|
||||||
return "matching_products"
|
|
||||||
return "new"
|
|
||||||
|
|
||||||
def _build_validation_errors(self, line: Dict[str, Any], duplicate_ids: set[int]) -> List[Dict[str, Any]]:
|
|
||||||
errors: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if not line.get("matched_customer_id"):
|
|
||||||
errors.append({"code": "customer_not_found", "message": "Kunde ikke fundet/matchet"})
|
|
||||||
|
|
||||||
if not line.get("matched_product_id"):
|
|
||||||
errors.append({"code": "product_not_found", "message": "Produkt ikke fundet/matchet"})
|
|
||||||
|
|
||||||
total_price = _to_decimal(line.get("total_price"), default=_to_decimal(line.get("sales_price"), Decimal("0")))
|
|
||||||
if total_price < 0:
|
|
||||||
errors.append({"code": "negative_price", "message": "Negativ pris fundet"})
|
|
||||||
if total_price == 0:
|
|
||||||
errors.append({"code": "zero_price", "message": "Pris er 0"})
|
|
||||||
|
|
||||||
if not line.get("billing_start"):
|
|
||||||
errors.append({"code": "missing_period", "message": "Manglende billing_start/periode"})
|
|
||||||
|
|
||||||
currency = _normalized_text(line.get("currency"))
|
|
||||||
if not currency:
|
|
||||||
errors.append({"code": "missing_currency", "message": "Valuta mangler"})
|
|
||||||
|
|
||||||
if int(line.get("id") or 0) in duplicate_ids:
|
|
||||||
errors.append({"code": "duplicate_line", "message": "Dublet-linje fundet i samme import"})
|
|
||||||
|
|
||||||
if line.get("matched_customer_id") is None and _normalized_text(line.get("also_company_id")):
|
|
||||||
has_company_mapping = execute_query_single(
|
|
||||||
"SELECT id FROM also_company_mapping WHERE also_company_id = %s AND is_active = true LIMIT 1",
|
|
||||||
(_normalized_text(line.get("also_company_id")),),
|
|
||||||
)
|
|
||||||
if not has_company_mapping:
|
|
||||||
errors.append({"code": "missing_company_mapping", "message": "Manglende company mapping"})
|
|
||||||
|
|
||||||
if line.get("matched_product_id") is None and _normalized_text(line.get("material_number")) and _normalized_text(line.get("vendor")):
|
|
||||||
has_product_mapping = execute_query_single(
|
|
||||||
"""
|
|
||||||
SELECT id FROM also_product_mapping
|
|
||||||
WHERE material_number = %s AND LOWER(vendor) = LOWER(%s) AND is_active = true
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(_normalized_text(line.get("material_number")), _normalized_text(line.get("vendor"))),
|
|
||||||
)
|
|
||||||
if not has_product_mapping:
|
|
||||||
errors.append({"code": "missing_product_mapping", "message": "Manglende product mapping"})
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
def _fetch_process_lines(self, import_job_id: Optional[int], line_ids: Optional[List[int]], limit: int) -> List[Dict[str, Any]]:
|
|
||||||
params: List[Any] = []
|
|
||||||
where: List[str] = ["queue_status <> 'invoiced'"]
|
|
||||||
|
|
||||||
if import_job_id:
|
|
||||||
where.append("import_job_id = %s")
|
|
||||||
params.append(import_job_id)
|
|
||||||
|
|
||||||
if line_ids:
|
|
||||||
placeholders = ",".join(["%s"] * len(line_ids))
|
|
||||||
where.append(f"id IN ({placeholders})")
|
|
||||||
params.extend(line_ids)
|
|
||||||
|
|
||||||
params.append(max(1, min(limit, 5000)))
|
|
||||||
|
|
||||||
return execute_query(
|
|
||||||
f"""
|
|
||||||
SELECT *
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE {' AND '.join(where)}
|
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
tuple(params),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
def create_import_job(self, payload: AlsoImportJobCreate, imported_by_user_id: Optional[int]) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
rows = execute_query(
|
|
||||||
"""
|
|
||||||
INSERT INTO also_import_jobs (
|
|
||||||
source_type,
|
|
||||||
source_label,
|
|
||||||
status,
|
|
||||||
file_name,
|
|
||||||
import_version,
|
|
||||||
imported_by_user_id,
|
|
||||||
raw_payload_json,
|
|
||||||
log_json,
|
|
||||||
started_at,
|
|
||||||
imported_at
|
|
||||||
) VALUES (%s, %s, 'new', %s, %s, %s, %s::jsonb, '[]'::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
RETURNING *
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
payload.source_type,
|
|
||||||
payload.source_label,
|
|
||||||
payload.file_name,
|
|
||||||
payload.import_version,
|
|
||||||
imported_by_user_id,
|
|
||||||
_json_dumps(payload.raw_payload),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return rows[0]
|
|
||||||
|
|
||||||
def list_import_jobs(self, status: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
|
||||||
self._assert_enabled()
|
|
||||||
if status:
|
|
||||||
return execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
j.*,
|
|
||||||
COALESCE(l.line_count, 0) AS line_count
|
|
||||||
FROM also_import_jobs j
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT import_job_id, COUNT(*) AS line_count
|
|
||||||
FROM also_import_lines
|
|
||||||
GROUP BY import_job_id
|
|
||||||
) l ON l.import_job_id = j.id
|
|
||||||
WHERE j.status = %s
|
|
||||||
ORDER BY j.imported_at DESC, j.id DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(status, max(1, min(limit, 500))),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
return execute_query(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
j.*,
|
|
||||||
COALESCE(l.line_count, 0) AS line_count
|
|
||||||
FROM also_import_jobs j
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT import_job_id, COUNT(*) AS line_count
|
|
||||||
FROM also_import_lines
|
|
||||||
GROUP BY import_job_id
|
|
||||||
) l ON l.import_job_id = j.id
|
|
||||||
ORDER BY j.imported_at DESC, j.id DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(max(1, min(limit, 500)),),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
def get_import_job(self, job_id: int) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
row = execute_query_single(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
j.*,
|
|
||||||
COALESCE(l.line_count, 0) AS line_count
|
|
||||||
FROM also_import_jobs j
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT import_job_id, COUNT(*) AS line_count
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE import_job_id = %s
|
|
||||||
GROUP BY import_job_id
|
|
||||||
) l ON l.import_job_id = j.id
|
|
||||||
WHERE j.id = %s
|
|
||||||
""",
|
|
||||||
(job_id, job_id),
|
|
||||||
)
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Import job not found")
|
|
||||||
return row
|
|
||||||
|
|
||||||
def import_lines(self, job_id: int, payload: AlsoImportLinesRequest) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
job = execute_query_single("SELECT id FROM also_import_jobs WHERE id = %s", (job_id,))
|
|
||||||
if not job:
|
|
||||||
raise HTTPException(status_code=404, detail="Import job not found")
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
duplicates = 0
|
|
||||||
|
|
||||||
for idx, line in enumerate(payload.lines, start=1):
|
|
||||||
line_data = line.model_dump()
|
|
||||||
line_no = line_data.get("line_no") or idx
|
|
||||||
hash_value = _line_hash(job_id, line_data)
|
|
||||||
|
|
||||||
existing = execute_query_single(
|
|
||||||
"SELECT id FROM also_import_lines WHERE import_job_id = %s AND line_hash = %s",
|
|
||||||
(job_id, hash_value),
|
|
||||||
)
|
|
||||||
if existing:
|
|
||||||
duplicates += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
execute_query(
|
|
||||||
"""
|
|
||||||
INSERT INTO also_import_lines (
|
|
||||||
import_job_id,
|
|
||||||
line_no,
|
|
||||||
queue_status,
|
|
||||||
source_line_ref,
|
|
||||||
line_hash,
|
|
||||||
also_company_id,
|
|
||||||
company,
|
|
||||||
customer_id,
|
|
||||||
account_id,
|
|
||||||
vat,
|
|
||||||
material_number,
|
|
||||||
product_name,
|
|
||||||
vendor,
|
|
||||||
cost_amount,
|
|
||||||
sales_price,
|
|
||||||
unit_price,
|
|
||||||
total_price,
|
|
||||||
currency,
|
|
||||||
billing_start,
|
|
||||||
charge_interval,
|
|
||||||
billing_interval,
|
|
||||||
billable_parameters,
|
|
||||||
raw_line_json,
|
|
||||||
validation_errors_json
|
|
||||||
) VALUES (
|
|
||||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
|
||||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
|
||||||
%s, %s, %s::jsonb, '[]'::jsonb
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
job_id,
|
|
||||||
line_no,
|
|
||||||
line_data.get("queue_status") or "new",
|
|
||||||
line_data.get("source_line_ref"),
|
|
||||||
hash_value,
|
|
||||||
line_data.get("also_company_id"),
|
|
||||||
line_data.get("company"),
|
|
||||||
line_data.get("customer_id"),
|
|
||||||
line_data.get("account_id"),
|
|
||||||
line_data.get("vat"),
|
|
||||||
line_data.get("material_number"),
|
|
||||||
line_data.get("product_name"),
|
|
||||||
line_data.get("vendor"),
|
|
||||||
line_data.get("cost_amount"),
|
|
||||||
line_data.get("sales_price"),
|
|
||||||
line_data.get("unit_price"),
|
|
||||||
line_data.get("total_price"),
|
|
||||||
line_data.get("currency"),
|
|
||||||
line_data.get("billing_start"),
|
|
||||||
line_data.get("charge_interval"),
|
|
||||||
line_data.get("billing_interval"),
|
|
||||||
line_data.get("billable_parameters"),
|
|
||||||
_json_dumps(line_data.get("raw_line") or {}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
inserted += 1
|
|
||||||
|
|
||||||
execute_query(
|
|
||||||
"""
|
|
||||||
UPDATE also_import_jobs
|
|
||||||
SET status = CASE WHEN %s > 0 THEN 'lines_imported' ELSE status END,
|
|
||||||
finished_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(inserted, job_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"job_id": job_id,
|
|
||||||
"received": len(payload.lines),
|
|
||||||
"inserted": inserted,
|
|
||||||
"duplicates": duplicates,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_queue(self, status: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
|
||||||
self._assert_enabled()
|
|
||||||
if status:
|
|
||||||
return execute_query(
|
|
||||||
"""
|
|
||||||
SELECT *
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE queue_status = %s
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(status, max(1, min(limit, 1000))),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
return execute_query(
|
|
||||||
"""
|
|
||||||
SELECT *
|
|
||||||
FROM also_import_lines
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(max(1, min(limit, 1000)),),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
def run_matching(self, import_job_id: Optional[int], line_ids: List[int], limit: int) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
lines = self._fetch_process_lines(import_job_id=import_job_id, line_ids=line_ids, limit=limit)
|
|
||||||
|
|
||||||
updated = 0
|
|
||||||
ready = 0
|
|
||||||
errored = 0
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
matched_customer_id = self._resolve_customer_match(line)
|
|
||||||
matched_product_id = self._resolve_product_match(line)
|
|
||||||
|
|
||||||
current_errors = line.get("validation_errors_json") or []
|
|
||||||
has_errors = bool(current_errors)
|
|
||||||
new_status = self._derive_queue_status(matched_customer_id, matched_product_id, has_errors)
|
|
||||||
|
|
||||||
execute_query(
|
|
||||||
"""
|
|
||||||
UPDATE also_import_lines
|
|
||||||
SET matched_customer_id = %s,
|
|
||||||
matched_product_id = %s,
|
|
||||||
queue_status = %s,
|
|
||||||
matching_checked_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(matched_customer_id, matched_product_id, new_status, line["id"]),
|
|
||||||
)
|
|
||||||
updated += 1
|
|
||||||
if new_status == "ready_for_approval":
|
|
||||||
ready += 1
|
|
||||||
if new_status == "error":
|
|
||||||
errored += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"processed": len(lines),
|
|
||||||
"updated": updated,
|
|
||||||
"ready_for_approval": ready,
|
|
||||||
"errored": errored,
|
|
||||||
}
|
|
||||||
|
|
||||||
def run_validation(self, import_job_id: Optional[int], line_ids: List[int], limit: int) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
lines = self._fetch_process_lines(import_job_id=import_job_id, line_ids=line_ids, limit=limit)
|
|
||||||
|
|
||||||
duplicate_rows = execute_query(
|
|
||||||
"""
|
|
||||||
SELECT id
|
|
||||||
FROM (
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
COUNT(*) OVER (
|
|
||||||
PARTITION BY import_job_id, COALESCE(company, ''), COALESCE(material_number, ''), COALESCE(vendor, ''), COALESCE(billing_start::text, ''), COALESCE(total_price::text, '')
|
|
||||||
) AS dup_count
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE queue_status <> 'invoiced'
|
|
||||||
AND (%s::INTEGER IS NULL OR import_job_id = %s)
|
|
||||||
) q
|
|
||||||
WHERE q.dup_count > 1
|
|
||||||
""",
|
|
||||||
(import_job_id, import_job_id),
|
|
||||||
) or []
|
|
||||||
duplicate_ids = {int(row["id"]) for row in duplicate_rows}
|
|
||||||
|
|
||||||
updated = 0
|
|
||||||
ready = 0
|
|
||||||
errored = 0
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
errors = self._build_validation_errors(line, duplicate_ids=duplicate_ids)
|
|
||||||
new_status = self._derive_queue_status(line.get("matched_customer_id"), line.get("matched_product_id"), bool(errors))
|
|
||||||
|
|
||||||
execute_query(
|
|
||||||
"""
|
|
||||||
UPDATE also_import_lines
|
|
||||||
SET validation_errors_json = %s::jsonb,
|
|
||||||
queue_status = %s,
|
|
||||||
validation_checked_at = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(_json_dumps(errors), new_status, line["id"]),
|
|
||||||
)
|
|
||||||
updated += 1
|
|
||||||
if new_status == "ready_for_approval":
|
|
||||||
ready += 1
|
|
||||||
if new_status == "error":
|
|
||||||
errored += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"processed": len(lines),
|
|
||||||
"updated": updated,
|
|
||||||
"ready_for_approval": ready,
|
|
||||||
"errored": errored,
|
|
||||||
}
|
|
||||||
|
|
||||||
def approve_lines_to_drafts(self, import_job_id: Optional[int], line_ids: List[int], approved_by_user_id: Optional[int]) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
|
|
||||||
params: List[Any] = []
|
|
||||||
where = ["l.queue_status = 'ready_for_approval'", "l.matched_customer_id IS NOT NULL", "l.matched_product_id IS NOT NULL"]
|
|
||||||
|
|
||||||
if import_job_id:
|
|
||||||
where.append("l.import_job_id = %s")
|
|
||||||
params.append(import_job_id)
|
|
||||||
|
|
||||||
if line_ids:
|
|
||||||
placeholders = ",".join(["%s"] * len(line_ids))
|
|
||||||
where.append(f"l.id IN ({placeholders})")
|
|
||||||
params.extend(line_ids)
|
|
||||||
|
|
||||||
lines = execute_query(
|
|
||||||
f"""
|
|
||||||
SELECT
|
|
||||||
l.*,
|
|
||||||
c.name AS matched_customer_name,
|
|
||||||
p.name AS matched_product_name
|
|
||||||
FROM also_import_lines l
|
|
||||||
LEFT JOIN customers c ON c.id = l.matched_customer_id
|
|
||||||
LEFT JOIN products p ON p.id = l.matched_product_id
|
|
||||||
WHERE {' AND '.join(where)}
|
|
||||||
ORDER BY l.matched_customer_id, l.currency, COALESCE(l.billing_start, CURRENT_DATE), l.id
|
|
||||||
""",
|
|
||||||
tuple(params),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
if not lines:
|
|
||||||
raise HTTPException(status_code=400, detail="No ready-for-approval lines found")
|
|
||||||
|
|
||||||
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
|
||||||
for line in lines:
|
|
||||||
period_key = str(line.get("billing_start") or datetime.utcnow().date())[:7]
|
|
||||||
key = f"{line.get('matched_customer_id')}|{_normalized_text(line.get('currency')) or 'DKK'}|{period_key}"
|
|
||||||
grouped.setdefault(key, []).append(line)
|
|
||||||
|
|
||||||
draft_ids: List[int] = []
|
|
||||||
approved_lines = 0
|
|
||||||
|
|
||||||
for group_key, group_lines in grouped.items():
|
|
||||||
first = group_lines[0]
|
|
||||||
customer_id = int(first["matched_customer_id"])
|
|
||||||
customer_name = first.get("matched_customer_name") or f"Kunde {customer_id}"
|
|
||||||
currency = _normalized_text(first.get("currency")) or "DKK"
|
|
||||||
period_key = str(first.get("billing_start") or datetime.utcnow().date())[:7]
|
|
||||||
|
|
||||||
draft_lines: List[Dict[str, Any]] = []
|
|
||||||
for line in group_lines:
|
|
||||||
quantity = _to_decimal(line.get("billable_parameters"), Decimal("1"))
|
|
||||||
if quantity <= 0:
|
|
||||||
quantity = Decimal("1")
|
|
||||||
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
|
|
||||||
amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price))
|
|
||||||
|
|
||||||
draft_lines.append(
|
|
||||||
{
|
|
||||||
"line_key": f"also:{line['id']}",
|
|
||||||
"source_type": "also_cloud",
|
|
||||||
"source_id": int(line["id"]),
|
|
||||||
"reference_id": int(line["import_job_id"]),
|
|
||||||
"description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement",
|
|
||||||
"quantity": float(quantity),
|
|
||||||
"unit": "stk",
|
|
||||||
"unit_price": float(unit_price),
|
|
||||||
"discount_percentage": 0.0,
|
|
||||||
"amount": float(amount),
|
|
||||||
"currency": currency,
|
|
||||||
"status": "approved",
|
|
||||||
"line_date": str(line.get("billing_start")) if line.get("billing_start") else None,
|
|
||||||
"product_id": int(line["matched_product_id"]),
|
|
||||||
"customer_id": customer_id,
|
|
||||||
"customer_name": customer_name,
|
|
||||||
"selected": True,
|
|
||||||
"meta": {
|
|
||||||
"also_material_number": line.get("material_number"),
|
|
||||||
"also_vendor": line.get("vendor"),
|
|
||||||
"also_import_job_id": int(line.get("import_job_id")),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
draft = execute_query_single(
|
|
||||||
"""
|
|
||||||
INSERT INTO ordre_drafts (
|
|
||||||
title,
|
|
||||||
customer_id,
|
|
||||||
lines_json,
|
|
||||||
notes,
|
|
||||||
layout_number,
|
|
||||||
created_by_user_id,
|
|
||||||
sync_status,
|
|
||||||
export_status_json,
|
|
||||||
invoice_aggregate_key,
|
|
||||||
updated_at
|
|
||||||
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
f"ALSO Cloud {customer_name} - {period_key}",
|
|
||||||
customer_id,
|
|
||||||
_json_dumps(draft_lines),
|
|
||||||
"Genereret fra ALSO Cloud Billing approval",
|
|
||||||
1,
|
|
||||||
approved_by_user_id,
|
|
||||||
_json_dumps({"source": "also_cloud_billing"}),
|
|
||||||
f"also-cloud-{customer_id}-{period_key}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
draft_id = int(draft["id"]) if draft and draft.get("id") else None
|
|
||||||
if not draft_id:
|
|
||||||
raise HTTPException(status_code=500, detail="Failed creating ordre draft from ALSO approval")
|
|
||||||
|
|
||||||
draft_ids.append(draft_id)
|
|
||||||
|
|
||||||
line_id_values = [int(line["id"]) for line in group_lines]
|
|
||||||
placeholders = ",".join(["%s"] * len(line_id_values))
|
|
||||||
execute_query(
|
|
||||||
f"""
|
|
||||||
UPDATE also_import_lines
|
|
||||||
SET queue_status = 'approved',
|
|
||||||
approved_at = CURRENT_TIMESTAMP,
|
|
||||||
approved_by_user_id = %s,
|
|
||||||
order_draft_id = %s,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id IN ({placeholders})
|
|
||||||
""",
|
|
||||||
tuple([approved_by_user_id, draft_id] + line_id_values),
|
|
||||||
)
|
|
||||||
approved_lines += len(group_lines)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"approved_lines": approved_lines,
|
|
||||||
"created_drafts": len(draft_ids),
|
|
||||||
"draft_ids": draft_ids,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_dashboard_summary(self) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
row = execute_query_single(
|
|
||||||
"""
|
|
||||||
WITH month_lines AS (
|
|
||||||
SELECT *
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS monthly_revenue,
|
|
||||||
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS monthly_cost,
|
|
||||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS monthly_margin,
|
|
||||||
COUNT(*) FILTER (WHERE matched_product_id IS NULL) AS unmatched_products,
|
|
||||||
COUNT(*) FILTER (WHERE matched_customer_id IS NULL) AS unmatched_customers,
|
|
||||||
COUNT(*) FILTER (WHERE queue_status = 'ready_for_approval') AS pending_approvals,
|
|
||||||
COUNT(DISTINCT matched_customer_id) FILTER (WHERE queue_status = 'invoiced' AND matched_customer_id IS NOT NULL) AS invoiced_customers
|
|
||||||
FROM month_lines
|
|
||||||
""",
|
|
||||||
(),
|
|
||||||
) or {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"monthly_revenue": row.get("monthly_revenue") or Decimal("0"),
|
|
||||||
"monthly_cost": row.get("monthly_cost") or Decimal("0"),
|
|
||||||
"monthly_margin": row.get("monthly_margin") or Decimal("0"),
|
|
||||||
"unmatched_products": int(row.get("unmatched_products") or 0),
|
|
||||||
"unmatched_customers": int(row.get("unmatched_customers") or 0),
|
|
||||||
"pending_approvals": int(row.get("pending_approvals") or 0),
|
|
||||||
"invoiced_customers": int(row.get("invoiced_customers") or 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_monthly_differences(self, limit: int = 50) -> List[Dict[str, Any]]:
|
|
||||||
self._assert_enabled()
|
|
||||||
rows = execute_query(
|
|
||||||
"""
|
|
||||||
WITH current_month AS (
|
|
||||||
SELECT
|
|
||||||
matched_customer_id,
|
|
||||||
material_number,
|
|
||||||
vendor,
|
|
||||||
product_name,
|
|
||||||
SUM(COALESCE(billable_parameters, 1)) AS qty
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
|
|
||||||
GROUP BY matched_customer_id, material_number, vendor, product_name
|
|
||||||
),
|
|
||||||
previous_month AS (
|
|
||||||
SELECT
|
|
||||||
matched_customer_id,
|
|
||||||
material_number,
|
|
||||||
vendor,
|
|
||||||
product_name,
|
|
||||||
SUM(COALESCE(billable_parameters, 1)) AS qty
|
|
||||||
FROM also_import_lines
|
|
||||||
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
|
|
||||||
GROUP BY matched_customer_id, material_number, vendor, product_name
|
|
||||||
),
|
|
||||||
merged AS (
|
|
||||||
SELECT
|
|
||||||
COALESCE(c.matched_customer_id, p.matched_customer_id) AS customer_id,
|
|
||||||
COALESCE(c.material_number, p.material_number) AS material_number,
|
|
||||||
COALESCE(c.vendor, p.vendor) AS vendor,
|
|
||||||
COALESCE(c.product_name, p.product_name) AS product_name,
|
|
||||||
COALESCE(p.qty, 0) AS previous_month_qty,
|
|
||||||
COALESCE(c.qty, 0) AS current_month_qty
|
|
||||||
FROM current_month c
|
|
||||||
FULL OUTER JOIN previous_month p
|
|
||||||
ON COALESCE(c.matched_customer_id, 0) = COALESCE(p.matched_customer_id, 0)
|
|
||||||
AND COALESCE(c.material_number, '') = COALESCE(p.material_number, '')
|
|
||||||
AND COALESCE(c.vendor, '') = COALESCE(p.vendor, '')
|
|
||||||
AND COALESCE(c.product_name, '') = COALESCE(p.product_name, '')
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
m.*,
|
|
||||||
cu.name AS customer_name,
|
|
||||||
CASE
|
|
||||||
WHEN m.previous_month_qty = 0 THEN NULL
|
|
||||||
ELSE ROUND(((m.current_month_qty - m.previous_month_qty) / NULLIF(m.previous_month_qty, 0)) * 100, 2)
|
|
||||||
END AS change_percent
|
|
||||||
FROM merged m
|
|
||||||
LEFT JOIN customers cu ON cu.id = m.customer_id
|
|
||||||
WHERE m.previous_month_qty <> m.current_month_qty
|
|
||||||
ORDER BY ABS(COALESCE(
|
|
||||||
CASE
|
|
||||||
WHEN m.previous_month_qty = 0 THEN NULL
|
|
||||||
ELSE ((m.current_month_qty - m.previous_month_qty) / NULLIF(m.previous_month_qty, 0)) * 100
|
|
||||||
END,
|
|
||||||
0
|
|
||||||
)) DESC, m.current_month_qty DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(max(1, min(limit, 200)),),
|
|
||||||
) or []
|
|
||||||
|
|
||||||
results: List[Dict[str, Any]] = []
|
|
||||||
for row in rows:
|
|
||||||
change_percent = row.get("change_percent")
|
|
||||||
warning = False
|
|
||||||
if change_percent is not None:
|
|
||||||
try:
|
|
||||||
warning = abs(Decimal(str(change_percent))) >= Decimal("50")
|
|
||||||
except Exception:
|
|
||||||
warning = False
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"customer_id": row.get("customer_id"),
|
|
||||||
"customer_name": row.get("customer_name"),
|
|
||||||
"material_number": row.get("material_number"),
|
|
||||||
"product_name": row.get("product_name"),
|
|
||||||
"vendor": row.get("vendor"),
|
|
||||||
"previous_month_qty": row.get("previous_month_qty") or Decimal("0"),
|
|
||||||
"current_month_qty": row.get("current_month_qty") or Decimal("0"),
|
|
||||||
"change_percent": change_percent,
|
|
||||||
"warning": warning,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def upsert_company_mapping(self, payload: AlsoCompanyMappingUpsert) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
rows = execute_query(
|
|
||||||
"""
|
|
||||||
INSERT INTO also_company_mapping (
|
|
||||||
also_company_id,
|
|
||||||
also_customer_id,
|
|
||||||
customer_id,
|
|
||||||
match_confidence,
|
|
||||||
notes,
|
|
||||||
is_active
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, true)
|
|
||||||
ON CONFLICT (also_company_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
also_customer_id = EXCLUDED.also_customer_id,
|
|
||||||
customer_id = EXCLUDED.customer_id,
|
|
||||||
match_confidence = EXCLUDED.match_confidence,
|
|
||||||
notes = EXCLUDED.notes,
|
|
||||||
is_active = true,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
RETURNING *
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
payload.also_company_id,
|
|
||||||
payload.also_customer_id,
|
|
||||||
payload.customer_id,
|
|
||||||
payload.match_confidence,
|
|
||||||
payload.notes,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return rows[0]
|
|
||||||
|
|
||||||
def upsert_product_mapping(self, payload: AlsoProductMappingUpsert) -> Dict[str, Any]:
|
|
||||||
self._assert_enabled()
|
|
||||||
rows = execute_query(
|
|
||||||
"""
|
|
||||||
INSERT INTO also_product_mapping (
|
|
||||||
material_number,
|
|
||||||
vendor,
|
|
||||||
hub_product_id,
|
|
||||||
product_name_snapshot,
|
|
||||||
is_active
|
|
||||||
) VALUES (%s, %s, %s, %s, true)
|
|
||||||
ON CONFLICT (material_number, vendor)
|
|
||||||
DO UPDATE SET
|
|
||||||
hub_product_id = EXCLUDED.hub_product_id,
|
|
||||||
product_name_snapshot = EXCLUDED.product_name_snapshot,
|
|
||||||
is_active = true,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
RETURNING *
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
payload.material_number,
|
|
||||||
payload.vendor,
|
|
||||||
payload.hub_product_id,
|
|
||||||
payload.product_name_snapshot,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return rows[0]
|
|
||||||
|
|
||||||
|
|
||||||
also_service = AlsoService()
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
from datetime import date, datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Dict, List, Literal, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
ALSO_SOURCE_TYPES = Literal["api", "xml", "json_export", "xml_export", "csv"]
|
|
||||||
ALSO_QUEUE_STATUSES = Literal[
|
|
||||||
"new",
|
|
||||||
"matching_products",
|
|
||||||
"matching_customers",
|
|
||||||
"ready_for_approval",
|
|
||||||
"approved",
|
|
||||||
"invoiced",
|
|
||||||
"error",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoImportJobCreate(BaseModel):
|
|
||||||
source_type: ALSO_SOURCE_TYPES
|
|
||||||
source_label: Optional[str] = None
|
|
||||||
file_name: Optional[str] = None
|
|
||||||
import_version: Optional[str] = None
|
|
||||||
raw_payload: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoImportLineInput(BaseModel):
|
|
||||||
line_no: Optional[int] = None
|
|
||||||
source_line_ref: Optional[str] = None
|
|
||||||
|
|
||||||
company: Optional[str] = None
|
|
||||||
customer_id: Optional[str] = None
|
|
||||||
account_id: Optional[str] = None
|
|
||||||
vat: Optional[str] = None
|
|
||||||
also_company_id: Optional[str] = None
|
|
||||||
|
|
||||||
material_number: Optional[str] = None
|
|
||||||
product_name: Optional[str] = None
|
|
||||||
vendor: Optional[str] = None
|
|
||||||
|
|
||||||
cost_amount: Optional[Decimal] = None
|
|
||||||
sales_price: Optional[Decimal] = None
|
|
||||||
unit_price: Optional[Decimal] = None
|
|
||||||
total_price: Optional[Decimal] = None
|
|
||||||
currency: Optional[str] = None
|
|
||||||
|
|
||||||
billing_start: Optional[date] = None
|
|
||||||
charge_interval: Optional[str] = None
|
|
||||||
billing_interval: Optional[str] = None
|
|
||||||
|
|
||||||
billable_parameters: Optional[Decimal] = None
|
|
||||||
queue_status: ALSO_QUEUE_STATUSES = "new"
|
|
||||||
raw_line: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoImportLinesRequest(BaseModel):
|
|
||||||
lines: List[AlsoImportLineInput] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoQueueProcessRequest(BaseModel):
|
|
||||||
import_job_id: Optional[int] = Field(default=None, gt=0)
|
|
||||||
line_ids: List[int] = Field(default_factory=list)
|
|
||||||
limit: int = Field(default=500, ge=1, le=5000)
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoQueueApproveRequest(BaseModel):
|
|
||||||
import_job_id: Optional[int] = Field(default=None, gt=0)
|
|
||||||
line_ids: List[int] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoQueueProcessResult(BaseModel):
|
|
||||||
processed: int
|
|
||||||
updated: int
|
|
||||||
ready_for_approval: int
|
|
||||||
errored: int
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoApproveResult(BaseModel):
|
|
||||||
approved_lines: int
|
|
||||||
created_drafts: int
|
|
||||||
draft_ids: List[int] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoCompanyMappingUpsert(BaseModel):
|
|
||||||
also_company_id: str = Field(min_length=1)
|
|
||||||
also_customer_id: Optional[str] = None
|
|
||||||
customer_id: int = Field(gt=0)
|
|
||||||
match_confidence: Optional[Decimal] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoProductMappingUpsert(BaseModel):
|
|
||||||
material_number: str = Field(min_length=1)
|
|
||||||
vendor: str = Field(min_length=1)
|
|
||||||
hub_product_id: int = Field(gt=0)
|
|
||||||
product_name_snapshot: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoImportJobResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
source_type: str
|
|
||||||
source_label: Optional[str] = None
|
|
||||||
status: str
|
|
||||||
file_name: Optional[str] = None
|
|
||||||
import_version: Optional[str] = None
|
|
||||||
imported_by_user_id: Optional[int] = None
|
|
||||||
imported_at: datetime
|
|
||||||
line_count: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoQueueLineResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
import_job_id: int
|
|
||||||
queue_status: str
|
|
||||||
company: Optional[str] = None
|
|
||||||
customer_id: Optional[str] = None
|
|
||||||
material_number: Optional[str] = None
|
|
||||||
product_name: Optional[str] = None
|
|
||||||
vendor: Optional[str] = None
|
|
||||||
total_price: Optional[Decimal] = None
|
|
||||||
currency: Optional[str] = None
|
|
||||||
matched_customer_id: Optional[int] = None
|
|
||||||
matched_product_id: Optional[int] = None
|
|
||||||
order_draft_id: Optional[int] = None
|
|
||||||
validation_errors_json: List[Dict[str, Any]] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoDashboardSummaryResponse(BaseModel):
|
|
||||||
monthly_revenue: Decimal
|
|
||||||
monthly_cost: Decimal
|
|
||||||
monthly_margin: Decimal
|
|
||||||
unmatched_products: int
|
|
||||||
unmatched_customers: int
|
|
||||||
pending_approvals: int
|
|
||||||
invoiced_customers: int
|
|
||||||
|
|
||||||
|
|
||||||
class AlsoDifferenceItem(BaseModel):
|
|
||||||
customer_id: Optional[int] = None
|
|
||||||
customer_name: Optional[str] = None
|
|
||||||
material_number: Optional[str] = None
|
|
||||||
product_name: Optional[str] = None
|
|
||||||
vendor: Optional[str] = None
|
|
||||||
previous_month_qty: Decimal
|
|
||||||
current_month_qty: Decimal
|
|
||||||
change_percent: Optional[Decimal] = None
|
|
||||||
warning: bool = False
|
|
||||||
@ -3,7 +3,6 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from app.core.database import execute_query, execute_query_single
|
from app.core.database import execute_query, execute_query_single
|
||||||
from app.modules.drift.backend.router import _event_blacklist_candidates, _get_drift_device_blacklist
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -65,54 +64,6 @@ def _table_columns(table_name: str) -> List[str]:
|
|||||||
return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")]
|
return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")]
|
||||||
|
|
||||||
|
|
||||||
def get_drift_status(limit: int = 5) -> Dict[str, Any]:
|
|
||||||
rel_row = execute_query_single(
|
|
||||||
"""
|
|
||||||
SELECT COALESCE(
|
|
||||||
to_regclass('drift_events')::text,
|
|
||||||
to_regclass('public.drift_events')::text
|
|
||||||
) AS rel
|
|
||||||
"""
|
|
||||||
) or {}
|
|
||||||
rel_name = str(rel_row.get("rel") or "").strip()
|
|
||||||
if not rel_name:
|
|
||||||
return {"down": 0, "list": []}
|
|
||||||
|
|
||||||
rows = execute_query(
|
|
||||||
f"""
|
|
||||||
SELECT device_name, message, source_event_id, raw_json
|
|
||||||
FROM {rel_name}
|
|
||||||
WHERE (
|
|
||||||
COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') IN ('active', 'warning', 'critical', 'new')
|
|
||||||
OR (
|
|
||||||
resolved_at IS NULL
|
|
||||||
AND COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') NOT IN ('resolved', 'acknowledged')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY COALESCE(started_at, created_at) DESC NULLS LAST, id DESC
|
|
||||||
""",
|
|
||||||
) or []
|
|
||||||
|
|
||||||
blacklist = _get_drift_device_blacklist()
|
|
||||||
blacklist_set = set(blacklist)
|
|
||||||
filtered_rows: List[Dict[str, Any]] = []
|
|
||||||
for row in rows:
|
|
||||||
candidates = set(_event_blacklist_candidates(row))
|
|
||||||
if any(item in candidates for item in blacklist_set):
|
|
||||||
continue
|
|
||||||
filtered_rows.append(row)
|
|
||||||
|
|
||||||
top_items = filtered_rows[: max(1, int(limit or 5))]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"down": len(filtered_rows),
|
|
||||||
"list": [
|
|
||||||
str((r.get("device_name") or r.get("message") or "Ukendt alarm")).strip()
|
|
||||||
for r in top_items
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
|
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
return []
|
return []
|
||||||
@ -156,12 +107,8 @@ def _can_view_boss_tab(user_id: Optional[int]) -> bool:
|
|||||||
|
|
||||||
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
|
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
|
||||||
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
|
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
|
||||||
if not setting:
|
|
||||||
# Default to enabled if the setting row is missing on older hubs.
|
|
||||||
return True
|
|
||||||
|
|
||||||
setting_value = str((setting or {}).get("value") or "").strip().lower()
|
setting_value = str((setting or {}).get("value") or "").strip().lower()
|
||||||
if setting_value in {"0", "false", "no", "off"}:
|
if setting_value not in {"1", "true", "yes", "on"}:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
@ -244,14 +191,11 @@ def get_dashboard_status() -> Dict[str, int]:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
drift_active = int(get_drift_status(limit=1).get("down") or 0)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"mails_unread": mails_unread,
|
"mails_unread": mails_unread,
|
||||||
"sager_open": sager_open,
|
"sager_open": sager_open,
|
||||||
"sager_urgent": sager_urgent,
|
"sager_urgent": sager_urgent,
|
||||||
"sager_unassigned": sager_unassigned,
|
"sager_unassigned": sager_unassigned,
|
||||||
"drift_active": drift_active,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -731,7 +675,6 @@ def build_bottom_bar_state(
|
|||||||
timer = get_active_timer(user_id)
|
timer = get_active_timer(user_id)
|
||||||
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
|
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
|
||||||
notifications = get_notifications(user_id, limit=10)
|
notifications = get_notifications(user_id, limit=10)
|
||||||
drift_status = get_drift_status(limit=5)
|
|
||||||
unassigned_open_cases = get_unassigned_open_cases(limit=8)
|
unassigned_open_cases = get_unassigned_open_cases(limit=8)
|
||||||
recent_cases = _get_recent_cases(user_id, limit=10)
|
recent_cases = _get_recent_cases(user_id, limit=10)
|
||||||
notes_summary = get_user_notes_summary(user_id, limit=10)
|
notes_summary = get_user_notes_summary(user_id, limit=10)
|
||||||
@ -944,12 +887,8 @@ def build_bottom_bar_state(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"kuma": {
|
"kuma": {
|
||||||
"down": int(drift_status.get("down") or 0),
|
"down": 0,
|
||||||
"list": drift_status.get("list") or [],
|
"list": [],
|
||||||
},
|
|
||||||
"drift": {
|
|
||||||
"down": int(drift_status.get("down") or 0),
|
|
||||||
"list": drift_status.get("list") or [],
|
|
||||||
},
|
},
|
||||||
"eset": {
|
"eset": {
|
||||||
"incidents": 0,
|
"incidents": 0,
|
||||||
|
|||||||
@ -1,98 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
import hmac
|
|
||||||
import hashlib
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from app.core.auth_dependencies import get_current_user
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.database import execute_query
|
from app.core.database import execute_query
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _calendar_token_secret() -> bytes:
|
|
||||||
return (settings.SECRET_KEY or settings.JWT_SECRET_KEY or "calendar-dev-secret").encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _create_calendar_feed_token(user_id: int, expires_at: datetime) -> str:
|
|
||||||
exp_ts = int(expires_at.timestamp())
|
|
||||||
payload = f"{int(user_id)}:{exp_ts}".encode("utf-8")
|
|
||||||
digest = hmac.new(_calendar_token_secret(), payload, hashlib.sha256).hexdigest()
|
|
||||||
return f"{int(user_id)}.{exp_ts}.{digest}"
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_calendar_feed_token(token: str) -> int:
|
|
||||||
parts = str(token or "").split(".")
|
|
||||||
if len(parts) != 3:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid calendar token")
|
|
||||||
|
|
||||||
user_part, exp_part, sig_part = parts
|
|
||||||
try:
|
|
||||||
user_id = int(user_part)
|
|
||||||
exp_ts = int(exp_part)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid calendar token") from exc
|
|
||||||
|
|
||||||
payload = f"{user_id}:{exp_ts}".encode("utf-8")
|
|
||||||
expected = hmac.new(_calendar_token_secret(), payload, hashlib.sha256).hexdigest()
|
|
||||||
if not hmac.compare_digest(expected, sig_part):
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid calendar token")
|
|
||||||
|
|
||||||
if datetime.utcnow().timestamp() > exp_ts:
|
|
||||||
raise HTTPException(status_code=401, detail="Calendar token expired")
|
|
||||||
|
|
||||||
return user_id
|
|
||||||
|
|
||||||
|
|
||||||
def _build_ical_response(events: list[dict], now: datetime) -> Response:
|
|
||||||
lines = [
|
|
||||||
"BEGIN:VCALENDAR",
|
|
||||||
"VERSION:2.0",
|
|
||||||
"PRODID:-//BMC Hub//Calendar//DA",
|
|
||||||
"CALSCALE:GREGORIAN",
|
|
||||||
"X-WR-CALNAME:BMC Hub Kalender",
|
|
||||||
"REFRESH-INTERVAL;VALUE=DURATION:PT15M",
|
|
||||||
"X-PUBLISHED-TTL:PT15M",
|
|
||||||
]
|
|
||||||
|
|
||||||
for event in events:
|
|
||||||
start_value = datetime.fromisoformat(event.get("start"))
|
|
||||||
summary = _escape_ical(event.get("title", ""))
|
|
||||||
description_parts = []
|
|
||||||
if event.get("customer_name"):
|
|
||||||
description_parts.append(f"Kunde: {event.get('customer_name')}")
|
|
||||||
if event.get("event_type"):
|
|
||||||
description_parts.append(f"Type: {event.get('event_type')}")
|
|
||||||
if event.get("url"):
|
|
||||||
description_parts.append(f"Link: {event.get('url')}")
|
|
||||||
description = _escape_ical("\n".join(description_parts))
|
|
||||||
uid = _escape_ical(f"{event.get('id')}@bmc-hub")
|
|
||||||
|
|
||||||
lines.extend([
|
|
||||||
"BEGIN:VEVENT",
|
|
||||||
f"UID:{uid}",
|
|
||||||
f"DTSTAMP:{_format_ical_dt(now)}",
|
|
||||||
f"DTSTART:{_format_ical_dt(start_value)}",
|
|
||||||
f"SUMMARY:{summary}",
|
|
||||||
f"DESCRIPTION:{description}",
|
|
||||||
"END:VEVENT",
|
|
||||||
])
|
|
||||||
|
|
||||||
lines.append("END:VCALENDAR")
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
content="\r\n".join(lines),
|
|
||||||
media_type="text/calendar; charset=utf-8",
|
|
||||||
headers={
|
|
||||||
"Content-Disposition": 'inline; filename="bmc-hub-calendar.ics"',
|
|
||||||
"Cache-Control": "public, max-age=300",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_datetime(value: str, fallback: datetime) -> datetime:
|
def _parse_iso_datetime(value: str, fallback: datetime) -> datetime:
|
||||||
if not value:
|
if not value:
|
||||||
return fallback
|
return fallback
|
||||||
@ -386,53 +302,40 @@ async def get_calendar_ical(
|
|||||||
types=types,
|
types=types,
|
||||||
)
|
)
|
||||||
|
|
||||||
return _build_ical_response(events, now)
|
lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//BMC Hub//Calendar//DA",
|
||||||
|
"CALSCALE:GREGORIAN",
|
||||||
|
"X-WR-CALNAME:BMC Hub Kalender",
|
||||||
|
]
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
start_value = datetime.fromisoformat(event.get("start"))
|
||||||
|
summary = _escape_ical(event.get("title", ""))
|
||||||
|
description_parts = []
|
||||||
|
if event.get("customer_name"):
|
||||||
|
description_parts.append(f"Kunde: {event.get('customer_name')}")
|
||||||
|
if event.get("event_type"):
|
||||||
|
description_parts.append(f"Type: {event.get('event_type')}")
|
||||||
|
if event.get("url"):
|
||||||
|
description_parts.append(f"Link: {event.get('url')}")
|
||||||
|
description = _escape_ical("\n".join(description_parts))
|
||||||
|
uid = _escape_ical(f"{event.get('id')}@bmc-hub")
|
||||||
|
|
||||||
@router.get("/calendar/ical/subscribe")
|
lines.extend([
|
||||||
async def get_calendar_ical_subscribe_link(
|
"BEGIN:VEVENT",
|
||||||
request: Request,
|
f"UID:{uid}",
|
||||||
current_user: dict = Depends(get_current_user),
|
f"DTSTAMP:{_format_ical_dt(now)}",
|
||||||
):
|
f"DTSTART:{_format_ical_dt(start_value)}",
|
||||||
"""Return a tokenized iCal subscription URL suitable for Outlook/Internet Calendar."""
|
f"SUMMARY:{summary}",
|
||||||
expires_at = datetime.utcnow() + timedelta(days=3650)
|
f"DESCRIPTION:{description}",
|
||||||
token = _create_calendar_feed_token(int(current_user.get("id")), expires_at)
|
"END:VEVENT",
|
||||||
base = str(request.base_url).rstrip("/")
|
])
|
||||||
http_url = f"{base}/api/v1/calendar/ical/feed?token={token}"
|
|
||||||
webcal_url = http_url.replace("https://", "webcals://", 1).replace("http://", "webcal://", 1)
|
|
||||||
|
|
||||||
return {
|
lines.append("END:VCALENDAR")
|
||||||
"http_url": http_url,
|
|
||||||
"webcal_url": webcal_url,
|
|
||||||
"expires_at": expires_at.isoformat() + "Z",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
return Response(
|
||||||
@router.get("/calendar/ical/feed")
|
content="\r\n".join(lines),
|
||||||
async def get_calendar_ical_feed(
|
media_type="text/calendar; charset=utf-8",
|
||||||
request: Request,
|
|
||||||
token: str = Query(...),
|
|
||||||
start: str = Query(None),
|
|
||||||
end: str = Query(None),
|
|
||||||
customer_id: int | None = Query(None),
|
|
||||||
types: str | None = Query(None),
|
|
||||||
):
|
|
||||||
"""Token-based iCal feed endpoint intended for external calendar subscriptions."""
|
|
||||||
now = datetime.now()
|
|
||||||
start_dt = _parse_iso_datetime(start, now - timedelta(days=14))
|
|
||||||
end_dt = _parse_iso_datetime(end, now + timedelta(days=60))
|
|
||||||
|
|
||||||
if end_dt < start_dt:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid date range")
|
|
||||||
|
|
||||||
user_id = _verify_calendar_feed_token(token)
|
|
||||||
events = _get_calendar_events(
|
|
||||||
request=request,
|
|
||||||
start_dt=start_dt,
|
|
||||||
end_dt=end_dt,
|
|
||||||
only_mine=True,
|
|
||||||
user_id=user_id,
|
|
||||||
customer_id=customer_id,
|
|
||||||
types=types,
|
|
||||||
)
|
)
|
||||||
return _build_ical_response(events, now)
|
|
||||||
|
|||||||
@ -431,15 +431,7 @@
|
|||||||
<div>Status: <span id="calendarStatus">Klar</span></div>
|
<div>Status: <span id="calendarStatus">Klar</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-ical">
|
<div class="hero-ical">
|
||||||
<div><strong>Outlook iCal abonnement (opdateres lobende):</strong></div>
|
iCal: <a href="{{ request.base_url }}api/v1/calendar/ical">{{ request.base_url }}api/v1/calendar/ical</a>
|
||||||
<div class="mt-1">
|
|
||||||
<a id="icalSubscribeLink" href="{{ request.base_url }}api/v1/calendar/ical" target="_blank" rel="noopener">{{ request.base_url }}api/v1/calendar/ical</a>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" type="button" id="copyIcalLinkBtn">Kopier link</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" id="copyWebcalLinkBtn">Kopier webcal://</button>
|
|
||||||
</div>
|
|
||||||
<div class="small text-muted mt-2">Tip: Brug internetkalender-abonnement i Outlook, ikke import, for automatisk opdatering.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="calendar-filter-card">
|
<div class="calendar-filter-card">
|
||||||
@ -582,9 +574,6 @@
|
|||||||
const rangeLabelEl = document.getElementById('rangeLabel');
|
const rangeLabelEl = document.getElementById('rangeLabel');
|
||||||
const customerSelect = document.getElementById('customerSelect');
|
const customerSelect = document.getElementById('customerSelect');
|
||||||
const customerSearch = document.getElementById('customerSearch');
|
const customerSearch = document.getElementById('customerSearch');
|
||||||
const icalSubscribeLink = document.getElementById('icalSubscribeLink');
|
|
||||||
const copyIcalLinkBtn = document.getElementById('copyIcalLinkBtn');
|
|
||||||
const copyWebcalLinkBtn = document.getElementById('copyWebcalLinkBtn');
|
|
||||||
const mineToggle = document.getElementById('mineToggle');
|
const mineToggle = document.getElementById('mineToggle');
|
||||||
const allToggle = document.getElementById('allToggle');
|
const allToggle = document.getElementById('allToggle');
|
||||||
const viewButtons = document.getElementById('viewButtons');
|
const viewButtons = document.getElementById('viewButtons');
|
||||||
@ -600,38 +589,6 @@
|
|||||||
|
|
||||||
let onlyMine = true;
|
let onlyMine = true;
|
||||||
|
|
||||||
async function copyTextToClipboard(value) {
|
|
||||||
if (!value) return;
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
calendarStatusEl.textContent = 'Link kopieret';
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Clipboard write failed', err);
|
|
||||||
calendarStatusEl.textContent = 'Kunne ikke kopiere link';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadIcalSubscriptionLink() {
|
|
||||||
if (!icalSubscribeLink) return;
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/calendar/ical/subscribe');
|
|
||||||
if (!response.ok) return;
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.http_url) {
|
|
||||||
icalSubscribeLink.href = data.http_url;
|
|
||||||
icalSubscribeLink.textContent = data.http_url;
|
|
||||||
}
|
|
||||||
if (copyIcalLinkBtn) {
|
|
||||||
copyIcalLinkBtn.onclick = () => copyTextToClipboard(data.http_url || '');
|
|
||||||
}
|
|
||||||
if (copyWebcalLinkBtn) {
|
|
||||||
copyWebcalLinkBtn.onclick = () => copyTextToClipboard(data.webcal_url || '');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Could not load tokenized iCal subscription URL', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setToggle(activeMine) {
|
function setToggle(activeMine) {
|
||||||
onlyMine = activeMine;
|
onlyMine = activeMine;
|
||||||
mineToggle.classList.toggle('active', activeMine);
|
mineToggle.classList.toggle('active', activeMine);
|
||||||
@ -760,7 +717,6 @@
|
|||||||
|
|
||||||
calendar.render();
|
calendar.render();
|
||||||
loadCustomers();
|
loadCustomers();
|
||||||
loadIcalSubscriptionLink();
|
|
||||||
|
|
||||||
mineToggle.addEventListener('click', () => setToggle(true));
|
mineToggle.addEventListener('click', () => setToggle(true));
|
||||||
allToggle.addEventListener('click', () => setToggle(false));
|
allToggle.addEventListener('click', () => setToggle(false));
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
from .router import router
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user