chore(release): bump version to 2.3.25
This commit is contained in:
parent
fd5d7dd7fb
commit
ca23ac1f2b
@ -28,8 +28,15 @@ class ContactCreate(BaseModel):
|
|||||||
last_name: str = ""
|
last_name: str = ""
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
|
mobile: Optional[str] = None
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
|
department: Optional[str] = None
|
||||||
company_id: Optional[int] = None
|
company_id: Optional[int] = None
|
||||||
|
company_ids: Optional[list[int]] = None
|
||||||
|
is_primary: bool = False
|
||||||
|
role: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ContactUpdate(BaseModel):
|
class ContactUpdate(BaseModel):
|
||||||
@ -220,18 +227,33 @@ async def create_contact(contact: ContactCreate):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
insert_query = """
|
insert_query = """
|
||||||
INSERT INTO contacts (first_name, last_name, email, phone, title, is_active)
|
INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department, is_active)
|
||||||
VALUES (%s, %s, %s, %s, %s, true)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
contact_id = execute_insert(
|
contact_id = execute_insert(
|
||||||
insert_query,
|
insert_query,
|
||||||
(contact.first_name, contact.last_name, contact.email, contact.phone, contact.title)
|
(
|
||||||
|
contact.first_name,
|
||||||
|
contact.last_name,
|
||||||
|
contact.email,
|
||||||
|
contact.phone,
|
||||||
|
contact.mobile,
|
||||||
|
contact.title,
|
||||||
|
contact.department,
|
||||||
|
contact.is_active,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
company_ids = []
|
||||||
|
if contact.company_ids:
|
||||||
|
company_ids.extend(int(company_id) for company_id in contact.company_ids if company_id)
|
||||||
|
if contact.company_id and contact.company_id not in company_ids:
|
||||||
|
company_ids.append(int(contact.company_id))
|
||||||
|
|
||||||
# Link to company if provided
|
# Link to company if provided
|
||||||
if contact.company_id:
|
for idx, company_id in enumerate(company_ids):
|
||||||
try:
|
try:
|
||||||
link_query = """
|
link_query = """
|
||||||
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||||
@ -240,9 +262,25 @@ async def create_contact(contact: ContactCreate):
|
|||||||
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
|
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
execute_insert(link_query, (contact_id, contact.company_id))
|
execute_insert(
|
||||||
|
link_query,
|
||||||
|
(
|
||||||
|
contact_id,
|
||||||
|
company_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if idx > 0 or not contact.is_primary or contact.role:
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE contact_companies
|
||||||
|
SET is_primary = %s,
|
||||||
|
role = COALESCE(%s, role)
|
||||||
|
WHERE contact_id = %s AND customer_id = %s
|
||||||
|
""",
|
||||||
|
(idx == 0 and contact.is_primary, contact.role, contact_id, company_id),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to link new contact {contact_id} to company {contact.company_id}: {e}")
|
logger.error(f"Failed to link new contact {contact_id} to company {company_id}: {e}")
|
||||||
# Don't fail the whole request, just log it
|
# Don't fail the whole request, just log it
|
||||||
|
|
||||||
return await get_contact(contact_id)
|
return await get_contact(contact_id)
|
||||||
|
|||||||
@ -817,6 +817,8 @@ let lastLoadedQueryKey = '';
|
|||||||
let availableCompanies = [];
|
let availableCompanies = [];
|
||||||
let selectedCompanyIds = new Set();
|
let selectedCompanyIds = new Set();
|
||||||
let currentContactsData = [];
|
let currentContactsData = [];
|
||||||
|
let pendingCreateModalCustomerId = null;
|
||||||
|
let pendingCreateReturnTo = null;
|
||||||
let currentSort = {
|
let currentSort = {
|
||||||
key: 'name',
|
key: 'name',
|
||||||
direction: 'asc'
|
direction: 'asc'
|
||||||
@ -830,12 +832,26 @@ let visibleColumns = {
|
|||||||
|
|
||||||
// Load contacts on page load
|
// Load contacts on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const preselectedCustomerId = Number(urlParams.get('customer_id'));
|
||||||
|
const shouldOpenCreateModal = urlParams.get('create') === '1';
|
||||||
|
pendingCreateReturnTo = urlParams.get('return_to') || null;
|
||||||
|
pendingCreateModalCustomerId = Number.isFinite(preselectedCustomerId) && preselectedCustomerId > 0
|
||||||
|
? preselectedCustomerId
|
||||||
|
: null;
|
||||||
|
|
||||||
loadTablePreferences();
|
loadTablePreferences();
|
||||||
applyColumnVisibility();
|
applyColumnVisibility();
|
||||||
updateSortIndicators();
|
updateSortIndicators();
|
||||||
loadContacts();
|
loadContacts();
|
||||||
loadCompaniesForSelect();
|
loadCompaniesForSelect();
|
||||||
|
|
||||||
|
if (shouldOpenCreateModal) {
|
||||||
|
setTimeout(() => {
|
||||||
|
showCreateContactModal();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
const searchInput = document.getElementById('searchInput');
|
const searchInput = document.getElementById('searchInput');
|
||||||
const clearBtn = document.getElementById('searchClearBtn');
|
const clearBtn = document.getElementById('searchClearBtn');
|
||||||
|
|
||||||
@ -915,7 +931,7 @@ function setFilter(filter) {
|
|||||||
loadContacts();
|
loadContacts();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadContacts() {
|
async function loadContacts(force = false) {
|
||||||
const tbody = document.getElementById('contactsTableBody');
|
const tbody = document.getElementById('contactsTableBody');
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-5"><div class="spinner-border text-primary"></div></td></tr>';
|
||||||
|
|
||||||
@ -942,7 +958,7 @@ async function loadContacts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
|
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
|
||||||
if (queryKey === lastLoadedQueryKey) {
|
if (!force && queryKey === lastLoadedQueryKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastLoadedQueryKey = queryKey;
|
lastLoadedQueryKey = queryKey;
|
||||||
@ -1339,6 +1355,9 @@ async function loadCompaniesForSelect() {
|
|||||||
availableCompanies = Array.isArray(data.customers)
|
availableCompanies = Array.isArray(data.customers)
|
||||||
? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
|
? data.customers.map((c) => ({ id: Number(c.id), name: String(c.name || '').trim() }))
|
||||||
: [];
|
: [];
|
||||||
|
if (pendingCreateModalCustomerId && availableCompanies.some((c) => c.id === pendingCreateModalCustomerId)) {
|
||||||
|
selectedCompanyIds.add(pendingCreateModalCustomerId);
|
||||||
|
}
|
||||||
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
|
renderCompanyResults(document.getElementById('companySearchInput')?.value || '');
|
||||||
renderSelectedCompanies();
|
renderSelectedCompanies();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1411,6 +1430,9 @@ function showCreateContactModal() {
|
|||||||
document.getElementById('createContactForm').reset();
|
document.getElementById('createContactForm').reset();
|
||||||
document.getElementById('isActiveInput').checked = true;
|
document.getElementById('isActiveInput').checked = true;
|
||||||
selectedCompanyIds = new Set();
|
selectedCompanyIds = new Set();
|
||||||
|
if (pendingCreateModalCustomerId) {
|
||||||
|
selectedCompanyIds.add(pendingCreateModalCustomerId);
|
||||||
|
}
|
||||||
const companySearchInput = document.getElementById('companySearchInput');
|
const companySearchInput = document.getElementById('companySearchInput');
|
||||||
if (companySearchInput) {
|
if (companySearchInput) {
|
||||||
companySearchInput.value = '';
|
companySearchInput.value = '';
|
||||||
@ -1469,9 +1491,34 @@ async function createContact() {
|
|||||||
// Close modal
|
// Close modal
|
||||||
const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal'));
|
const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal'));
|
||||||
modal.hide();
|
modal.hide();
|
||||||
|
|
||||||
|
const createdContactId = Number(newContact?.id) || null;
|
||||||
|
|
||||||
|
if (pendingCreateReturnTo) {
|
||||||
|
window.location.href = pendingCreateReturnTo;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdContactId) {
|
||||||
|
window.location.href = `/contacts/${createdContactId}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reload contact list
|
// Reload contact list
|
||||||
await loadContacts();
|
lastLoadedQueryKey = '';
|
||||||
|
currentPage = 0;
|
||||||
|
searchQuery = '';
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
toggleClearButton('');
|
||||||
|
await loadContacts(true);
|
||||||
|
|
||||||
|
if (pendingCreateModalCustomerId) {
|
||||||
|
const cleanUrl = new URL(window.location.href);
|
||||||
|
cleanUrl.searchParams.delete('create');
|
||||||
|
cleanUrl.searchParams.delete('return_to');
|
||||||
|
window.history.replaceState({}, '', cleanUrl.toString());
|
||||||
|
pendingCreateModalCustomerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
alert('Kontakt oprettet succesfuldt!');
|
alert('Kontakt oprettet succesfuldt!');
|
||||||
|
|||||||
@ -2156,6 +2156,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadCustomerSessions();
|
loadCustomerSessions();
|
||||||
}, { once: false });
|
}, { once: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.location.hash) {
|
||||||
|
const hashTab = document.querySelector(`a[data-bs-toggle="tab"][href="${window.location.hash}"]`);
|
||||||
|
if (hashTab && window.bootstrap?.Tab) {
|
||||||
|
bootstrap.Tab.getOrCreateInstance(hashTab).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
eventListenersAdded = true;
|
eventListenersAdded = true;
|
||||||
});
|
});
|
||||||
@ -5348,8 +5355,12 @@ async function addSyncOkTagToCustomer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showAddContactModal() {
|
function showAddContactModal() {
|
||||||
// TODO: Open add contact modal
|
if (!customerId) {
|
||||||
console.log('Add contact for customer:', customerId);
|
alert('Kunde-ID mangler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const returnTo = `/customers/${customerId}#contacts`;
|
||||||
|
window.location.href = `/contacts?customer_id=${encodeURIComponent(customerId)}&create=1&return_to=${encodeURIComponent(returnTo)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription management functions
|
// Subscription management functions
|
||||||
|
|||||||
@ -163,10 +163,17 @@ async def get_dashboard(
|
|||||||
def count_by_type(issue_type: str, statuses: List[str]) -> Dict[str, Any]:
|
def count_by_type(issue_type: str, statuses: List[str]) -> Dict[str, Any]:
|
||||||
total = 0
|
total = 0
|
||||||
impact = 0.0
|
impact = 0.0
|
||||||
for status in statuses:
|
if issue_type == "*":
|
||||||
data = summary.get(issue_type, {}).get(status, {})
|
for issue_summary in summary.values():
|
||||||
total += data.get("count", 0)
|
for status in statuses:
|
||||||
impact += data.get("total_impact", 0.0)
|
data = issue_summary.get(status, {})
|
||||||
|
total += data.get("count", 0)
|
||||||
|
impact += data.get("total_impact", 0.0)
|
||||||
|
else:
|
||||||
|
for status in statuses:
|
||||||
|
data = summary.get(issue_type, {}).get(status, {})
|
||||||
|
total += data.get("count", 0)
|
||||||
|
impact += data.get("total_impact", 0.0)
|
||||||
return {"count": total, "total_impact": impact}
|
return {"count": total, "total_impact": impact}
|
||||||
|
|
||||||
last_runs = execute_query(
|
last_runs = execute_query(
|
||||||
@ -185,7 +192,7 @@ async def get_dashboard(
|
|||||||
"quantity_drop": count_by_type("quantity_drop", ["open", "investigating"]),
|
"quantity_drop": count_by_type("quantity_drop", ["open", "investigating"]),
|
||||||
"price_change": count_by_type("price_change", ["open", "investigating"]),
|
"price_change": count_by_type("price_change", ["open", "investigating"]),
|
||||||
"ready_to_invoice": count_by_type("*", ["ready_to_invoice"]),
|
"ready_to_invoice": count_by_type("*", ["ready_to_invoice"]),
|
||||||
"no_owner": count_by_type("*", ["open", "investigating", "ready_to_invoice"]) if False else {
|
"no_owner": {
|
||||||
"count": execute_query_single(
|
"count": execute_query_single(
|
||||||
"SELECT COUNT(*) AS c FROM invoice_error_finder_issues WHERE status IN ('open','investigating','ready_to_invoice') AND assigned_user_id IS NULL"
|
"SELECT COUNT(*) AS c FROM invoice_error_finder_issues WHERE status IN ('open','investigating','ready_to_invoice') AND assigned_user_id IS NULL"
|
||||||
)["c"],
|
)["c"],
|
||||||
@ -222,14 +229,18 @@ async def list_issues(
|
|||||||
if customer_id:
|
if customer_id:
|
||||||
filters.append("i.customer_id = %s")
|
filters.append("i.customer_id = %s")
|
||||||
params.append(customer_id)
|
params.append(customer_id)
|
||||||
if assigned_user_id is not None and assigned_user_id.strip() != "":
|
if assigned_user_id is not None:
|
||||||
try:
|
normalized_assigned = assigned_user_id.strip().lower()
|
||||||
assigned_user_id = int(assigned_user_id)
|
if normalized_assigned == "null":
|
||||||
except (TypeError, ValueError):
|
filters.append("i.assigned_user_id IS NULL")
|
||||||
assigned_user_id = None
|
elif normalized_assigned != "":
|
||||||
if assigned_user_id is not None:
|
try:
|
||||||
filters.append("i.assigned_user_id IS NOT DISTINCT FROM %s")
|
assigned_user_id_int = int(assigned_user_id)
|
||||||
params.append(assigned_user_id)
|
except (TypeError, ValueError):
|
||||||
|
assigned_user_id_int = None
|
||||||
|
if assigned_user_id_int is not None:
|
||||||
|
filters.append("i.assigned_user_id IS NOT DISTINCT FROM %s")
|
||||||
|
params.append(assigned_user_id_int)
|
||||||
|
|
||||||
where_clause = " AND ".join(filters)
|
where_clause = " AND ".join(filters)
|
||||||
|
|
||||||
|
|||||||
@ -166,7 +166,13 @@ class DetectionService:
|
|||||||
SELECT COALESCE(simply_source_record_id, '')
|
SELECT COALESCE(simply_source_record_id, '')
|
||||||
FROM invoice_error_finder_issues
|
FROM invoice_error_finder_issues
|
||||||
WHERE issue_type = 'open_order_not_invoiced'
|
WHERE issue_type = 'open_order_not_invoiced'
|
||||||
AND status IN ('invoiced', 'ignored')
|
AND (
|
||||||
|
status = 'invoiced'
|
||||||
|
OR (
|
||||||
|
status = 'ignored'
|
||||||
|
AND (ignored_until IS NULL OR ignored_until >= CURRENT_DATE)
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
ORDER BY so.id
|
ORDER BY so.id
|
||||||
""",
|
""",
|
||||||
@ -438,7 +444,19 @@ class DetectionService:
|
|||||||
if customer and customer.get("deleted_at"):
|
if customer and customer.get("deleted_at"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Consider customer closed if all active subscriptions have ended before the reference month
|
has_subscriptions = execute_query(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM sag_subscriptions
|
||||||
|
WHERE customer_id = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(customer_id,),
|
||||||
|
)
|
||||||
|
if not has_subscriptions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Only treat customer as closed when subscription data explicitly shows no active coverage.
|
||||||
active = execute_query(
|
active = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@ -524,9 +542,50 @@ class DetectionService:
|
|||||||
)
|
)
|
||||||
return existing["id"]
|
return existing["id"]
|
||||||
|
|
||||||
if existing and existing.get("status") in {"ignored", "invoiced"}:
|
if existing and existing.get("status") == "invoiced":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if existing and existing.get("status") == "ignored":
|
||||||
|
ignored_row = execute_query_single(
|
||||||
|
"SELECT ignored_until FROM invoice_error_finder_issues WHERE id = %s",
|
||||||
|
(existing["id"],),
|
||||||
|
)
|
||||||
|
ignored_until = (ignored_row or {}).get("ignored_until")
|
||||||
|
if ignored_until is None or ignored_until >= date.today():
|
||||||
|
return None
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
"""
|
||||||
|
UPDATE invoice_error_finder_issues
|
||||||
|
SET status = %s,
|
||||||
|
ignored_until = NULL,
|
||||||
|
resolved_at = NULL,
|
||||||
|
expected_quantity = COALESCE(%s, expected_quantity),
|
||||||
|
actual_quantity = COALESCE(%s, actual_quantity),
|
||||||
|
expected_price = COALESCE(%s, expected_price),
|
||||||
|
actual_price = COALESCE(%s, actual_price),
|
||||||
|
amount_impact = COALESCE(%s, amount_impact),
|
||||||
|
last_invoice_number = COALESCE(%s, last_invoice_number),
|
||||||
|
last_invoice_date = COALESCE(%s, last_invoice_date),
|
||||||
|
sales_order_number = COALESCE(%s, sales_order_number),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
kwargs.get("status", "open"),
|
||||||
|
kwargs.get("expected_quantity"),
|
||||||
|
kwargs.get("actual_quantity"),
|
||||||
|
kwargs.get("expected_price"),
|
||||||
|
kwargs.get("actual_price"),
|
||||||
|
kwargs.get("amount_impact"),
|
||||||
|
kwargs.get("last_invoice_number"),
|
||||||
|
kwargs.get("last_invoice_date"),
|
||||||
|
kwargs.get("sales_order_number"),
|
||||||
|
existing["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return existing["id"]
|
||||||
|
|
||||||
row = execute_query_single(
|
row = execute_query_single(
|
||||||
"""
|
"""
|
||||||
INSERT INTO invoice_error_finder_issues (
|
INSERT INTO invoice_error_finder_issues (
|
||||||
|
|||||||
@ -4,6 +4,7 @@ Fetches invoices and invoice lines from e-conomic and persists them locally
|
|||||||
for comparison with subscriptions and sales orders.
|
for comparison with subscriptions and sales orders.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
@ -55,6 +56,8 @@ class EconomicImportService:
|
|||||||
]
|
]
|
||||||
|
|
||||||
all_invoices: List[Dict[str, Any]] = []
|
all_invoices: List[Dict[str, Any]] = []
|
||||||
|
imported_count = 0
|
||||||
|
failed_count = 0
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
for source_type, endpoint in endpoints:
|
for source_type, endpoint in endpoints:
|
||||||
try:
|
try:
|
||||||
@ -88,28 +91,26 @@ class EconomicImportService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("❌ Error fetching from %s: %s", endpoint, exc)
|
logger.error("❌ Error fetching from %s: %s", endpoint, exc)
|
||||||
|
|
||||||
logger.info("📥 Fetched %s e-conomic invoice headers", len(all_invoices))
|
logger.info("📥 Fetched %s e-conomic invoice headers", len(all_invoices))
|
||||||
|
|
||||||
imported_count = 0
|
for inv in all_invoices:
|
||||||
failed_count = 0
|
try:
|
||||||
|
invoice_date_raw = inv.get("date")
|
||||||
|
invoice_date = self._parse_date(invoice_date_raw)
|
||||||
|
if invoice_date and invoice_date < start_date:
|
||||||
|
continue
|
||||||
|
|
||||||
for inv in all_invoices:
|
invoice_id = self._persist_invoice(run_id, inv)
|
||||||
try:
|
if invoice_id:
|
||||||
invoice_date_raw = inv.get("date")
|
lines = await self._fetch_invoice_lines(session, inv)
|
||||||
invoice_date = self._parse_date(invoice_date_raw)
|
self._persist_lines(invoice_id, lines)
|
||||||
if invoice_date and invoice_date < start_date:
|
imported_count += 1
|
||||||
continue
|
except Exception as exc:
|
||||||
|
logger.error("❌ Failed to import invoice %s: %s", inv.get("draftInvoiceNumber") or inv.get("bookedInvoiceNumber"), exc)
|
||||||
|
failed_count += 1
|
||||||
|
|
||||||
invoice_id = self._persist_invoice(run_id, inv)
|
final_status = "success" if failed_count == 0 else "partial"
|
||||||
if invoice_id:
|
self._complete_import_run(run_id, final_status, imported_count, failed_count)
|
||||||
lines = await self._fetch_invoice_lines(session, inv)
|
|
||||||
self._persist_lines(invoice_id, lines)
|
|
||||||
imported_count += 1
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("❌ Failed to import invoice %s: %s", inv.get("draftInvoiceNumber") or inv.get("bookedInvoiceNumber"), exc)
|
|
||||||
failed_count += 1
|
|
||||||
|
|
||||||
self._complete_import_run(run_id, "success", imported_count, failed_count)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"✅ e-conomic import complete: %s imported, %s failed",
|
"✅ e-conomic import complete: %s imported, %s failed",
|
||||||
imported_count,
|
imported_count,
|
||||||
@ -184,20 +185,20 @@ class EconomicImportService:
|
|||||||
self._parse_amount(invoice.get("netAmount")),
|
self._parse_amount(invoice.get("netAmount")),
|
||||||
self._parse_amount(invoice.get("vatAmount")),
|
self._parse_amount(invoice.get("vatAmount")),
|
||||||
self._parse_amount(invoice.get("grossAmount")),
|
self._parse_amount(invoice.get("grossAmount")),
|
||||||
str(invoice),
|
json.dumps(invoice, ensure_ascii=False, default=str),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return row["id"] if row else None
|
return row["id"] if row else None
|
||||||
|
|
||||||
def _persist_lines(self, invoice_id: int, lines: List[Dict[str, Any]]) -> None:
|
def _persist_lines(self, invoice_id: int, lines: List[Dict[str, Any]]) -> None:
|
||||||
if not lines:
|
|
||||||
return
|
|
||||||
|
|
||||||
execute_query(
|
execute_query(
|
||||||
"DELETE FROM invoice_error_finder_economic_invoice_lines WHERE invoice_id = %s",
|
"DELETE FROM invoice_error_finder_economic_invoice_lines WHERE invoice_id = %s",
|
||||||
(invoice_id,),
|
(invoice_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
product = line.get("product") or {}
|
product = line.get("product") or {}
|
||||||
execute_query(
|
execute_query(
|
||||||
@ -218,7 +219,7 @@ class EconomicImportService:
|
|||||||
self._parse_amount(line.get("unitNetPrice")),
|
self._parse_amount(line.get("unitNetPrice")),
|
||||||
self._parse_amount(line.get("totalNetAmount")),
|
self._parse_amount(line.get("totalNetAmount")),
|
||||||
self._parse_amount(line.get("discountPercentage")),
|
self._parse_amount(line.get("discountPercentage")),
|
||||||
str(line),
|
json.dumps(line, ensure_ascii=False, default=str),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -92,8 +92,8 @@ class SimplyImportService:
|
|||||||
return all_records
|
return all_records
|
||||||
|
|
||||||
def _persist_order(self, run_id: int, raw: Dict[str, Any]) -> None:
|
def _persist_order(self, run_id: int, raw: Dict[str, Any]) -> None:
|
||||||
source_record_id = str(raw.get("id") or "")
|
order_source_id = str(raw.get("id") or "")
|
||||||
if not source_record_id:
|
if not order_source_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Sales orders in Simply may have line items inline
|
# Sales orders in Simply may have line items inline
|
||||||
@ -124,7 +124,8 @@ class SimplyImportService:
|
|||||||
"total_amount": self._parse_amount(raw.get("hdnGrandTotal"), 0),
|
"total_amount": self._parse_amount(raw.get("hdnGrandTotal"), 0),
|
||||||
})
|
})
|
||||||
|
|
||||||
for row in rows_to_insert:
|
for idx, row in enumerate(rows_to_insert):
|
||||||
|
line_source_record_id = order_source_id if len(rows_to_insert) == 1 else f"{order_source_id}:{idx + 1}:{self._safe_str(row['product_number'] or 'line', 100)}"
|
||||||
execute_query(
|
execute_query(
|
||||||
"""
|
"""
|
||||||
INSERT INTO invoice_error_finder_simply_sales_orders (
|
INSERT INTO invoice_error_finder_simply_sales_orders (
|
||||||
@ -153,7 +154,7 @@ class SimplyImportService:
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
run_id,
|
run_id,
|
||||||
source_record_id,
|
line_source_record_id,
|
||||||
self._safe_str(raw.get("salesorder_no"), 80),
|
self._safe_str(raw.get("salesorder_no"), 80),
|
||||||
self._safe_str(raw.get("account_id"), 80),
|
self._safe_str(raw.get("account_id"), 80),
|
||||||
self._safe_str(raw.get("accountname") or raw.get("customer_name"), 255),
|
self._safe_str(raw.get("accountname") or raw.get("customer_name"), 255),
|
||||||
|
|||||||
@ -173,7 +173,7 @@ function buildQueryParams() {
|
|||||||
|
|
||||||
const assigned = document.getElementById('filterAssigned').value;
|
const assigned = document.getElementById('filterAssigned').value;
|
||||||
if (assigned === 'null') {
|
if (assigned === 'null') {
|
||||||
params.set('assigned_user_id', '');
|
params.set('assigned_user_id', 'null');
|
||||||
} else if (assigned) {
|
} else if (assigned) {
|
||||||
params.set('assigned_user_id', assigned);
|
params.set('assigned_user_id', assigned);
|
||||||
}
|
}
|
||||||
|
|||||||
54
tests/test_contacts_router_simple.py
Normal file
54
tests/test_contacts_router_simple.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
||||||
|
def test_router_simple_create_contact_supports_extended_payload_and_company_links(monkeypatch):
|
||||||
|
from app.contacts.backend import router_simple
|
||||||
|
|
||||||
|
insert_calls = []
|
||||||
|
update_calls = []
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
if "UPDATE contact_companies" in query:
|
||||||
|
update_calls.append((query, params))
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fake_execute_insert(query, params=None):
|
||||||
|
insert_calls.append((query, params))
|
||||||
|
if "INSERT INTO contacts" in query:
|
||||||
|
return 321
|
||||||
|
return 1
|
||||||
|
|
||||||
|
async def fake_get_contact(contact_id):
|
||||||
|
return {"id": contact_id, "first_name": "Ada"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(router_simple, "execute_query", fake_execute_query)
|
||||||
|
monkeypatch.setattr(router_simple, "execute_insert", fake_execute_insert)
|
||||||
|
monkeypatch.setattr(router_simple, "get_contact", fake_get_contact)
|
||||||
|
|
||||||
|
payload = router_simple.ContactCreate(
|
||||||
|
first_name="Ada",
|
||||||
|
last_name="Lovelace",
|
||||||
|
email="ada@example.com",
|
||||||
|
phone="11111111",
|
||||||
|
mobile="22222222",
|
||||||
|
title="CTO",
|
||||||
|
department="IT",
|
||||||
|
company_ids=[10, 20],
|
||||||
|
is_primary=True,
|
||||||
|
role="Decision maker",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
created = asyncio.run(router_simple.create_contact(payload))
|
||||||
|
|
||||||
|
assert created["id"] == 321
|
||||||
|
assert any("INSERT INTO contacts" in query for query, _ in insert_calls)
|
||||||
|
company_link_params = [params for query, params in insert_calls if "INSERT INTO contact_companies" in query]
|
||||||
|
assert len(company_link_params) == 2
|
||||||
|
assert company_link_params[0][1] == 10
|
||||||
|
assert company_link_params[1][1] == 20
|
||||||
|
assert update_calls
|
||||||
176
tests/test_invoice_error_finder.py
Normal file
176
tests/test_invoice_error_finder.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
||||||
|
def test_simply_import_service_persists_multi_line_orders_without_overwrite(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.services.simply_import_service import SimplyImportService
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
if "INSERT INTO invoice_error_finder_simply_sales_orders" in query:
|
||||||
|
captured.append(params)
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.simply_import_service.execute_query",
|
||||||
|
fake_execute_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = SimplyImportService()
|
||||||
|
service._persist_order(
|
||||||
|
7,
|
||||||
|
{
|
||||||
|
"id": "SO-14",
|
||||||
|
"salesorder_no": "SO-14",
|
||||||
|
"account_id": "A-1",
|
||||||
|
"accountname": "Testkunde",
|
||||||
|
"sostatus": "Approved",
|
||||||
|
"LineItems": [
|
||||||
|
{"productnumber": "P1", "productname": "Linje 1", "quantity": 1, "listprice": 10, "netprice": 10},
|
||||||
|
{"productnumber": "P2", "productname": "Linje 2", "quantity": 2, "listprice": 20, "netprice": 40},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(captured) == 2
|
||||||
|
assert captured[0][1] != captured[1][1]
|
||||||
|
assert captured[0][1].startswith("SO-14:")
|
||||||
|
assert captured[1][1].startswith("SO-14:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_economic_import_service_persists_valid_json(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.services.economic_import_service import EconomicImportService
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "INSERT INTO invoice_error_finder_economic_invoices" in query:
|
||||||
|
captured["invoice_raw"] = params[-1]
|
||||||
|
return {"id": 11}
|
||||||
|
return {"id": 11}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.economic_import_service.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = EconomicImportService()
|
||||||
|
invoice_id = service._persist_invoice(
|
||||||
|
3,
|
||||||
|
{
|
||||||
|
"bookedInvoiceNumber": 123,
|
||||||
|
"customer": {"customerNumber": 10, "name": "ACME"},
|
||||||
|
"date": "2026-07-01",
|
||||||
|
"grossAmount": 100,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert invoice_id == 11
|
||||||
|
assert json.loads(captured["invoice_raw"])["bookedInvoiceNumber"] == 123
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_service_keeps_customers_without_subscriptions(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "SELECT deleted_at FROM customers" in query:
|
||||||
|
return {"deleted_at": None}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
if "FROM sag_subscriptions" in query:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.detection_service.execute_query",
|
||||||
|
fake_execute_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DetectionService()
|
||||||
|
assert service._is_customer_closed_or_cancelled(55, __import__("datetime").date(2026, 7, 1)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_service_reopens_expired_ignored_issue(monkeypatch):
|
||||||
|
from datetime import date
|
||||||
|
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
if "FROM invoice_error_finder_issues" in query and "SELECT id, status" in query:
|
||||||
|
return {"id": 9, "status": "ignored"}
|
||||||
|
if "SELECT ignored_until FROM invoice_error_finder_issues" in query:
|
||||||
|
return {"ignored_until": date(2026, 7, 1)}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
if "UPDATE invoice_error_finder_issues" in query:
|
||||||
|
updates.append((query, params))
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.services.detection_service.execute_query",
|
||||||
|
fake_execute_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
service = DetectionService()
|
||||||
|
issue_id = service._upsert_issue(
|
||||||
|
issue_type="missing_line",
|
||||||
|
customer_id=77,
|
||||||
|
product_number="P-1",
|
||||||
|
reference_period_start=date(2026, 7, 1),
|
||||||
|
reference_period_end=date(2026, 7, 31),
|
||||||
|
expected_quantity=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert issue_id == 9
|
||||||
|
assert updates
|
||||||
|
assert "ignored_until = NULL" in updates[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_issues_supports_unassigned_filter(monkeypatch):
|
||||||
|
from app.modules.invoice_error_finder.backend.router import list_issues
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_execute_query_single(query, params=None):
|
||||||
|
captured["count_query"] = query
|
||||||
|
return {"c": 0}
|
||||||
|
|
||||||
|
def fake_execute_query(query, params=None):
|
||||||
|
captured["list_query"] = query
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.backend.router.execute_query_single",
|
||||||
|
fake_execute_query_single,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.modules.invoice_error_finder.backend.router.execute_query",
|
||||||
|
fake_execute_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = asyncio.run(
|
||||||
|
list_issues(
|
||||||
|
assigned_user_id="null",
|
||||||
|
current_user={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["total"] == 0
|
||||||
|
assert "assigned_user_id IS NULL" in captured["count_query"]
|
||||||
|
assert "assigned_user_id IS NULL" in captured["list_query"]
|
||||||
Loading…
Reference in New Issue
Block a user