Compare commits
4 Commits
70a01db422
...
8710f7f798
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8710f7f798 | ||
|
|
4822637466 | ||
|
|
a604a3cc44 | ||
|
|
ce75f12f56 |
13
.env.example
13
.env.example
@ -70,6 +70,19 @@ 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,6 +87,20 @@ 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,6 +273,10 @@ 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(
|
||||||
@ -306,3 +310,73 @@ 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,6 +507,218 @@ 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,6 +315,15 @@ 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
|
from typing import List, Optional, Dict, Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -1780,3 +1780,139 @@ 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))
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,24 @@
|
|||||||
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;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@ -225,6 +243,123 @@
|
|||||||
.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 %}
|
||||||
|
|
||||||
@ -342,6 +477,11 @@
|
|||||||
<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
|
||||||
@ -377,6 +517,11 @@
|
|||||||
<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>
|
||||||
|
|
||||||
@ -550,31 +695,47 @@
|
|||||||
<!-- 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">
|
||||||
<h5 class="fw-bold mb-0">Kontaktpersoner</h5>
|
<div>
|
||||||
|
<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="table-responsive" id="contactsContainer">
|
<div class="contacts-toolbar">
|
||||||
<table class="table table-hover align-middle mb-0">
|
<div class="input-group contacts-search">
|
||||||
<thead class="table-light">
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
<tr>
|
<input type="search" class="form-control" id="contactsSearchInput" placeholder="Søg i navn, titel, email eller nummer">
|
||||||
<th>Navn</th>
|
</div>
|
||||||
<th>Titel</th>
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<th>Email</th>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="contactsOnlyCallableToggle">Kun med nummer</button>
|
||||||
<th>Telefon</th>
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="contactsClearFilters">Nulstil</button>
|
||||||
<th>Mobil</th>
|
<span class="badge text-bg-light border" id="contactsResultCount">0</span>
|
||||||
<th>Primær</th>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<div class="contacts-panel">
|
||||||
<tbody>
|
<div class="table-responsive" id="contactsContainer">
|
||||||
<tr>
|
<table class="table table-hover align-middle contacts-table">
|
||||||
<td colspan="6" class="text-center py-4">
|
<thead>
|
||||||
<div class="spinner-border text-primary"></div>
|
<tr>
|
||||||
</td>
|
<th>Navn</th>
|
||||||
</tr>
|
<th>Titel</th>
|
||||||
</tbody>
|
<th>Email</th>
|
||||||
</table>
|
<th>Telefon</th>
|
||||||
|
<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>
|
||||||
|
|
||||||
@ -781,6 +942,105 @@
|
|||||||
</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">
|
||||||
@ -945,6 +1205,49 @@
|
|||||||
</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>
|
||||||
@ -1478,6 +1781,14 @@ 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) {
|
||||||
@ -1769,12 +2080,13 @@ function displayCustomer(customer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCustomerCallNumber(number) {
|
function renderCustomerCallNumber(number) {
|
||||||
const clean = String(number || '').trim();
|
const clean = normalizePhoneValue(number);
|
||||||
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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -1795,8 +2107,8 @@ async function ensureCustomerDetailCurrentUserId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function customerDetailCallViaYealink(number) {
|
async function customerDetailCallViaYealink(number) {
|
||||||
const clean = String(number || '').trim();
|
const clean = normalizePhoneValue(number);
|
||||||
if (!clean || clean === '-') {
|
if (!clean) {
|
||||||
alert('Intet gyldigt nummer at ringe til');
|
alert('Intet gyldigt nummer at ringe til');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2586,11 +2898,118 @@ function displayUtilityCompany(payload) {
|
|||||||
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
|
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadContacts() {
|
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');
|
const container = document.getElementById('contactsContainer');
|
||||||
container.innerHTML = `
|
if (!container) return;
|
||||||
<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>
|
||||||
@ -2601,64 +3020,108 @@ async function loadContacts() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
${bodyHtml}
|
||||||
<td colspan="6" class="text-center py-4">
|
|
||||||
<div class="spinner-border text-primary"></div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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() {
|
||||||
|
const container = document.getElementById('contactsContainer');
|
||||||
|
|
||||||
|
initContactsFilterControls();
|
||||||
|
|
||||||
|
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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 : [];
|
||||||
if (!contacts || contacts.length === 0) {
|
renderContactsFromState();
|
||||||
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>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3646,6 +4109,61 @@ 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;
|
||||||
@ -4636,6 +5154,80 @@ 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');
|
||||||
@ -4850,6 +5442,17 @@ 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>
|
||||||
|
|
||||||
|
|||||||
0
app/modules/also/backend/__init__.py
Normal file
0
app/modules/also/backend/__init__.py
Normal file
114
app/modules/also/backend/router.py
Normal file
114
app/modules/also/backend/router.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
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)
|
||||||
907
app/modules/also/backend/service.py
Normal file
907
app/modules/also/backend/service.py
Normal file
@ -0,0 +1,907 @@
|
|||||||
|
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()
|
||||||
0
app/modules/also/models/__init__.py
Normal file
0
app/modules/also/models/__init__.py
Normal file
148
app/modules/also/models/schemas.py
Normal file
148
app/modules/also/models/schemas.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
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,6 +3,7 @@ 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__)
|
||||||
|
|
||||||
@ -64,6 +65,54 @@ 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 []
|
||||||
@ -107,8 +156,12 @@ 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 not in {"1", "true", "yes", "on"}:
|
if setting_value in {"0", "false", "no", "off"}:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
@ -191,11 +244,14 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -675,6 +731,7 @@ 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)
|
||||||
@ -887,8 +944,12 @@ def build_bottom_bar_state(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"kuma": {
|
"kuma": {
|
||||||
"down": 0,
|
"down": int(drift_status.get("down") or 0),
|
||||||
"list": [],
|
"list": drift_status.get("list") or [],
|
||||||
|
},
|
||||||
|
"drift": {
|
||||||
|
"down": int(drift_status.get("down") or 0),
|
||||||
|
"list": drift_status.get("list") or [],
|
||||||
},
|
},
|
||||||
"eset": {
|
"eset": {
|
||||||
"incidents": 0,
|
"incidents": 0,
|
||||||
|
|||||||
@ -1,14 +1,98 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, 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
|
||||||
@ -302,40 +386,53 @@ async def get_calendar_ical(
|
|||||||
types=types,
|
types=types,
|
||||||
)
|
)
|
||||||
|
|
||||||
lines = [
|
return _build_ical_response(events, now)
|
||||||
"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")
|
|
||||||
|
|
||||||
lines.extend([
|
@router.get("/calendar/ical/subscribe")
|
||||||
"BEGIN:VEVENT",
|
async def get_calendar_ical_subscribe_link(
|
||||||
f"UID:{uid}",
|
request: Request,
|
||||||
f"DTSTAMP:{_format_ical_dt(now)}",
|
current_user: dict = Depends(get_current_user),
|
||||||
f"DTSTART:{_format_ical_dt(start_value)}",
|
):
|
||||||
f"SUMMARY:{summary}",
|
"""Return a tokenized iCal subscription URL suitable for Outlook/Internet Calendar."""
|
||||||
f"DESCRIPTION:{description}",
|
expires_at = datetime.utcnow() + timedelta(days=3650)
|
||||||
"END:VEVENT",
|
token = _create_calendar_feed_token(int(current_user.get("id")), expires_at)
|
||||||
])
|
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)
|
||||||
|
|
||||||
lines.append("END:VCALENDAR")
|
return {
|
||||||
|
"http_url": http_url,
|
||||||
|
"webcal_url": webcal_url,
|
||||||
|
"expires_at": expires_at.isoformat() + "Z",
|
||||||
|
}
|
||||||
|
|
||||||
return Response(
|
|
||||||
content="\r\n".join(lines),
|
@router.get("/calendar/ical/feed")
|
||||||
media_type="text/calendar; charset=utf-8",
|
async def get_calendar_ical_feed(
|
||||||
|
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,7 +431,15 @@
|
|||||||
<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">
|
||||||
iCal: <a href="{{ request.base_url }}api/v1/calendar/ical">{{ request.base_url }}api/v1/calendar/ical</a>
|
<div><strong>Outlook iCal abonnement (opdateres lobende):</strong></div>
|
||||||
|
<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">
|
||||||
@ -574,6 +582,9 @@
|
|||||||
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');
|
||||||
@ -589,6 +600,38 @@
|
|||||||
|
|
||||||
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);
|
||||||
@ -717,6 +760,7 @@
|
|||||||
|
|
||||||
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
app/modules/drift/backend/__init__.py
Normal file
1
app/modules/drift/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .router import router
|
||||||
1714
app/modules/drift/backend/router.py
Normal file
1714
app/modules/drift/backend/router.py
Normal file
File diff suppressed because it is too large
Load Diff
1
app/modules/drift/frontend/__init__.py
Normal file
1
app/modules/drift/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .views import router
|
||||||
14
app/modules/drift/frontend/views.py
Normal file
14
app/modules/drift/frontend/views.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/drift", response_class=HTMLResponse)
|
||||||
|
async def drift_index(request: Request):
|
||||||
|
return templates.TemplateResponse("modules/drift/templates/drift.html", {"request": request})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user