Compare commits

...

4 Commits

Author SHA1 Message Date
Christian
8710f7f798 feat(drift): add Drift module with frontend and backend components
- Implemented the Drift module with a new FastAPI router and HTML templates for the frontend.
- Created database tables for drift sources, devices, events, event history, and customer mappings.
- Added functionality to manage and display drift events, including filtering and bulk mapping of monitors to customers.
- Introduced tests for the Drift module, covering various functionalities including event blacklisting and UISP connector configuration.
- Enhanced Telefoni service tests to ensure proper call termination handling.
2026-06-26 14:15:46 +02:00
Christian
4822637466 feat: add time multiplier presets management and extend worklog model
- Added a new section in the settings frontend for managing time multiplier presets, including UI for adding, displaying, and saving presets.
- Introduced a new function to normalize and load time multiplier presets from settings.
- Updated the worklog submission process to include a selected multiplier preset and its corresponding rate multiplier.
- Enhanced the worklog model to support additional fields: extra_billed_hours, extended_support_flag, rate_multiplier, manual_hourly_rate, manual_rounded_hours, and rounding_override_reason.
- Implemented backend validation to ensure that time entries linked to prepaid cards cannot be edited if the card is in a locked status.
- Added database migration to introduce new columns and constraints for the worklog and time entry tables.
2026-06-21 10:26:07 +02:00
Christian
a604a3cc44 Add comprehensive test suite for vTiger integration and ticket module
- Implemented test scripts for vTiger account retrieval, contact data, and modules.
- Created a detailed test suite for the ticket module, covering database schema validation, ticket number generation, prepaid card constraints, and service logic.
- Added tests for vTiger field inspection and various queries related to accounts and sales orders.
- Introduced SQL migration scripts for the ALSO Cloud Billing foundation, including import jobs, lines, and mapping tables with necessary constraints and indices.
- Enhanced workflow columns in the import lines table to support matching and validation timestamps.
2026-06-20 03:29:51 +02:00
Christian
ce75f12f56 feat: integrate bug reporting module and enhance screenshot functionality
- Added bug reporting API router to main application.
- Enhanced screenshot functionality in bug-report.js with timeout handling and dynamic loading of html2canvas library.
- Improved screenshot capture strategies and error handling for better user experience.
- Updated modal handling for bug reports to include screenshot previews and status messages.
- Refactored code for better readability and maintainability.

feat: implement user-specific case status and preferences

- Created migration to allow dynamic case statuses in sag_sager table.
- Added user_sag_list_preferences table for per-user preferences on case list filters.
- Introduced user_menu_preferences table to manage per-user menu visibility preferences with triggers for updated_at timestamps.
2026-06-18 22:20:39 +02:00
222 changed files with 9541 additions and 526 deletions

View File

@ -70,6 +70,19 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
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)
# =====================================================

View File

@ -87,6 +87,20 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here
ECONOMIC_READ_ONLY=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
# =====================================================

View File

@ -273,6 +273,10 @@ class AnyDeskIdAdd(BaseModel):
label: Optional[str] = None
class MenuPreferencesUpdate(BaseModel):
hidden_menu_keys: list[str] = []
@router.get("/me/anydesk-ids")
async def get_my_anydesk_ids(current_user: dict = Depends(get_current_user)):
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:
raise HTTPException(status_code=404, detail="Ikke fundet")
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

View File

@ -507,6 +507,218 @@ async def get_related_contacts(contact_id: int):
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")
async def get_contact_subscriptions(contact_id: int):
customer_id = get_primary_customer_id(contact_id)

View File

@ -315,6 +315,15 @@ class Settings(BaseSettings):
FEDEX_BASE_URL: str = ""
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_ENABLED: bool = False

View File

