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_READ_ONLY=true # Set to false ONLY after testing
|
||||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# ALSO Cloud Marketplace Integration (Optional)
|
||||||
|
# =====================================================
|
||||||
|
ALSO_ENABLED=false
|
||||||
|
ALSO_API_BASE_URL=
|
||||||
|
ALSO_API_KEY=
|
||||||
|
ALSO_API_SECRET=
|
||||||
|
ALSO_TIMEOUT_SECONDS=20
|
||||||
|
|
||||||
|
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede importer/sync
|
||||||
|
ALSO_READ_ONLY=true
|
||||||
|
ALSO_DRY_RUN=true
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# FedEx Integration (Optional)
|
# FedEx Integration (Optional)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@ -87,6 +87,20 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here
|
|||||||
ECONOMIC_READ_ONLY=true
|
ECONOMIC_READ_ONLY=true
|
||||||
ECONOMIC_DRY_RUN=true
|
ECONOMIC_DRY_RUN=true
|
||||||
|
|
||||||
|
# =====================================================
|
||||||
|
# ALSO Cloud Marketplace Integration - Production (Optional)
|
||||||
|
# =====================================================
|
||||||
|
ALSO_ENABLED=false
|
||||||
|
ALSO_API_BASE_URL=
|
||||||
|
ALSO_API_KEY=
|
||||||
|
ALSO_API_SECRET=
|
||||||
|
ALSO_TIMEOUT_SECONDS=20
|
||||||
|
|
||||||
|
# 🚨 SAFETY SWITCHES
|
||||||
|
# Start ALTID med begge sat til true i ny production deployment!
|
||||||
|
ALSO_READ_ONLY=true
|
||||||
|
ALSO_DRY_RUN=true
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# FedEx Integration - Production
|
# FedEx Integration - Production
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@ -315,6 +315,15 @@ class Settings(BaseSettings):
|
|||||||
FEDEX_BASE_URL: str = ""
|
FEDEX_BASE_URL: str = ""
|
||||||
FEDEX_TIMEOUT_SECONDS: int = 20
|
FEDEX_TIMEOUT_SECONDS: int = 20
|
||||||
|
|
||||||
|
# ALSO Cloud Marketplace Integration
|
||||||
|
ALSO_ENABLED: bool = False
|
||||||
|
ALSO_READ_ONLY: bool = True
|
||||||
|
ALSO_DRY_RUN: bool = True
|
||||||
|
ALSO_API_BASE_URL: str = ""
|
||||||
|
ALSO_API_KEY: str = ""
|
||||||
|
ALSO_API_SECRET: str = ""
|
||||||
|
ALSO_TIMEOUT_SECONDS: int = 20
|
||||||
|
|
||||||
# Bottom bar module
|
# Bottom bar module
|
||||||
BOTTOM_BAR_ENABLED: bool = False
|
BOTTOM_BAR_ENABLED: bool = False
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ Adapted from OmniSync for BMC Hub
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict, Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -1780,3 +1780,139 @@ async def get_subscription_billing_matrix(
|
|||||||
logger.error(f"❌ Error generating billing matrix: {e}")
|
logger.error(f"❌ Error generating billing matrix: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}/acmp")
|
||||||
|
async def get_customer_acmp_overview(
|
||||||
|
customer_id: int,
|
||||||
|
months: int = Query(default=6, ge=1, le=24, description="Months for trend data"),
|
||||||
|
):
|
||||||
|
"""Return ACMP (ALSO Cloud Marketplace) detail and statistics for one customer."""
|
||||||
|
try:
|
||||||
|
customer = execute_query_single("SELECT id, name FROM customers WHERE id = %s", (customer_id,))
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
||||||
|
|
||||||
|
table_check = execute_query_single("SELECT to_regclass('public.also_import_lines') AS table_name")
|
||||||
|
if not table_check or not table_check.get("table_name"):
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"customer_name": customer.get("name"),
|
||||||
|
"available": False,
|
||||||
|
"message": "ACMP data is not available yet (missing also_import_lines table)",
|
||||||
|
"summary": {},
|
||||||
|
"status_breakdown": [],
|
||||||
|
"products": [],
|
||||||
|
"monthly": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = execute_query_single(
|
||||||
|
"""
|
||||||
|
WITH scoped AS (
|
||||||
|
SELECT *
|
||||||
|
FROM also_import_lines
|
||||||
|
WHERE matched_customer_id = %s
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS line_count,
|
||||||
|
COUNT(DISTINCT COALESCE(material_number, product_name, 'ukendt')) AS distinct_products,
|
||||||
|
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
|
||||||
|
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
|
||||||
|
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
|
||||||
|
COUNT(*) FILTER (WHERE queue_status = 'ready_for_approval') AS pending_approvals,
|
||||||
|
COUNT(*) FILTER (WHERE queue_status = 'error') AS error_count,
|
||||||
|
COALESCE(
|
||||||
|
SUM(COALESCE(total_price, sales_price, 0)) FILTER (
|
||||||
|
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS revenue_month,
|
||||||
|
COALESCE(
|
||||||
|
SUM(COALESCE(cost_amount, 0)) FILTER (
|
||||||
|
WHERE date_trunc('month', COALESCE(billing_start::timestamp, created_at)) = date_trunc('month', CURRENT_DATE)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) AS cost_month
|
||||||
|
FROM scoped
|
||||||
|
""",
|
||||||
|
(customer_id,),
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
status_breakdown = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT queue_status, COUNT(*)::INTEGER AS count
|
||||||
|
FROM also_import_lines
|
||||||
|
WHERE matched_customer_id = %s
|
||||||
|
GROUP BY queue_status
|
||||||
|
ORDER BY count DESC, queue_status ASC
|
||||||
|
""",
|
||||||
|
(customer_id,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
products = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COALESCE(material_number, 'N/A') AS material_number,
|
||||||
|
COALESCE(vendor, 'N/A') AS vendor,
|
||||||
|
COALESCE(product_name, 'Ukendt produkt') AS product_name,
|
||||||
|
COUNT(*)::INTEGER AS line_count,
|
||||||
|
COALESCE(SUM(COALESCE(billable_parameters, 1)), 0) AS quantity_total,
|
||||||
|
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
|
||||||
|
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
|
||||||
|
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
|
||||||
|
MAX(billing_start) AS last_billing_start,
|
||||||
|
MAX(created_at) AS last_seen_at
|
||||||
|
FROM also_import_lines
|
||||||
|
WHERE matched_customer_id = %s
|
||||||
|
GROUP BY COALESCE(material_number, 'N/A'), COALESCE(vendor, 'N/A'), COALESCE(product_name, 'Ukendt produkt')
|
||||||
|
ORDER BY revenue_total DESC, quantity_total DESC
|
||||||
|
LIMIT 200
|
||||||
|
""",
|
||||||
|
(customer_id,),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
monthly = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
TO_CHAR(date_trunc('month', COALESCE(billing_start::timestamp, created_at)), 'YYYY-MM') AS month,
|
||||||
|
COALESCE(SUM(COALESCE(billable_parameters, 1)), 0) AS quantity_total,
|
||||||
|
COALESCE(SUM(COALESCE(total_price, sales_price, 0)), 0) AS revenue_total,
|
||||||
|
COALESCE(SUM(COALESCE(cost_amount, 0)), 0) AS cost_total,
|
||||||
|
COALESCE(SUM(COALESCE(total_price, sales_price, 0) - COALESCE(cost_amount, 0)), 0) AS margin_total,
|
||||||
|
COUNT(*)::INTEGER AS lines
|
||||||
|
FROM also_import_lines
|
||||||
|
WHERE matched_customer_id = %s
|
||||||
|
AND date_trunc('month', COALESCE(billing_start::timestamp, created_at)) >= date_trunc('month', CURRENT_DATE) - ((%s - 1) * INTERVAL '1 month')
|
||||||
|
GROUP BY date_trunc('month', COALESCE(billing_start::timestamp, created_at))
|
||||||
|
ORDER BY month ASC
|
||||||
|
""",
|
||||||
|
(customer_id, months),
|
||||||
|
) or []
|
||||||
|
|
||||||
|
response: Dict[str, Any] = {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"customer_name": customer.get("name"),
|
||||||
|
"available": True,
|
||||||
|
"summary": {
|
||||||
|
"line_count": int(summary.get("line_count") or 0),
|
||||||
|
"distinct_products": int(summary.get("distinct_products") or 0),
|
||||||
|
"revenue_total": summary.get("revenue_total") or 0,
|
||||||
|
"cost_total": summary.get("cost_total") or 0,
|
||||||
|
"margin_total": summary.get("margin_total") or 0,
|
||||||
|
"revenue_month": summary.get("revenue_month") or 0,
|
||||||
|
"cost_month": summary.get("cost_month") or 0,
|
||||||
|
"margin_month": (summary.get("revenue_month") or 0) - (summary.get("cost_month") or 0),
|
||||||
|
"pending_approvals": int(summary.get("pending_approvals") or 0),
|
||||||
|
"error_count": int(summary.get("error_count") or 0),
|
||||||
|
},
|
||||||
|
"status_breakdown": status_breakdown,
|
||||||
|
"products": products,
|
||||||
|
"monthly": monthly,
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("❌ Error fetching ACMP overview for customer %s: %s", customer_id, e)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,24 @@
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.acmp-stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid rgba(15, 76, 117, 0.14);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acmp-stat-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acmp-stat-value {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -228,9 +246,33 @@
|
|||||||
|
|
||||||
.contacts-panel {
|
.contacts-panel {
|
||||||
background: var(--bg-card);
|
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;
|
border-radius: 14px;
|
||||||
overflow: hidden;
|
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 {
|
.contacts-table {
|
||||||
@ -259,6 +301,18 @@
|
|||||||
color: var(--text-primary);
|
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 {
|
.contacts-table .contact-email a {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@ -267,6 +321,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contacts-table .contact-phone-wrap,
|
||||||
.contacts-table .contact-mobile-wrap {
|
.contacts-table .contact-mobile-wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -274,6 +329,12 @@
|
|||||||
flex-wrap: wrap;
|
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 {
|
.contacts-table .contact-mobile-wrap .btn {
|
||||||
padding: 0.18rem 0.52rem;
|
padding: 0.18rem 0.52rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@ -285,6 +346,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
|
.contacts-toolbar {
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contacts-search {
|
||||||
|
max-width: none;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
#contactsContainer {
|
#contactsContainer {
|
||||||
min-width: 920px;
|
min-width: 920px;
|
||||||
}
|
}
|
||||||
@ -406,6 +477,11 @@
|
|||||||
<i class="bi bi-table"></i>Abonnements Matrix
|
<i class="bi bi-table"></i>Abonnements Matrix
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#acmp">
|
||||||
|
<i class="bi bi-cloud"></i>ACMP
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" data-bs-toggle="tab" href="#locations">
|
<a class="nav-link" data-bs-toggle="tab" href="#locations">
|
||||||
<i class="bi bi-geo-alt"></i>Lokationer
|
<i class="bi bi-geo-alt"></i>Lokationer
|
||||||
@ -622,6 +698,17 @@
|
|||||||
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
<i class="bi bi-plus-lg me-2"></i>Tilføj Kontakt
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="contacts-toolbar">
|
||||||
|
<div class="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="contacts-panel">
|
||||||
<div class="table-responsive" id="contactsContainer">
|
<div class="table-responsive" id="contactsContainer">
|
||||||
<table class="table table-hover align-middle contacts-table">
|
<table class="table table-hover align-middle contacts-table">
|
||||||
@ -850,6 +937,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ACMP Tab -->
|
||||||
|
<div class="tab-pane fade" id="acmp">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h5 class="fw-bold mb-0">
|
||||||
|
<i class="bi bi-cloud me-2"></i>ACMP Detaljer
|
||||||
|
<small class="text-muted fw-normal">(ALSO Cloud Marketplace)</small>
|
||||||
|
</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadAcmpOverview()" title="Opdater ACMP-data">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="acmpLoading" class="text-center py-5">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
<p class="text-muted mt-2">Henter ACMP-data...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="acmpEmpty" class="text-center py-5" style="display:none;">
|
||||||
|
<p class="text-muted mb-0">Ingen ACMP-data for denne kunde endnu</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="acmpContainer" style="display:none;">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="acmp-stat-card">
|
||||||
|
<div class="acmp-stat-label">Månedens omsætning</div>
|
||||||
|
<div class="acmp-stat-value" id="acmpRevenueMonth">0 kr.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="acmp-stat-card">
|
||||||
|
<div class="acmp-stat-label">Månedens dækningsbidrag</div>
|
||||||
|
<div class="acmp-stat-value" id="acmpMarginMonth">0 kr.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="acmp-stat-card">
|
||||||
|
<div class="acmp-stat-label">Produkter</div>
|
||||||
|
<div class="acmp-stat-value" id="acmpDistinctProducts">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-3 col-md-6">
|
||||||
|
<div class="acmp-stat-card">
|
||||||
|
<div class="acmp-stat-label">Ventende godkendelser</div>
|
||||||
|
<div class="acmp-stat-value" id="acmpPendingApprovals">0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="info-card">
|
||||||
|
<h6 class="fw-bold mb-3">Statusfordeling</h6>
|
||||||
|
<div id="acmpStatusBreakdown" class="d-flex flex-wrap gap-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="info-card">
|
||||||
|
<h6 class="fw-bold mb-3">Månedlig udvikling (seneste 6 mdr)</h6>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Måned</th>
|
||||||
|
<th class="text-end">Oms.</th>
|
||||||
|
<th class="text-end">DB</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="acmpMonthlyRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="fw-bold mb-0">Produkter</h6>
|
||||||
|
<small class="text-muted">Top 200 pr. omsætning</small>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th>Materiale</th>
|
||||||
|
<th>Vendor</th>
|
||||||
|
<th class="text-end">Antal</th>
|
||||||
|
<th class="text-end">Omsætning</th>
|
||||||
|
<th class="text-end">DB</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="acmpProductsRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Locations Tab -->
|
<!-- Locations Tab -->
|
||||||
<div class="tab-pane fade" id="locations">
|
<div class="tab-pane fade" id="locations">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
@ -1838,12 +2024,13 @@ function displayCustomer(customer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCustomerCallNumber(number) {
|
function renderCustomerCallNumber(number) {
|
||||||
const clean = String(number || '').trim();
|
const clean = normalizePhoneValue(number);
|
||||||
if (!clean) return '-';
|
if (!clean) return '-';
|
||||||
return `
|
return `
|
||||||
<div class="d-flex gap-2 align-items-center justify-content-end flex-wrap">
|
<div class="d-flex gap-2 align-items-center justify-content-end flex-wrap">
|
||||||
<span>${escapeHtml(clean)}</span>
|
<span>${escapeHtml(clean)}</span>
|
||||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="customerDetailCallViaYealink('${escapeHtml(clean)}')">Ring op</button>
|
<button type="button" class="btn btn-sm btn-outline-success" onclick="customerDetailCallViaYealink('${escapeHtml(clean)}')">Ring op</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(clean)}', '', null)">SMS</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -1864,8 +2051,8 @@ async function ensureCustomerDetailCurrentUserId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function customerDetailCallViaYealink(number) {
|
async function customerDetailCallViaYealink(number) {
|
||||||
const clean = String(number || '').trim();
|
const clean = normalizePhoneValue(number);
|
||||||
if (!clean || clean === '-') {
|
if (!clean) {
|
||||||
alert('Intet gyldigt nummer at ringe til');
|
alert('Intet gyldigt nummer at ringe til');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2655,9 +2842,193 @@ function displayUtilityCompany(payload) {
|
|||||||
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
|
contactEl.innerHTML = contactPieces.length > 0 ? contactPieces.join(' • ') : 'Ingen kontaktinfo';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePhoneValue(value) {
|
||||||
|
const clean = String(value || '').trim();
|
||||||
|
if (!clean) return '';
|
||||||
|
|
||||||
|
const lowered = clean.toLowerCase();
|
||||||
|
if (clean === '-' || clean === '—' || lowered === 'n/a' || lowered === 'null' || lowered === 'none') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require at least one digit to treat value as a callable/SMS-capable number.
|
||||||
|
if (!/[0-9]/.test(clean)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let customerContactsData = [];
|
||||||
|
let contactsSearchQuery = '';
|
||||||
|
let contactsOnlyCallable = false;
|
||||||
|
let contactsFilterControlsInitialized = false;
|
||||||
|
|
||||||
|
function getContactDisplayName(contact) {
|
||||||
|
const firstName = String(contact.first_name || '').trim();
|
||||||
|
const lastName = String(contact.last_name || '').trim();
|
||||||
|
return [firstName, lastName].filter(Boolean).join(' ') || String(contact.name || '').trim() || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContactPhoneValue(contact) {
|
||||||
|
return normalizePhoneValue(contact.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContactMobileValue(contact) {
|
||||||
|
return normalizePhoneValue(contact.mobile || contact.mobile_phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContactsRows(contacts) {
|
||||||
|
return contacts.map(contact => {
|
||||||
|
const displayName = getContactDisplayName(contact);
|
||||||
|
const contactId = Number(contact.id) || null;
|
||||||
|
const mobileValue = getContactMobileValue(contact);
|
||||||
|
const phoneValue = getContactPhoneValue(contact);
|
||||||
|
const titleValue = String(contact.title || contact.role || '').trim();
|
||||||
|
const nameCell = contactId
|
||||||
|
? `<a class="contact-name-link" href="/contacts/${contactId}">${escapeHtml(displayName)}</a>`
|
||||||
|
: escapeHtml(displayName);
|
||||||
|
|
||||||
|
const email = contact.email ? `<a href="mailto:${contact.email}">${escapeHtml(contact.email)}</a>` : '—';
|
||||||
|
const phone = phoneValue
|
||||||
|
? `<div class="contact-phone-wrap"><span class="contact-number"><a href="tel:${phoneValue}">${escapeHtml(phoneValue)}</a></span><button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${escapeHtml(phoneValue)}"><i class="bi bi-telephone-outbound me-1"></i>VOIP</button></div>`
|
||||||
|
: '—';
|
||||||
|
const mobile = mobileValue
|
||||||
|
? `<div class="contact-mobile-wrap"><span class="contact-number"><a href="tel:${mobileValue}">${escapeHtml(mobileValue)}</a></span><button type="button" class="btn btn-sm btn-outline-success btn-voip js-contact-voip" data-number="${escapeHtml(mobileValue)}"><i class="bi bi-telephone-outbound me-1"></i>VOIP</button><button type="button" class="btn btn-sm btn-outline-primary" onclick="openSmsPrompt('${escapeHtml(mobileValue)}', '${escapeHtml(displayName)}', ${contact.id || 'null'})">SMS</button></div>`
|
||||||
|
: '—';
|
||||||
|
const title = titleValue ? escapeHtml(titleValue) : '—';
|
||||||
|
const primaryBadge = contact.is_primary ? '<span class="badge bg-primary primary-pill">Primær</span>' : '—';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td class="contact-name">${nameCell}</td>
|
||||||
|
<td>${title}</td>
|
||||||
|
<td class="contact-email">${email}</td>
|
||||||
|
<td class="contact-number">${phone}</td>
|
||||||
|
<td>${mobile}</td>
|
||||||
|
<td>${primaryBadge}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredContacts() {
|
||||||
|
const query = contactsSearchQuery.toLowerCase();
|
||||||
|
return customerContactsData.filter(contact => {
|
||||||
|
const displayName = getContactDisplayName(contact);
|
||||||
|
const phoneValue = getContactPhoneValue(contact);
|
||||||
|
const mobileValue = getContactMobileValue(contact);
|
||||||
|
const searchable = [
|
||||||
|
displayName,
|
||||||
|
String(contact.title || contact.role || ''),
|
||||||
|
String(contact.email || ''),
|
||||||
|
phoneValue,
|
||||||
|
mobileValue
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
|
||||||
|
const matchesQuery = !query || searchable.includes(query);
|
||||||
|
const matchesCallable = !contactsOnlyCallable || Boolean(phoneValue || mobileValue);
|
||||||
|
return matchesQuery && matchesCallable;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContactsResultCount(current, total) {
|
||||||
|
const countEl = document.getElementById('contactsResultCount');
|
||||||
|
if (!countEl) return;
|
||||||
|
countEl.textContent = `${current} / ${total}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindContactVoipButtons() {
|
||||||
|
document.querySelectorAll('.js-contact-voip').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const number = String(btn.getAttribute('data-number') || '').trim();
|
||||||
|
customerDetailCallViaYealink(number);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContactsFromState() {
|
||||||
|
const container = document.getElementById('contactsContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const renderContactsTable = (bodyHtml) => `
|
||||||
|
<table class="table table-hover align-middle contacts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Navn</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Telefon</th>
|
||||||
|
<th>Mobil</th>
|
||||||
|
<th>Primær</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${bodyHtml}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!customerContactsData.length) {
|
||||||
|
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
|
||||||
|
updateContactsResultCount(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredContacts = getFilteredContacts();
|
||||||
|
updateContactsResultCount(filteredContacts.length, customerContactsData.length);
|
||||||
|
|
||||||
|
if (!filteredContacts.length) {
|
||||||
|
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter matcher søgningen</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = renderContactsTable(buildContactsRows(filteredContacts));
|
||||||
|
bindContactVoipButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initContactsFilterControls() {
|
||||||
|
if (contactsFilterControlsInitialized) return;
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('contactsSearchInput');
|
||||||
|
const callableBtn = document.getElementById('contactsOnlyCallableToggle');
|
||||||
|
const clearBtn = document.getElementById('contactsClearFilters');
|
||||||
|
|
||||||
|
if (!searchInput || !callableBtn || !clearBtn) return;
|
||||||
|
|
||||||
|
const syncCallableBtn = () => {
|
||||||
|
callableBtn.classList.toggle('btn-outline-secondary', !contactsOnlyCallable);
|
||||||
|
callableBtn.classList.toggle('btn-primary', contactsOnlyCallable);
|
||||||
|
};
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', (event) => {
|
||||||
|
contactsSearchQuery = String(event.target.value || '').trim();
|
||||||
|
renderContactsFromState();
|
||||||
|
});
|
||||||
|
|
||||||
|
callableBtn.addEventListener('click', () => {
|
||||||
|
contactsOnlyCallable = !contactsOnlyCallable;
|
||||||
|
syncCallableBtn();
|
||||||
|
renderContactsFromState();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
contactsSearchQuery = '';
|
||||||
|
contactsOnlyCallable = false;
|
||||||
|
searchInput.value = '';
|
||||||
|
syncCallableBtn();
|
||||||
|
renderContactsFromState();
|
||||||
|
});
|
||||||
|
|
||||||
|
syncCallableBtn();
|
||||||
|
contactsFilterControlsInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadContacts() {
|
async function loadContacts() {
|
||||||
const container = document.getElementById('contactsContainer');
|
const container = document.getElementById('contactsContainer');
|
||||||
|
|
||||||
|
initContactsFilterControls();
|
||||||
|
|
||||||
const renderContactsTable = (bodyHtml) => `
|
const renderContactsTable = (bodyHtml) => `
|
||||||
<table class="table table-hover align-middle contacts-table">
|
<table class="table table-hover align-middle contacts-table">
|
||||||
<thead>
|
<thead>
|
||||||
@ -2689,45 +3060,12 @@ async function loadContacts() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
const response = await fetch(`/api/v1/customers/${customerId}/contacts`);
|
||||||
const contacts = await response.json();
|
const contacts = await response.json();
|
||||||
|
customerContactsData = Array.isArray(contacts) ? contacts : [];
|
||||||
if (!contacts || contacts.length === 0) {
|
renderContactsFromState();
|
||||||
container.innerHTML = '<div class="text-center py-5 text-muted">Ingen kontakter endnu</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = contacts.map(contact => {
|
|
||||||
const 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)}
|
|
||||||
`;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load contacts:', error);
|
console.error('Failed to load contacts:', error);
|
||||||
|
customerContactsData = [];
|
||||||
|
updateContactsResultCount(0, 0);
|
||||||
container.innerHTML = '<div class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
|
container.innerHTML = '<div class="text-center py-5 text-danger">Kunne ikke indlæse kontakter</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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() {
|
function editInternalComment() {
|
||||||
const commentText = document.getElementById('commentText').textContent;
|
const commentText = document.getElementById('commentText').textContent;
|
||||||
const commentInput = document.getElementById('internalCommentInput');
|
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>
|
</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 users u ON u.user_id = s.ansvarlig_bruger_id
|
||||||
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
LEFT JOIN groups g ON g.id = s.assigned_group_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT cc.contact_id
|
SELECT sk.contact_id
|
||||||
FROM contact_companies cc
|
FROM sag_kontakter sk
|
||||||
WHERE cc.customer_id = c.id
|
WHERE sk.sag_id = s.id
|
||||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
AND sk.deleted_at IS NULL
|
||||||
|
ORDER BY sk.is_primary DESC NULLS LAST, sk.id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) cc_first ON true
|
) sk_first ON true
|
||||||
LEFT JOIN contacts cont ON cc_first.contact_id = cont.id
|
LEFT JOIN contacts cont ON sk_first.contact_id = cont.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT t.title, t.due_date
|
SELECT t.title, t.due_date
|
||||||
FROM sag_todo_steps t
|
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