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.
This commit is contained in:
Christian 2026-06-20 03:29:51 +02:00
parent ce75f12f56
commit a604a3cc44
182 changed files with 1936 additions and 48 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

@ -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

@ -62,6 +62,24 @@
border-radius: 12px;
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;
@ -228,9 +246,33 @@
.contacts-panel {
background: var(--bg-card);
border: 1px solid rgba(0, 0, 0, 0.08);
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 {
@ -259,6 +301,18 @@
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;
}
@ -267,6 +321,7 @@
white-space: nowrap;
}
.contacts-table .contact-phone-wrap,
.contacts-table .contact-mobile-wrap {
display: inline-flex;
align-items: center;
@ -274,6 +329,12 @@
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;
@ -285,6 +346,16 @@
}
@media (max-width: 992px) {
.contacts-toolbar {
padding: 0.7rem;
}
.contacts-search {
max-width: none;
min-width: 0;
width: 100%;
}
#contactsContainer {
min-width: 920px;
}
@ -406,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
@ -622,6 +698,17 @@
<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 contacts-table">
@ -850,6 +937,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">
@ -1838,12 +2024,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>
`;
}
@ -1864,8 +2051,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;
}
@ -2655,9 +2842,193 @@ function displayUtilityCompany(payload) {
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
}
function normalizePhoneValue(value) {
const clean = String(value || '').trim();
if (!clean) return '';
const lowered = clean.toLowerCase();
if (clean === '-' || clean === '—' || lowered === 'n/a' || lowered === 'null' || lowered === 'none') {
return '';
}
// Require at least one digit to treat value as a callable/SMS-capable number.
if (!/[0-9]/.test(clean)) {
return '';
}
return clean;
}
let customerContactsData = [];
let contactsSearchQuery = '';
let contactsOnlyCallable = false;
let contactsFilterControlsInitialized = false;
function getContactDisplayName(contact) {
const firstName = String(contact.first_name || '').trim();
const lastName = String(contact.last_name || '').trim();
return [firstName, lastName].filter(Boolean).join(' ') || String(contact.name || '').trim() || '—';
}
function getContactPhoneValue(contact) {
return normalizePhoneValue(contact.phone);
}
function getContactMobileValue(contact) {
return normalizePhoneValue(contact.mobile || contact.mobile_phone);
}
function buildContactsRows(contacts) {
return contacts.map(contact => {
const displayName = getContactDisplayName(contact);
const contactId = Number(contact.id) || null;
const mobileValue = getContactMobileValue(contact);
const phoneValue = getContactPhoneValue(contact);
const titleValue = String(contact.title || contact.role || '').trim();
const nameCell = contactId
? `<a class="contact-name-link" href="/contacts/${contactId}">${escapeHtml(displayName)}</a>`
: escapeHtml(displayName);
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
const phone = phoneValue
? `<div class="contact-phone-wrap"><span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></span><button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${escapeHtml(phoneValue)}"><i class="bi bi-telephone-outbound me-1"></i>VOIP</button></div>`
: '—';
const mobile = mobileValue
? `<div class="contact-mobile-wrap"><span class="contact-number"><a href="tel:${mobileValue}">${escapeHtml(mobileValue)}</a></span><button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${escapeHtml(mobileValue)}"><i class="bi bi-telephone-outbound me-1"></i>VOIP</button><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(mobileValue)}', '${escapeHtml(displayName)}', ${contact.id || 'null'})">SMS</button></div>`
: '—';
const title = titleValue ? escapeHtml(titleValue) : '—';
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary primary-pill">Primær</span>' : '—';
return `
<tr>
<td class="contact-name">${nameCell}</td>
<td>${title}</td>
<td class="contact-email">${email}</td>
<td class="contact-number">${phone}</td>
<td>${mobile}</td>
<td>${primaryBadge}</td>
</tr>
`;
}).join('');
}
function getFilteredContacts() {
const query = contactsSearchQuery.toLowerCase();
return customerContactsData.filter(contact => {
const displayName = getContactDisplayName(contact);
const phoneValue = getContactPhoneValue(contact);
const mobileValue = getContactMobileValue(contact);
const searchable = [
displayName,
String(contact.title || contact.role || ''),
String(contact.email || ''),
phoneValue,
mobileValue
].join(' ').toLowerCase();
const matchesQuery = !query || searchable.includes(query);
const matchesCallable = !contactsOnlyCallable || Boolean(phoneValue || mobileValue);
return matchesQuery && matchesCallable;
});
}
function updateContactsResultCount(current, total) {
const countEl = document.getElementById('contactsResultCount');
if (!countEl) return;
countEl.textContent = `${current} / ${total}`;
}
function bindContactVoipButtons() {
document.querySelectorAll('.js-contact-voip').forEach((btn) => {
btn.addEventListener('click', () => {
const number = String(btn.getAttribute('data-number') || '').trim();
customerDetailCallViaYealink(number);
});
});
}
function renderContactsFromState() {
const container = document.getElementById('contactsContainer');
if (!container) return;
const renderContactsTable = (bodyHtml) => `
<table class="table table-hover align-middle contacts-table">
<thead>
<tr>
<th>Navn</th>
<th>Titel</th>
<th>Email</th>
<th>Telefon</th>
<th>Mobil</th>
<th>Primær</th>
</tr>
</thead>
<tbody>
${bodyHtml}
</tbody>
</table>
`;
if (!customerContactsData.length) {
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
updateContactsResultCount(0, 0);
return;
}
const filteredContacts = getFilteredContacts();
updateContactsResultCount(filteredContacts.length, customerContactsData.length);
if (!filteredContacts.length) {
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter matcher søgningen</div>';
return;
}
container.innerHTML = renderContactsTable(buildContactsRows(filteredContacts));
bindContactVoipButtons();
}
function initContactsFilterControls() {
if (contactsFilterControlsInitialized) return;
const searchInput = document.getElementById('contactsSearchInput');
const callableBtn = document.getElementById('contactsOnlyCallableToggle');
const clearBtn = document.getElementById('contactsClearFilters');
if (!searchInput || !callableBtn || !clearBtn) return;
const syncCallableBtn = () => {
callableBtn.classList.toggle('btn-outline-secondary', !contactsOnlyCallable);
callableBtn.classList.toggle('btn-primary', contactsOnlyCallable);
};
searchInput.addEventListener('input', (event) => {
contactsSearchQuery = String(event.target.value || '').trim();
renderContactsFromState();
});
callableBtn.addEventListener('click', () => {
contactsOnlyCallable = !contactsOnlyCallable;
syncCallableBtn();
renderContactsFromState();
});
clearBtn.addEventListener('click', () => {
contactsSearchQuery = '';
contactsOnlyCallable = false;
searchInput.value = '';
syncCallableBtn();
renderContactsFromState();
});
syncCallableBtn();
contactsFilterControlsInitialized = true;
}
async function loadContacts() {
const container = document.getElementById('contactsContainer');
initContactsFilterControls();
const renderContactsTable = (bodyHtml) => `
<table class="table table-hover align-middle contacts-table">
<thead>
@ -2689,45 +3060,12 @@ async function loadContacts() {
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 rows = contacts.map(contact => {
const firstName = String(contact.first_name || '').trim();
const lastName = String(contact.last_name || '').trim();
const displayName = [firstName, lastName].filter(Boolean).join(' ') || String(contact.name || '').trim() || '—';
const mobileValue = String(contact.mobile || contact.mobile_phone || '').trim();
const phoneValue = String(contact.phone || '').trim();
const titleValue = String(contact.title || contact.role || '').trim();
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
const phone = phoneValue ? `<span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></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-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">${escapeHtml(displayName)}</td>
<td>${title}</td>
<td class="contact-email">${email}</td>
<td class="contact-number">${phone}</td>
<td>${mobile}</td>
<td>${primaryBadge}</td>
</tr>
`;
}).join('');
container.innerHTML = `
${renderContactsTable(rows)}
`;
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>';
}
}
@ -4705,6 +5043,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');
@ -4919,6 +5331,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

@ -230,13 +230,14 @@ async def sager_liste(
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN groups g ON g.id = s.assigned_group_id
LEFT JOIN LATERAL (
SELECT cc.contact_id
FROM contact_companies cc
WHERE cc.customer_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
SELECT sk.contact_id
FROM sag_kontakter sk
WHERE sk.sag_id = s.id
AND sk.deleted_at IS NULL
ORDER BY sk.is_primary DESC NULLS LAST, sk.id ASC
LIMIT 1
) cc_first ON true
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
) sk_first ON true
LEFT JOIN contacts cont ON sk_first.contact_id = cont.id
LEFT JOIN LATERAL (
SELECT t.title, t.due_date
FROM sag_todo_steps t

Some files were not shown because too many files have changed in this diff Show More