@ -5,7 +5,7 @@ Adapted from OmniSync for BMC Hub
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional, Dict
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
import logging
import asyncio
@ -1780,3 +1780,139 @@ async def get_subscription_billing_matrix(
logger.error(f"❌ Error generating billing matrix: {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))

View File

@ -63,6 +63,24 @@
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 {
display: flex;
justify-content: space-between;
@ -225,6 +243,123 @@
.btn-edit-customer:hover i {
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>
{% endblock %}
@ -342,6 +477,11 @@
<i class="bi bi-table"></i>Abonnements Matrix
</a>
</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">
<a class="nav-link" data-bs-toggle="tab" href="#locations">
<i class="bi bi-geo-alt"></i>Lokationer
@ -377,6 +517,11 @@
<i class="bi bi-mic"></i>Samtaler
</a>
</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>
</div>
@ -550,14 +695,29 @@
<!-- Contacts Tab -->
<div class="tab-pane fade" id="contacts">
<div class="d-flex justify-content-between align-items-center mb-4">
<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()">
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
</button>
</div>
<div class="contacts-toolbar">
<div class="input-group contacts-search">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" id="contactsSearchInput" placeholder="Søg i navn, titel, email eller nummer">
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-sm btn-outline-secondary" id="contactsOnlyCallableToggle">Kun med nummer</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="contactsClearFilters">Nulstil</button>
<span class="badge text-bg-light border" id="contactsResultCount">0</span>
</div>
</div>
<div class="contacts-panel">
<div class="table-responsive" id="contactsContainer">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Titel</th>
@ -577,6 +737,7 @@
</table>
</div>
</div>
</div>
<!-- Cases Tab -->
<div class="tab-pane fade" id="cases">
@ -781,6 +942,105 @@
</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 -->
<div class="tab-pane fade" id="locations">
<div class="d-flex justify-content-between align-items-center mb-4">
@ -945,6 +1205,49 @@
</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>
@ -1478,6 +1781,14 @@ document.addEventListener('DOMContentLoaded', () => {
}, { 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
const nextcloudTab = document.querySelector('a[href="#nextcloud"]');
if (nextcloudTab) {
@ -1769,12 +2080,13 @@ function displayCustomer(customer) {
}
function renderCustomerCallNumber(number) {
const clean = String(number || '').trim();
const clean = normalizePhoneValue(number);
if (!clean) return '-';
return `
<div class="d-flex gap-2 align-items-center justify-content-end flex-wrap">
<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-primary" onclick="openSmsPrompt('${escapeHtml(clean)}', '', null)">SMS</button>
</div>
`;
}
@ -1795,8 +2107,8 @@ async function ensureCustomerDetailCurrentUserId() {
}
async function customerDetailCallViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
const clean = normalizePhoneValue(number);
if (!clean) {
alert('Intet gyldigt nummer at ringe til');
return;
}
@ -2586,63 +2898,118 @@ function displayUtilityCompany(payload) {
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
}
async function loadContacts() {
const container = document.getElementById('contactsContainer');
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>
<tr>
<td colspan="6" class="text-center py-4">
<div class="spinner-border text-primary"></div>
</td>
</tr>
</tbody>
</table>
`;
function normalizePhoneValue(value) {
const clean = String(value || '').trim();
if (!clean) return '';
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
const contacts = await response.json();
if (!contacts || contacts.length === 0) {
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
return;
const lowered = clean.toLowerCase();
if (clean === '-' || clean === '—' || lowered === 'n/a' || lowered === 'null' || lowered === 'none') {
return '';
}
const rows = contacts.map(contact => {
// 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 = 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 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 title = contact.title ? escapeHtml(contact.title) : '—';
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary">Primær</span>' : '—';
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="fw-semibold">${escapeHtml(contact.name || '-') }</td>
<td class="contact-name">${nameCell}</td>
<td>${title}</td>
<td>${email}</td>
<td>${phone}</td>
<td class="contact-email">${email}</td>
<td class="contact-number">${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">
function getFilteredContacts() {
const query = contactsSearchQuery.toLowerCase();
return customerContactsData.filter(contact => {
const displayName = getContactDisplayName(contact);
const phoneValue = getContactPhoneValue(contact);
const mobileValue = getContactMobileValue(contact);
const searchable = [
displayName,
String(contact.title || contact.role || ''),
String(contact.email || ''),
phoneValue,
mobileValue
].join(' ').toLowerCase();
const matchesQuery = !query || searchable.includes(query);
const matchesCallable = !contactsOnlyCallable || Boolean(phoneValue || mobileValue);
return matchesQuery && matchesCallable;
});
}
function updateContactsResultCount(current, total) {
const countEl = document.getElementById('contactsResultCount');
if (!countEl) return;
countEl.textContent = `${current} / ${total}`;
}
function bindContactVoipButtons() {
document.querySelectorAll('.js-contact-voip').forEach((btn) => {
btn.addEventListener('click', () => {
const number = String(btn.getAttribute('data-number') || '').trim();
customerDetailCallViaYealink(number);
});
});
}
function renderContactsFromState() {
const container = document.getElementById('contactsContainer');
if (!container) return;
const renderContactsTable = (bodyHtml) => `
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Titel</th>
@ -2653,12 +3020,108 @@ async function loadContacts() {
</tr>
</thead>
<tbody>
${rows}
${bodyHtml}
</tbody>
</table>
`;
if (!customerContactsData.length) {
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
updateContactsResultCount(0, 0);
return;
}
const filteredContacts = getFilteredContacts();
updateContactsResultCount(filteredContacts.length, customerContactsData.length);
if (!filteredContacts.length) {
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter matcher søgningen</div>';
return;
}
container.innerHTML = renderContactsTable(buildContactsRows(filteredContacts));
bindContactVoipButtons();
}
function initContactsFilterControls() {
if (contactsFilterControlsInitialized) return;
const searchInput = document.getElementById('contactsSearchInput');
const callableBtn = document.getElementById('contactsOnlyCallableToggle');
const clearBtn = document.getElementById('contactsClearFilters');
if (!searchInput || !callableBtn || !clearBtn) return;
const syncCallableBtn = () => {
callableBtn.classList.toggle('btn-outline-secondary', !contactsOnlyCallable);
callableBtn.classList.toggle('btn-primary', contactsOnlyCallable);
};
searchInput.addEventListener('input', (event) => {
contactsSearchQuery = String(event.target.value || '').trim();
renderContactsFromState();
});
callableBtn.addEventListener('click', () => {
contactsOnlyCallable = !contactsOnlyCallable;
syncCallableBtn();
renderContactsFromState();
});
clearBtn.addEventListener('click', () => {
contactsSearchQuery = '';
contactsOnlyCallable = false;
searchInput.value = '';
syncCallableBtn();
renderContactsFromState();
});
syncCallableBtn();
contactsFilterControlsInitialized = true;
}
async function loadContacts() {
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 {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
const contacts = await response.json();
customerContactsData = Array.isArray(contacts) ? contacts : [];
renderContactsFromState();
} catch (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>';
}
}
@ -3646,6 +4109,61 @@ async function loadActivity() {
}, 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() {
const container = document.getElementById('customerKontaktContainer');
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() {
const commentText = document.getElementById('commentText').textContent;
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>

View File

View 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)

View 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()

View File

View 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

View File

@ -3,6 +3,7 @@ from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
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__)
@ -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")]
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]:
if user_id is None:
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:
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()
if setting_value not in {"1", "true", "yes", "on"}:
if setting_value in {"0", "false", "no", "off"}:
return False
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 {
"mails_unread": mails_unread,
"sager_open": sager_open,
"sager_urgent": sager_urgent,
"sager_unassigned": sager_unassigned,
"drift_active": drift_active,
}
@ -675,6 +731,7 @@ def build_bottom_bar_state(
timer = get_active_timer(user_id)
own_timers = get_own_timer_snapshot(user_id, paused_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)
recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10)
@ -887,8 +944,12 @@ def build_bottom_bar_state(
},
},
"kuma": {
"down": 0,
"list": [],
"down": int(drift_status.get("down") or 0),
"list": drift_status.get("list") or [],
},
"drift": {
"down": int(drift_status.get("down") or 0),
"list": drift_status.get("list") or [],
},
"eset": {
"incidents": 0,

View File

@ -1,14 +1,98 @@
import logging
import hmac
import hashlib
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 app.core.auth_dependencies import get_current_user
from app.core.config import settings
from app.core.database import execute_query
logger = logging.getLogger(__name__)
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:
if not value:
return fallback
@ -302,40 +386,53 @@ async def get_calendar_ical(
types=types,
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//BMC Hub//Calendar//DA",
"CALSCALE:GREGORIAN",
"X-WR-CALNAME:BMC Hub Kalender",
]
return _build_ical_response(events, now)
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",
])
@router.get("/calendar/ical/subscribe")
async def get_calendar_ical_subscribe_link(
request: Request,
current_user: dict = Depends(get_current_user),
):
"""Return a tokenized iCal subscription URL suitable for Outlook/Internet Calendar."""
expires_at = datetime.utcnow() + timedelta(days=3650)
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),
media_type="text/calendar; charset=utf-8",
@router.get("/calendar/ical/feed")
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)

View File

@ -431,7 +431,15 @@
<div>Status: <span id="calendarStatus">Klar</span></div>
</div>
<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 class="calendar-filter-card">
@ -574,6 +582,9 @@
const rangeLabelEl = document.getElementById('rangeLabel');
const customerSelect = document.getElementById('customerSelect');
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 allToggle = document.getElementById('allToggle');
const viewButtons = document.getElementById('viewButtons');
@ -589,6 +600,38 @@
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) {
onlyMine = activeMine;
mineToggle.classList.toggle('active', activeMine);
@ -717,6 +760,7 @@
calendar.render();
loadCustomers();
loadIcalSubscriptionLink();
mineToggle.addEventListener('click', () => setToggle(true));
allToggle.addEventListener('click', () => setToggle(false));

View File

@ -0,0 +1 @@
from .router import router

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
from .views import router

View 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