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:
parent
ce75f12f56
commit
a604a3cc44
13
.env.example
13
.env.example
@ -70,6 +70,19 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||
ECONOMIC_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)
|
||||
# =====================================================
|
||||
|
||||
@ -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
|
||||
# =====================================================
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
0
app/modules/also/backend/__init__.py
Normal file
0
app/modules/also/backend/__init__.py
Normal file
114
app/modules/also/backend/router.py
Normal file
114
app/modules/also/backend/router.py
Normal file
@ -0,0 +1,114 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
|
||||
from app.modules.also.backend.service import also_service
|
||||
from app.modules.also.models.schemas import (
|
||||
AlsoApproveResult,
|
||||
AlsoCompanyMappingUpsert,
|
||||
AlsoDifferenceItem,
|
||||
AlsoDashboardSummaryResponse,
|
||||
AlsoImportJobCreate,
|
||||
AlsoImportJobResponse,
|
||||
AlsoImportLinesRequest,
|
||||
AlsoProductMappingUpsert,
|
||||
AlsoQueueApproveRequest,
|
||||
AlsoQueueLineResponse,
|
||||
AlsoQueueProcessRequest,
|
||||
AlsoQueueProcessResult,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_id_from_request(request: Request) -> Optional[int]:
|
||||
raw_user_id = getattr(request.state, "user_id", None)
|
||||
if raw_user_id is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw_user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/also/config")
|
||||
async def also_config() -> dict:
|
||||
return also_service.get_config()
|
||||
|
||||
|
||||
@router.post("/also/import-jobs", response_model=AlsoImportJobResponse)
|
||||
async def create_import_job(payload: AlsoImportJobCreate, request: Request):
|
||||
return also_service.create_import_job(payload, _user_id_from_request(request))
|
||||
|
||||
|
||||
@router.get("/also/import-jobs", response_model=list[AlsoImportJobResponse])
|
||||
async def list_import_jobs(
|
||||
status: Optional[str] = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
):
|
||||
return also_service.list_import_jobs(status=status, limit=limit)
|
||||
|
||||
|
||||
@router.get("/also/import-jobs/{job_id}", response_model=AlsoImportJobResponse)
|
||||
async def get_import_job(job_id: int):
|
||||
return also_service.get_import_job(job_id)
|
||||
|
||||
|
||||
@router.post("/also/import-jobs/{job_id}/lines")
|
||||
async def import_lines(job_id: int, payload: AlsoImportLinesRequest):
|
||||
return also_service.import_lines(job_id=job_id, payload=payload)
|
||||
|
||||
|
||||
@router.get("/also/queue", response_model=list[AlsoQueueLineResponse])
|
||||
async def get_queue(
|
||||
status: Optional[str] = Query(default=None),
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
):
|
||||
return also_service.get_queue(status=status, limit=limit)
|
||||
|
||||
|
||||
@router.get("/also/dashboard/summary", response_model=AlsoDashboardSummaryResponse)
|
||||
async def get_dashboard_summary():
|
||||
return also_service.get_dashboard_summary()
|
||||
|
||||
|
||||
@router.get("/also/dashboard/differences", response_model=list[AlsoDifferenceItem])
|
||||
async def get_dashboard_differences(limit: int = Query(default=50, ge=1, le=200)):
|
||||
return also_service.get_monthly_differences(limit=limit)
|
||||
|
||||
|
||||
@router.post("/also/queue/run-matching", response_model=AlsoQueueProcessResult)
|
||||
async def run_matching(payload: AlsoQueueProcessRequest):
|
||||
return also_service.run_matching(
|
||||
import_job_id=payload.import_job_id,
|
||||
line_ids=payload.line_ids,
|
||||
limit=payload.limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/also/queue/run-validation", response_model=AlsoQueueProcessResult)
|
||||
async def run_validation(payload: AlsoQueueProcessRequest):
|
||||
return also_service.run_validation(
|
||||
import_job_id=payload.import_job_id,
|
||||
line_ids=payload.line_ids,
|
||||
limit=payload.limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/also/queue/approve", response_model=AlsoApproveResult)
|
||||
async def approve_queue(payload: AlsoQueueApproveRequest, request: Request):
|
||||
return also_service.approve_lines_to_drafts(
|
||||
import_job_id=payload.import_job_id,
|
||||
line_ids=payload.line_ids,
|
||||
approved_by_user_id=_user_id_from_request(request),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/also/mappings/company")
|
||||
async def upsert_company_mapping(payload: AlsoCompanyMappingUpsert):
|
||||
return also_service.upsert_company_mapping(payload)
|
||||
|
||||
|
||||
@router.put("/also/mappings/product")
|
||||
async def upsert_product_mapping(payload: AlsoProductMappingUpsert):
|
||||
return also_service.upsert_product_mapping(payload)
|
||||
907
app/modules/also/backend/service.py
Normal file
907
app/modules/also/backend/service.py
Normal file
@ -0,0 +1,907 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_query_single, table_has_column
|
||||
from app.modules.also.models.schemas import (
|
||||
AlsoCompanyMappingUpsert,
|
||||
AlsoImportJobCreate,
|
||||
AlsoImportLinesRequest,
|
||||
AlsoProductMappingUpsert,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_default(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _json_dumps(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, default=_json_default)
|
||||
|
||||
|
||||
def _normalized_text(value: Optional[Any]) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _to_decimal(value: Any, default: Decimal = Decimal("0")) -> Decimal:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return Decimal(str(value))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_cvr(vat_value: Optional[str]) -> str:
|
||||
digits = re.sub(r"[^0-9]", "", _normalized_text(vat_value))
|
||||
return digits
|
||||
|
||||
|
||||
def _line_hash(job_id: int, line_payload: Dict[str, Any]) -> str:
|
||||
source_ref = _normalized_text(line_payload.get("source_line_ref"))
|
||||
if source_ref:
|
||||
key = f"{job_id}|{source_ref}"
|
||||
else:
|
||||
key = "|".join(
|
||||
[
|
||||
str(job_id),
|
||||
_normalized_text(line_payload.get("company")).lower(),
|
||||
_normalized_text(line_payload.get("customer_id")),
|
||||
_normalized_text(line_payload.get("material_number")),
|
||||
_normalized_text(line_payload.get("vendor")).lower(),
|
||||
_normalized_text(line_payload.get("billing_start")),
|
||||
_normalized_text(line_payload.get("total_price")),
|
||||
]
|
||||
)
|
||||
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class AlsoService:
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(settings.ALSO_ENABLED)
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return bool(settings.ALSO_READ_ONLY)
|
||||
|
||||
@property
|
||||
def dry_run(self) -> bool:
|
||||
return bool(settings.ALSO_DRY_RUN)
|
||||
|
||||
def _assert_enabled(self) -> None:
|
||||
if not self.enabled:
|
||||
raise HTTPException(status_code=503, detail="ALSO integration is disabled")
|
||||
|
||||
def get_config(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"read_only": self.read_only,
|
||||
"dry_run": self.dry_run,
|
||||
"api_base_url": settings.ALSO_API_BASE_URL,
|
||||
"preferred_import_order": ["api", "xml", "json_export", "xml_export", "csv"],
|
||||
}
|
||||
|
||||
def _resolve_customer_match(self, line: Dict[str, Any]) -> Optional[int]:
|
||||
also_company_id = _normalized_text(line.get("also_company_id"))
|
||||
if also_company_id:
|
||||
mapped = execute_query_single(
|
||||
"""
|
||||
SELECT customer_id
|
||||
FROM also_company_mapping
|
||||
WHERE also_company_id = %s AND is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(also_company_id,),
|
||||
)
|
||||
if mapped and mapped.get("customer_id"):
|
||||
return int(mapped["customer_id"])
|
||||
|
||||
external_customer_id = _normalized_text(line.get("customer_id"))
|
||||
if external_customer_id:
|
||||
row = execute_query_single(
|
||||
"SELECT id FROM customers WHERE vtiger_id = %s LIMIT 1",
|
||||
(external_customer_id,),
|
||||
)
|
||||
if row and row.get("id"):
|
||||
return int(row["id"])
|
||||
|
||||
vat = _normalize_cvr(line.get("vat"))
|
||||
if vat:
|
||||
cvr_column = "cvr_number" if table_has_column("customers", "cvr_number") else None
|
||||
if cvr_column:
|
||||
row = execute_query_single(
|
||||
f"SELECT id FROM customers WHERE REPLACE(REPLACE(COALESCE({cvr_column}, ''), ' ', ''), '-', '') = %s LIMIT 1",
|
||||
(vat,),
|
||||
)
|
||||
if row and row.get("id"):
|
||||
return int(row["id"])
|
||||
|
||||
company_name = _normalized_text(line.get("company"))
|
||||
if company_name:
|
||||
row = execute_query_single(
|
||||
"SELECT id FROM customers WHERE LOWER(name) = LOWER(%s) LIMIT 1",
|
||||
(company_name,),
|
||||
)
|
||||
if row and row.get("id"):
|
||||
return int(row["id"])
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_product_match(self, line: Dict[str, Any]) -> Optional[int]:
|
||||
material_number = _normalized_text(line.get("material_number"))
|
||||
vendor = _normalized_text(line.get("vendor"))
|
||||
product_name = _normalized_text(line.get("product_name"))
|
||||
|
||||
if material_number and vendor:
|
||||
mapped = execute_query_single(
|
||||
"""
|
||||
SELECT hub_product_id
|
||||
FROM also_product_mapping
|
||||
WHERE material_number = %s
|
||||
AND LOWER(vendor) = LOWER(%s)
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(material_number, vendor),
|
||||
)
|
||||
if mapped and mapped.get("hub_product_id"):
|
||||
return int(mapped["hub_product_id"])
|
||||
|
||||
if material_number:
|
||||
supplier = execute_query_single(
|
||||
"""
|
||||
SELECT product_id
|
||||
FROM product_suppliers
|
||||
WHERE supplier_sku = %s
|
||||
AND (
|
||||
%s = '' OR LOWER(COALESCE(supplier_name, '')) = LOWER(%s) OR LOWER(COALESCE(supplier_code, '')) = LOWER(%s)
|
||||
)
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(material_number, vendor, vendor, vendor),
|
||||
)
|
||||
if supplier and supplier.get("product_id"):
|
||||
return int(supplier["product_id"])
|
||||
|
||||
if product_name:
|
||||
row = execute_query_single(
|
||||
"SELECT id FROM products WHERE LOWER(name) = LOWER(%s) LIMIT 1",
|
||||
(product_name,),
|
||||
)
|
||||
if row and row.get("id"):
|
||||
return int(row["id"])
|
||||
|
||||
return None
|
||||
|
||||
def _derive_queue_status(self, matched_customer_id: Optional[int], matched_product_id: Optional[int], has_errors: bool) -> str:
|
||||
if has_errors:
|
||||
return "error"
|
||||
if matched_customer_id and matched_product_id:
|
||||
return "ready_for_approval"
|
||||
if not matched_customer_id and matched_product_id:
|
||||
return "matching_customers"
|
||||
if matched_customer_id and not matched_product_id:
|
||||
return "matching_products"
|
||||
return "new"
|
||||
|
||||
def _build_validation_errors(self, line: Dict[str, Any], duplicate_ids: set[int]) -> List[Dict[str, Any]]:
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
if not line.get("matched_customer_id"):
|
||||
errors.append({"code": "customer_not_found", "message": "Kunde ikke fundet/matchet"})
|
||||
|
||||
if not line.get("matched_product_id"):
|
||||
errors.append({"code": "product_not_found", "message": "Produkt ikke fundet/matchet"})
|
||||
|
||||
total_price = _to_decimal(line.get("total_price"), default=_to_decimal(line.get("sales_price"), Decimal("0")))
|
||||
if total_price < 0:
|
||||
errors.append({"code": "negative_price", "message": "Negativ pris fundet"})
|
||||
if total_price == 0:
|
||||
errors.append({"code": "zero_price", "message": "Pris er 0"})
|
||||
|
||||
if not line.get("billing_start"):
|
||||
errors.append({"code": "missing_period", "message": "Manglende billing_start/periode"})
|
||||
|
||||
currency = _normalized_text(line.get("currency"))
|
||||
if not currency:
|
||||
errors.append({"code": "missing_currency", "message": "Valuta mangler"})
|
||||
|
||||
if int(line.get("id") or 0) in duplicate_ids:
|
||||
errors.append({"code": "duplicate_line", "message": "Dublet-linje fundet i samme import"})
|
||||
|
||||
if line.get("matched_customer_id") is None and _normalized_text(line.get("also_company_id")):
|
||||
has_company_mapping = execute_query_single(
|
||||
"SELECT id FROM also_company_mapping WHERE also_company_id = %s AND is_active = true LIMIT 1",
|
||||
(_normalized_text(line.get("also_company_id")),),
|
||||
)
|
||||
if not has_company_mapping:
|
||||
errors.append({"code": "missing_company_mapping", "message": "Manglende company mapping"})
|
||||
|
||||
if line.get("matched_product_id") is None and _normalized_text(line.get("material_number")) and _normalized_text(line.get("vendor")):
|
||||
has_product_mapping = execute_query_single(
|
||||
"""
|
||||
SELECT id FROM also_product_mapping
|
||||
WHERE material_number = %s AND LOWER(vendor) = LOWER(%s) AND is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(_normalized_text(line.get("material_number")), _normalized_text(line.get("vendor"))),
|
||||
)
|
||||
if not has_product_mapping:
|
||||
errors.append({"code": "missing_product_mapping", "message": "Manglende product mapping"})
|
||||
|
||||
return errors
|
||||
|
||||
def _fetch_process_lines(self, import_job_id: Optional[int], line_ids: Optional[List[int]], limit: int) -> List[Dict[str, Any]]:
|
||||
params: List[Any] = []
|
||||
where: List[str] = ["queue_status <> 'invoiced'"]
|
||||
|
||||
if import_job_id:
|
||||
where.append("import_job_id = %s")
|
||||
params.append(import_job_id)
|
||||
|
||||
if line_ids:
|
||||
placeholders = ",".join(["%s"] * len(line_ids))
|
||||
where.append(f"id IN ({placeholders})")
|
||||
params.extend(line_ids)
|
||||
|
||||
params.append(max(1, min(limit, 5000)))
|
||||
|
||||
return execute_query(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM also_import_lines
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY id ASC
|
||||
LIMIT %s
|
||||
""",
|
||||
tuple(params),
|
||||
) or []
|
||||
|
||||
def create_import_job(self, payload: AlsoImportJobCreate, imported_by_user_id: Optional[int]) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
rows = execute_query(
|
||||
"""
|
||||
INSERT INTO also_import_jobs (
|
||||
source_type,
|
||||
source_label,
|
||||
status,
|
||||
file_name,
|
||||
import_version,
|
||||
imported_by_user_id,
|
||||
raw_payload_json,
|
||||
log_json,
|
||||
started_at,
|
||||
imported_at
|
||||
) VALUES (%s, %s, 'new', %s, %s, %s, %s::jsonb, '[]'::jsonb, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
payload.source_type,
|
||||
payload.source_label,
|
||||
payload.file_name,
|
||||
payload.import_version,
|
||||
imported_by_user_id,
|
||||
_json_dumps(payload.raw_payload),
|
||||
),
|
||||
)
|
||||
return rows[0]
|
||||
|
||||
def list_import_jobs(self, status: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
||||
self._assert_enabled()
|
||||
if status:
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT
|
||||
j.*,
|
||||
COALESCE(l.line_count, 0) AS line_count
|
||||
FROM also_import_jobs j
|
||||
LEFT JOIN (
|
||||
SELECT import_job_id, COUNT(*) AS line_count
|
||||
FROM also_import_lines
|
||||
GROUP BY import_job_id
|
||||
) l ON l.import_job_id = j.id
|
||||
WHERE j.status = %s
|
||||
ORDER BY j.imported_at DESC, j.id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(status, max(1, min(limit, 500))),
|
||||
) or []
|
||||
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT
|
||||
j.*,
|
||||
COALESCE(l.line_count, 0) AS line_count
|
||||
FROM also_import_jobs j
|
||||
LEFT JOIN (
|
||||
SELECT import_job_id, COUNT(*) AS line_count
|
||||
FROM also_import_lines
|
||||
GROUP BY import_job_id
|
||||
) l ON l.import_job_id = j.id
|
||||
ORDER BY j.imported_at DESC, j.id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(limit, 500)),),
|
||||
) or []
|
||||
|
||||
def get_import_job(self, job_id: int) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
row = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
j.*,
|
||||
COALESCE(l.line_count, 0) AS line_count
|
||||
FROM also_import_jobs j
|
||||
LEFT JOIN (
|
||||
SELECT import_job_id, COUNT(*) AS line_count
|
||||
FROM also_import_lines
|
||||
WHERE import_job_id = %s
|
||||
GROUP BY import_job_id
|
||||
) l ON l.import_job_id = j.id
|
||||
WHERE j.id = %s
|
||||
""",
|
||||
(job_id, job_id),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Import job not found")
|
||||
return row
|
||||
|
||||
def import_lines(self, job_id: int, payload: AlsoImportLinesRequest) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
job = execute_query_single("SELECT id FROM also_import_jobs WHERE id = %s", (job_id,))
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Import job not found")
|
||||
|
||||
inserted = 0
|
||||
duplicates = 0
|
||||
|
||||
for idx, line in enumerate(payload.lines, start=1):
|
||||
line_data = line.model_dump()
|
||||
line_no = line_data.get("line_no") or idx
|
||||
hash_value = _line_hash(job_id, line_data)
|
||||
|
||||
existing = execute_query_single(
|
||||
"SELECT id FROM also_import_lines WHERE import_job_id = %s AND line_hash = %s",
|
||||
(job_id, hash_value),
|
||||
)
|
||||
if existing:
|
||||
duplicates += 1
|
||||
continue
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO also_import_lines (
|
||||
import_job_id,
|
||||
line_no,
|
||||
queue_status,
|
||||
source_line_ref,
|
||||
line_hash,
|
||||
also_company_id,
|
||||
company,
|
||||
customer_id,
|
||||
account_id,
|
||||
vat,
|
||||
material_number,
|
||||
product_name,
|
||||
vendor,
|
||||
cost_amount,
|
||||
sales_price,
|
||||
unit_price,
|
||||
total_price,
|
||||
currency,
|
||||
billing_start,
|
||||
charge_interval,
|
||||
billing_interval,
|
||||
billable_parameters,
|
||||
raw_line_json,
|
||||
validation_errors_json
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s::jsonb, '[]'::jsonb
|
||||
)
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
line_no,
|
||||
line_data.get("queue_status") or "new",
|
||||
line_data.get("source_line_ref"),
|
||||
hash_value,
|
||||
line_data.get("also_company_id"),
|
||||
line_data.get("company"),
|
||||
line_data.get("customer_id"),
|
||||
line_data.get("account_id"),
|
||||
line_data.get("vat"),
|
||||
line_data.get("material_number"),
|
||||
line_data.get("product_name"),
|
||||
line_data.get("vendor"),
|
||||
line_data.get("cost_amount"),
|
||||
line_data.get("sales_price"),
|
||||
line_data.get("unit_price"),
|
||||
line_data.get("total_price"),
|
||||
line_data.get("currency"),
|
||||
line_data.get("billing_start"),
|
||||
line_data.get("charge_interval"),
|
||||
line_data.get("billing_interval"),
|
||||
line_data.get("billable_parameters"),
|
||||
_json_dumps(line_data.get("raw_line") or {}),
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE also_import_jobs
|
||||
SET status = CASE WHEN %s > 0 THEN 'lines_imported' ELSE status END,
|
||||
finished_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(inserted, job_id),
|
||||
)
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"received": len(payload.lines),
|
||||
"inserted": inserted,
|
||||
"duplicates": duplicates,
|
||||
}
|
||||
|
||||
def get_queue(self, status: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
||||
self._assert_enabled()
|
||||
if status:
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT *
|
||||
FROM also_import_lines
|
||||
WHERE queue_status = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(status, max(1, min(limit, 1000))),
|
||||
) or []
|
||||
|
||||
return execute_query(
|
||||
"""
|
||||
SELECT *
|
||||
FROM also_import_lines
|
||||
ORDER BY id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(limit, 1000)),),
|
||||
) or []
|
||||
|
||||
def run_matching(self, import_job_id: Optional[int], line_ids: List[int], limit: int) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
lines = self._fetch_process_lines(import_job_id=import_job_id, line_ids=line_ids, limit=limit)
|
||||
|
||||
updated = 0
|
||||
ready = 0
|
||||
errored = 0
|
||||
|
||||
for line in lines:
|
||||
matched_customer_id = self._resolve_customer_match(line)
|
||||
matched_product_id = self._resolve_product_match(line)
|
||||
|
||||
current_errors = line.get("validation_errors_json") or []
|
||||
has_errors = bool(current_errors)
|
||||
new_status = self._derive_queue_status(matched_customer_id, matched_product_id, has_errors)
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE also_import_lines
|
||||
SET matched_customer_id = %s,
|
||||
matched_product_id = %s,
|
||||
queue_status = %s,
|
||||
matching_checked_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(matched_customer_id, matched_product_id, new_status, line["id"]),
|
||||
)
|
||||
updated += 1
|
||||
if new_status == "ready_for_approval":
|
||||
ready += 1
|
||||
if new_status == "error":
|
||||
errored += 1
|
||||
|
||||
return {
|
||||
"processed": len(lines),
|
||||
"updated": updated,
|
||||
"ready_for_approval": ready,
|
||||
"errored": errored,
|
||||
}
|
||||
|
||||
def run_validation(self, import_job_id: Optional[int], line_ids: List[int], limit: int) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
lines = self._fetch_process_lines(import_job_id=import_job_id, line_ids=line_ids, limit=limit)
|
||||
|
||||
duplicate_rows = execute_query(
|
||||
"""
|
||||
SELECT id
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
COUNT(*) OVER (
|
||||
PARTITION BY import_job_id, COALESCE(company, ''), COALESCE(material_number, ''), COALESCE(vendor, ''), COALESCE(billing_start::text, ''), COALESCE(total_price::text, '')
|
||||
) AS dup_count
|
||||
FROM also_import_lines
|
||||
WHERE queue_status <> 'invoiced'
|
||||
AND (%s::INTEGER IS NULL OR import_job_id = %s)
|
||||
) q
|
||||
WHERE q.dup_count > 1
|
||||
""",
|
||||
(import_job_id, import_job_id),
|
||||
) or []
|
||||
duplicate_ids = {int(row["id"]) for row in duplicate_rows}
|
||||
|
||||
updated = 0
|
||||
ready = 0
|
||||
errored = 0
|
||||
|
||||
for line in lines:
|
||||
errors = self._build_validation_errors(line, duplicate_ids=duplicate_ids)
|
||||
new_status = self._derive_queue_status(line.get("matched_customer_id"), line.get("matched_product_id"), bool(errors))
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE also_import_lines
|
||||
SET validation_errors_json = %s::jsonb,
|
||||
queue_status = %s,
|
||||
validation_checked_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
""",
|
||||
(_json_dumps(errors), new_status, line["id"]),
|
||||
)
|
||||
updated += 1
|
||||
if new_status == "ready_for_approval":
|
||||
ready += 1
|
||||
if new_status == "error":
|
||||
errored += 1
|
||||
|
||||
return {
|
||||
"processed": len(lines),
|
||||
"updated": updated,
|
||||
"ready_for_approval": ready,
|
||||
"errored": errored,
|
||||
}
|
||||
|
||||
def approve_lines_to_drafts(self, import_job_id: Optional[int], line_ids: List[int], approved_by_user_id: Optional[int]) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
|
||||
params: List[Any] = []
|
||||
where = ["l.queue_status = 'ready_for_approval'", "l.matched_customer_id IS NOT NULL", "l.matched_product_id IS NOT NULL"]
|
||||
|
||||
if import_job_id:
|
||||
where.append("l.import_job_id = %s")
|
||||
params.append(import_job_id)
|
||||
|
||||
if line_ids:
|
||||
placeholders = ",".join(["%s"] * len(line_ids))
|
||||
where.append(f"l.id IN ({placeholders})")
|
||||
params.extend(line_ids)
|
||||
|
||||
lines = execute_query(
|
||||
f"""
|
||||
SELECT
|
||||
l.*,
|
||||
c.name AS matched_customer_name,
|
||||
p.name AS matched_product_name
|
||||
FROM also_import_lines l
|
||||
LEFT JOIN customers c ON c.id = l.matched_customer_id
|
||||
LEFT JOIN products p ON p.id = l.matched_product_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY l.matched_customer_id, l.currency, COALESCE(l.billing_start, CURRENT_DATE), l.id
|
||||
""",
|
||||
tuple(params),
|
||||
) or []
|
||||
|
||||
if not lines:
|
||||
raise HTTPException(status_code=400, detail="No ready-for-approval lines found")
|
||||
|
||||
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for line in lines:
|
||||
period_key = str(line.get("billing_start") or datetime.utcnow().date())[:7]
|
||||
key = f"{line.get('matched_customer_id')}|{_normalized_text(line.get('currency')) or 'DKK'}|{period_key}"
|
||||
grouped.setdefault(key, []).append(line)
|
||||
|
||||
draft_ids: List[int] = []
|
||||
approved_lines = 0
|
||||
|
||||
for group_key, group_lines in grouped.items():
|
||||
first = group_lines[0]
|
||||
customer_id = int(first["matched_customer_id"])
|
||||
customer_name = first.get("matched_customer_name") or f"Kunde {customer_id}"
|
||||
currency = _normalized_text(first.get("currency")) or "DKK"
|
||||
period_key = str(first.get("billing_start") or datetime.utcnow().date())[:7]
|
||||
|
||||
draft_lines: List[Dict[str, Any]] = []
|
||||
for line in group_lines:
|
||||
quantity = _to_decimal(line.get("billable_parameters"), Decimal("1"))
|
||||
if quantity <= 0:
|
||||
quantity = Decimal("1")
|
||||
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
|
||||
amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price))
|
||||
|
||||
draft_lines.append(
|
||||
{
|
||||
"line_key": f"also:{line['id']}",
|
||||
"source_type": "also_cloud",
|
||||
"source_id": int(line["id"]),
|
||||
"reference_id": int(line["import_job_id"]),
|
||||
"description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement",
|
||||
"quantity": float(quantity),
|
||||
"unit": "stk",
|
||||
"unit_price": float(unit_price),
|
||||
"discount_percentage": 0.0,
|
||||
"amount": float(amount),
|
||||
"currency": currency,
|
||||
"status": "approved",
|
||||
"line_date": str(line.get("billing_start")) if line.get("billing_start") else None,
|
||||
"product_id": int(line["matched_product_id"]),
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer_name,
|
||||
"selected": True,
|
||||
"meta": {
|
||||
"also_material_number": line.get("material_number"),
|
||||
"also_vendor": line.get("vendor"),
|
||||
"also_import_job_id": int(line.get("import_job_id")),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
draft = execute_query_single(
|
||||
"""
|
||||
INSERT INTO ordre_drafts (
|
||||
title,
|
||||
customer_id,
|
||||
lines_json,
|
||||
notes,
|
||||
layout_number,
|
||||
created_by_user_id,
|
||||
sync_status,
|
||||
export_status_json,
|
||||
invoice_aggregate_key,
|
||||
updated_at
|
||||
) VALUES (%s, %s, %s::jsonb, %s, %s, %s, 'pending', %s::jsonb, %s, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
f"ALSO Cloud {customer_name} - {period_key}",
|
||||
customer_id,
|
||||
_json_dumps(draft_lines),
|
||||
"Genereret fra ALSO Cloud Billing approval",
|
||||
1,
|
||||
approved_by_user_id,
|
||||
_json_dumps({"source": "also_cloud_billing"}),
|
||||
f"also-cloud-{customer_id}-{period_key}",
|
||||
),
|
||||
)
|
||||
|
||||
draft_id = int(draft["id"]) if draft and draft.get("id") else None
|
||||
if not draft_id:
|
||||
raise HTTPException(status_code=500, detail="Failed creating ordre draft from ALSO approval")
|
||||
|
||||
draft_ids.append(draft_id)
|
||||
|
||||
line_id_values = [int(line["id"]) for line in group_lines]
|
||||
placeholders = ",".join(["%s"] * len(line_id_values))
|
||||
execute_query(
|
||||
f"""
|
||||
UPDATE also_import_lines
|
||||
SET queue_status = 'approved',
|
||||
approved_at = CURRENT_TIMESTAMP,
|
||||
approved_by_user_id = %s,
|
||||
order_draft_id = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
tuple([approved_by_user_id, draft_id] + line_id_values),
|
||||
)
|
||||
approved_lines += len(group_lines)
|
||||
|
||||
return {
|
||||
"approved_lines": approved_lines,
|
||||
"created_drafts": len(draft_ids),
|
||||
"draft_ids": draft_ids,
|
||||
}
|
||||
|
||||
def get_dashboard_summary(self) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
row = execute_query_single(
|
||||
"""
|
||||
WITH month_lines AS (
|
||||
SELECT *
|
||||
FROM also_import_lines
|
||||
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS monthly_revenue,
|
||||
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS monthly_cost,
|
||||
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS monthly_margin,
|
||||
COUNT(*) FILTER (WHERE matched_product_id IS NULL) AS unmatched_products,
|
||||
COUNT(*) FILTER (WHERE matched_customer_id IS NULL) AS unmatched_customers,
|
||||
COUNT(*) FILTER (WHERE queue_status = 'ready_for_approval') AS pending_approvals,
|
||||
COUNT(DISTINCT matched_customer_id) FILTER (WHERE queue_status = 'invoiced' AND matched_customer_id IS NOT NULL) AS invoiced_customers
|
||||
FROM month_lines
|
||||
""",
|
||||
(),
|
||||
) or {}
|
||||
|
||||
return {
|
||||
"monthly_revenue": row.get("monthly_revenue") or Decimal("0"),
|
||||
"monthly_cost": row.get("monthly_cost") or Decimal("0"),
|
||||
"monthly_margin": row.get("monthly_margin") or Decimal("0"),
|
||||
"unmatched_products": int(row.get("unmatched_products") or 0),
|
||||
"unmatched_customers": int(row.get("unmatched_customers") or 0),
|
||||
"pending_approvals": int(row.get("pending_approvals") or 0),
|
||||
"invoiced_customers": int(row.get("invoiced_customers") or 0),
|
||||
}
|
||||
|
||||
def get_monthly_differences(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
self._assert_enabled()
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH current_month AS (
|
||||
SELECT
|
||||
matched_customer_id,
|
||||
material_number,
|
||||
vendor,
|
||||
product_name,
|
||||
SUM(COALESCE(billable_parameters, 1)) AS qty
|
||||
FROM also_import_lines
|
||||
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
|
||||
GROUP BY matched_customer_id, material_number, vendor, product_name
|
||||
),
|
||||
previous_month AS (
|
||||
SELECT
|
||||
matched_customer_id,
|
||||
material_number,
|
||||
vendor,
|
||||
product_name,
|
||||
SUM(COALESCE(billable_parameters, 1)) AS qty
|
||||
FROM also_import_lines
|
||||
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE - INTERVAL '1 month')
|
||||
GROUP BY matched_customer_id, material_number, vendor, product_name
|
||||
),
|
||||
merged AS (
|
||||
SELECT
|
||||
COALESCE(c.matched_customer_id, p.matched_customer_id) AS customer_id,
|
||||
COALESCE(c.material_number, p.material_number) AS material_number,
|
||||
COALESCE(c.vendor, p.vendor) AS vendor,
|
||||
COALESCE(c.product_name, p.product_name) AS product_name,
|
||||
COALESCE(p.qty, 0) AS previous_month_qty,
|
||||
COALESCE(c.qty, 0) AS current_month_qty
|
||||
FROM current_month c
|
||||
FULL OUTER JOIN previous_month p
|
||||
ON COALESCE(c.matched_customer_id, 0) = COALESCE(p.matched_customer_id, 0)
|
||||
AND COALESCE(c.material_number, '') = COALESCE(p.material_number, '')
|
||||
AND COALESCE(c.vendor, '') = COALESCE(p.vendor, '')
|
||||
AND COALESCE(c.product_name, '') = COALESCE(p.product_name, '')
|
||||
)
|
||||
SELECT
|
||||
m.*,
|
||||
cu.name AS customer_name,
|
||||
CASE
|
||||
WHEN m.previous_month_qty = 0 THEN NULL
|
||||
ELSE ROUND(((m.current_month_qty - m.previous_month_qty) / NULLIF(m.previous_month_qty, 0)) * 100, 2)
|
||||
END AS change_percent
|
||||
FROM merged m
|
||||
LEFT JOIN customers cu ON cu.id = m.customer_id
|
||||
WHERE m.previous_month_qty <> m.current_month_qty
|
||||
ORDER BY ABS(COALESCE(
|
||||
CASE
|
||||
WHEN m.previous_month_qty = 0 THEN NULL
|
||||
ELSE ((m.current_month_qty - m.previous_month_qty) / NULLIF(m.previous_month_qty, 0)) * 100
|
||||
END,
|
||||
0
|
||||
)) DESC, m.current_month_qty DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(max(1, min(limit, 200)),),
|
||||
) or []
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
change_percent = row.get("change_percent")
|
||||
warning = False
|
||||
if change_percent is not None:
|
||||
try:
|
||||
warning = abs(Decimal(str(change_percent))) >= Decimal("50")
|
||||
except Exception:
|
||||
warning = False
|
||||
|
||||
results.append(
|
||||
{
|
||||
"customer_id": row.get("customer_id"),
|
||||
"customer_name": row.get("customer_name"),
|
||||
"material_number": row.get("material_number"),
|
||||
"product_name": row.get("product_name"),
|
||||
"vendor": row.get("vendor"),
|
||||
"previous_month_qty": row.get("previous_month_qty") or Decimal("0"),
|
||||
"current_month_qty": row.get("current_month_qty") or Decimal("0"),
|
||||
"change_percent": change_percent,
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def upsert_company_mapping(self, payload: AlsoCompanyMappingUpsert) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
rows = execute_query(
|
||||
"""
|
||||
INSERT INTO also_company_mapping (
|
||||
also_company_id,
|
||||
also_customer_id,
|
||||
customer_id,
|
||||
match_confidence,
|
||||
notes,
|
||||
is_active
|
||||
) VALUES (%s, %s, %s, %s, %s, true)
|
||||
ON CONFLICT (also_company_id)
|
||||
DO UPDATE SET
|
||||
also_customer_id = EXCLUDED.also_customer_id,
|
||||
customer_id = EXCLUDED.customer_id,
|
||||
match_confidence = EXCLUDED.match_confidence,
|
||||
notes = EXCLUDED.notes,
|
||||
is_active = true,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
payload.also_company_id,
|
||||
payload.also_customer_id,
|
||||
payload.customer_id,
|
||||
payload.match_confidence,
|
||||
payload.notes,
|
||||
),
|
||||
)
|
||||
return rows[0]
|
||||
|
||||
def upsert_product_mapping(self, payload: AlsoProductMappingUpsert) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
rows = execute_query(
|
||||
"""
|
||||
INSERT INTO also_product_mapping (
|
||||
material_number,
|
||||
vendor,
|
||||
hub_product_id,
|
||||
product_name_snapshot,
|
||||
is_active
|
||||
) VALUES (%s, %s, %s, %s, true)
|
||||
ON CONFLICT (material_number, vendor)
|
||||
DO UPDATE SET
|
||||
hub_product_id = EXCLUDED.hub_product_id,
|
||||
product_name_snapshot = EXCLUDED.product_name_snapshot,
|
||||
is_active = true,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
payload.material_number,
|
||||
payload.vendor,
|
||||
payload.hub_product_id,
|
||||
payload.product_name_snapshot,
|
||||
),
|
||||
)
|
||||
return rows[0]
|
||||
|
||||
|
||||
also_service = AlsoService()
|
||||
0
app/modules/also/models/__init__.py
Normal file
0
app/modules/also/models/__init__.py
Normal file
148
app/modules/also/models/schemas.py
Normal file
148
app/modules/also/models/schemas.py
Normal file
@ -0,0 +1,148 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
ALSO_SOURCE_TYPES = Literal["api", "xml", "json_export", "xml_export", "csv"]
|
||||
ALSO_QUEUE_STATUSES = Literal[
|
||||
"new",
|
||||
"matching_products",
|
||||
"matching_customers",
|
||||
"ready_for_approval",
|
||||
"approved",
|
||||
"invoiced",
|
||||
"error",
|
||||
]
|
||||
|
||||
|
||||
class AlsoImportJobCreate(BaseModel):
|
||||
source_type: ALSO_SOURCE_TYPES
|
||||
source_label: Optional[str] = None
|
||||
file_name: Optional[str] = None
|
||||
import_version: Optional[str] = None
|
||||
raw_payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AlsoImportLineInput(BaseModel):
|
||||
line_no: Optional[int] = None
|
||||
source_line_ref: Optional[str] = None
|
||||
|
||||
company: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
account_id: Optional[str] = None
|
||||
vat: Optional[str] = None
|
||||
also_company_id: Optional[str] = None
|
||||
|
||||
material_number: Optional[str] = None
|
||||
product_name: Optional[str] = None
|
||||
vendor: Optional[str] = None
|
||||
|
||||
cost_amount: Optional[Decimal] = None
|
||||
sales_price: Optional[Decimal] = None
|
||||
unit_price: Optional[Decimal] = None
|
||||
total_price: Optional[Decimal] = None
|
||||
currency: Optional[str] = None
|
||||
|
||||
billing_start: Optional[date] = None
|
||||
charge_interval: Optional[str] = None
|
||||
billing_interval: Optional[str] = None
|
||||
|
||||
billable_parameters: Optional[Decimal] = None
|
||||
queue_status: ALSO_QUEUE_STATUSES = "new"
|
||||
raw_line: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AlsoImportLinesRequest(BaseModel):
|
||||
lines: List[AlsoImportLineInput] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AlsoQueueProcessRequest(BaseModel):
|
||||
import_job_id: Optional[int] = Field(default=None, gt=0)
|
||||
line_ids: List[int] = Field(default_factory=list)
|
||||
limit: int = Field(default=500, ge=1, le=5000)
|
||||
|
||||
|
||||
class AlsoQueueApproveRequest(BaseModel):
|
||||
import_job_id: Optional[int] = Field(default=None, gt=0)
|
||||
line_ids: List[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AlsoQueueProcessResult(BaseModel):
|
||||
processed: int
|
||||
updated: int
|
||||
ready_for_approval: int
|
||||
errored: int
|
||||
|
||||
|
||||
class AlsoApproveResult(BaseModel):
|
||||
approved_lines: int
|
||||
created_drafts: int
|
||||
draft_ids: List[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AlsoCompanyMappingUpsert(BaseModel):
|
||||
also_company_id: str = Field(min_length=1)
|
||||
also_customer_id: Optional[str] = None
|
||||
customer_id: int = Field(gt=0)
|
||||
match_confidence: Optional[Decimal] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AlsoProductMappingUpsert(BaseModel):
|
||||
material_number: str = Field(min_length=1)
|
||||
vendor: str = Field(min_length=1)
|
||||
hub_product_id: int = Field(gt=0)
|
||||
product_name_snapshot: Optional[str] = None
|
||||
|
||||
|
||||
class AlsoImportJobResponse(BaseModel):
|
||||
id: int
|
||||
source_type: str
|
||||
source_label: Optional[str] = None
|
||||
status: str
|
||||
file_name: Optional[str] = None
|
||||
import_version: Optional[str] = None
|
||||
imported_by_user_id: Optional[int] = None
|
||||
imported_at: datetime
|
||||
line_count: int = 0
|
||||
|
||||
|
||||
class AlsoQueueLineResponse(BaseModel):
|
||||
id: int
|
||||
import_job_id: int
|
||||
queue_status: str
|
||||
company: Optional[str] = None
|
||||
customer_id: Optional[str] = None
|
||||
material_number: Optional[str] = None
|
||||
product_name: Optional[str] = None
|
||||
vendor: Optional[str] = None
|
||||
total_price: Optional[Decimal] = None
|
||||
currency: Optional[str] = None
|
||||
matched_customer_id: Optional[int] = None
|
||||
matched_product_id: Optional[int] = None
|
||||
order_draft_id: Optional[int] = None
|
||||
validation_errors_json: List[Dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AlsoDashboardSummaryResponse(BaseModel):
|
||||
monthly_revenue: Decimal
|
||||
monthly_cost: Decimal
|
||||
monthly_margin: Decimal
|
||||
unmatched_products: int
|
||||
unmatched_customers: int
|
||||
pending_approvals: int
|
||||
invoiced_customers: int
|
||||
|
||||
|
||||
class AlsoDifferenceItem(BaseModel):
|
||||
customer_id: Optional[int] = None
|
||||
customer_name: Optional[str] = None
|
||||
material_number: Optional[str] = None
|
||||
product_name: Optional[str] = None
|
||||
vendor: Optional[str] = None
|
||||
previous_month_qty: Decimal
|
||||
current_month_qty: Decimal
|
||||
change_percent: Optional[Decimal] = None
|
||||
warning: bool = False
|
||||
@ -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
Loading…
Reference in New Issue
Block a user