chore(release): bump version to 2.3.25

This commit is contained in:
Christian 2026-07-10 08:10:31 +02:00
parent fd5d7dd7fb
commit ca23ac1f2b
11 changed files with 456 additions and 58 deletions

View File

@ -1 +1 @@
2.3.24
2.3.25

View File

@ -28,8 +28,15 @@ class ContactCreate(BaseModel):
last_name: str = ""
email: Optional[str] = None
phone: Optional[str] = None
mobile: Optional[str] = None
title: Optional[str] = None
department: Optional[str] = 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):
@ -220,18 +227,33 @@ async def create_contact(contact: ContactCreate):
pass
insert_query = """
INSERT INTO contacts (first_name, last_name, email, phone, title, is_active)
VALUES (%s, %s, %s, %s, %s, true)
INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
"""
contact_id = execute_insert(
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
if contact.company_id:
for idx, company_id in enumerate(company_ids):
try:
link_query = """
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
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:
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
return await get_contact(contact_id)

View File

@ -817,6 +817,8 @@ let lastLoadedQueryKey = '';
let availableCompanies = [];
let selectedCompanyIds = new Set();
let currentContactsData = [];
let pendingCreateModalCustomerId = null;
let pendingCreateReturnTo = null;
let currentSort = {
key: 'name',
direction: 'asc'
@ -830,12 +832,26 @@ let visibleColumns = {
// Load contacts on page load
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();
applyColumnVisibility();
updateSortIndicators();
loadContacts();
loadCompaniesForSelect();
if (shouldOpenCreateModal) {
setTimeout(() => {
showCreateContactModal();
}, 0);
}
const searchInput = document.getElementById('searchInput');
const clearBtn = document.getElementById('searchClearBtn');
@ -915,7 +931,7 @@ function setFilter(filter) {
loadContacts();
}
async function loadContacts() {
async function loadContacts(force = false) {
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>';
@ -942,7 +958,7 @@ async function loadContacts() {
}
const queryKey = `${currentPage}|${pageSize}|${searchQuery}|${currentFilter}`;
if (queryKey === lastLoadedQueryKey) {
if (!force && queryKey === lastLoadedQueryKey) {
return;
}
lastLoadedQueryKey = queryKey;
@ -1339,6 +1355,9 @@ async function loadCompaniesForSelect() {
availableCompanies = Array.isArray(data.customers)
? 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 || '');
renderSelectedCompanies();
} catch (error) {
@ -1411,6 +1430,9 @@ function showCreateContactModal() {
document.getElementById('createContactForm').reset();
document.getElementById('isActiveInput').checked = true;
selectedCompanyIds = new Set();
if (pendingCreateModalCustomerId) {
selectedCompanyIds.add(pendingCreateModalCustomerId);
}
const companySearchInput = document.getElementById('companySearchInput');
if (companySearchInput) {
companySearchInput.value = '';
@ -1469,9 +1491,34 @@ async function createContact() {
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('createContactModal'));
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
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
alert('Kontakt oprettet succesfuldt!');

View File

@ -2156,6 +2156,13 @@ document.addEventListener('DOMContentLoaded', () => {
loadCustomerSessions();
}, { 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;
});
@ -5348,8 +5355,12 @@ async function addSyncOkTagToCustomer() {
}
function showAddContactModal() {
// TODO: Open add contact modal
console.log('Add contact for customer:', customerId);
if (!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

View File

@ -163,10 +163,17 @@ async def get_dashboard(
def count_by_type(issue_type: str, statuses: List[str]) -> Dict[str, Any]:
total = 0
impact = 0.0
for status in statuses:
data = summary.get(issue_type, {}).get(status, {})
total += data.get("count", 0)
impact += data.get("total_impact", 0.0)
if issue_type == "*":
for issue_summary in summary.values():
for status in statuses:
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}
last_runs = execute_query(
@ -185,7 +192,7 @@ async def get_dashboard(
"quantity_drop": count_by_type("quantity_drop", ["open", "investigating"]),
"price_change": count_by_type("price_change", ["open", "investigating"]),
"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(
"SELECT COUNT(*) AS c FROM invoice_error_finder_issues WHERE status IN ('open','investigating','ready_to_invoice') AND assigned_user_id IS NULL"
)["c"],
@ -222,14 +229,18 @@ async def list_issues(
if customer_id:
filters.append("i.customer_id = %s")
params.append(customer_id)
if assigned_user_id is not None and assigned_user_id.strip() != "":
try:
assigned_user_id = int(assigned_user_id)
except (TypeError, ValueError):
assigned_user_id = None
if assigned_user_id is not None:
filters.append("i.assigned_user_id IS NOT DISTINCT FROM %s")
params.append(assigned_user_id)
if assigned_user_id is not None:
normalized_assigned = assigned_user_id.strip().lower()
if normalized_assigned == "null":
filters.append("i.assigned_user_id IS NULL")
elif normalized_assigned != "":
try:
assigned_user_id_int = int(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)

View File

@ -166,7 +166,13 @@ class DetectionService:
SELECT COALESCE(simply_source_record_id, '')
FROM invoice_error_finder_issues
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
""",
@ -438,7 +444,19 @@ class DetectionService:
if customer and customer.get("deleted_at"):
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(
"""
SELECT 1
@ -524,9 +542,50 @@ class DetectionService:
)
return existing["id"]
if existing and existing.get("status") in {"ignored", "invoiced"}:
if existing and existing.get("status") == "invoiced":
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(
"""
INSERT INTO invoice_error_finder_issues (

View File

@ -4,6 +4,7 @@ Fetches invoices and invoice lines from e-conomic and persists them locally
for comparison with subscriptions and sales orders.
"""
import logging
import json
from datetime import datetime, date
from typing import Dict, List, Optional, Any
from dateutil.relativedelta import relativedelta
@ -55,6 +56,8 @@ class EconomicImportService:
]
all_invoices: List[Dict[str, Any]] = []
imported_count = 0
failed_count = 0
async with aiohttp.ClientSession() as session:
for source_type, endpoint in endpoints:
try:
@ -88,28 +91,26 @@ class EconomicImportService:
except Exception as 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
failed_count = 0
for inv in all_invoices:
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:
try:
invoice_date_raw = inv.get("date")
invoice_date = self._parse_date(invoice_date_raw)
if invoice_date and invoice_date < start_date:
continue
invoice_id = self._persist_invoice(run_id, inv)
if invoice_id:
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
invoice_id = self._persist_invoice(run_id, inv)
if invoice_id:
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)
final_status = "success" if failed_count == 0 else "partial"
self._complete_import_run(run_id, final_status, imported_count, failed_count)
logger.info(
"✅ e-conomic import complete: %s imported, %s failed",
imported_count,
@ -184,20 +185,20 @@ class EconomicImportService:
self._parse_amount(invoice.get("netAmount")),
self._parse_amount(invoice.get("vatAmount")),
self._parse_amount(invoice.get("grossAmount")),
str(invoice),
json.dumps(invoice, ensure_ascii=False, default=str),
),
)
return row["id"] if row else None
def _persist_lines(self, invoice_id: int, lines: List[Dict[str, Any]]) -> None:
if not lines:
return
execute_query(
"DELETE FROM invoice_error_finder_economic_invoice_lines WHERE invoice_id = %s",
(invoice_id,),
)
if not lines:
return
for line in lines:
product = line.get("product") or {}
execute_query(
@ -218,7 +219,7 @@ class EconomicImportService:
self._parse_amount(line.get("unitNetPrice")),
self._parse_amount(line.get("totalNetAmount")),
self._parse_amount(line.get("discountPercentage")),
str(line),
json.dumps(line, ensure_ascii=False, default=str),
),
)

View File

@ -92,8 +92,8 @@ class SimplyImportService:
return all_records
def _persist_order(self, run_id: int, raw: Dict[str, Any]) -> None:
source_record_id = str(raw.get("id") or "")
if not source_record_id:
order_source_id = str(raw.get("id") or "")
if not order_source_id:
return
# Sales orders in Simply may have line items inline
@ -124,7 +124,8 @@ class SimplyImportService:
"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(
"""
INSERT INTO invoice_error_finder_simply_sales_orders (
@ -153,7 +154,7 @@ class SimplyImportService:
""",
(
run_id,
source_record_id,
line_source_record_id,
self._safe_str(raw.get("salesorder_no"), 80),
self._safe_str(raw.get("account_id"), 80),
self._safe_str(raw.get("accountname") or raw.get("customer_name"), 255),

View File

@ -173,7 +173,7 @@ function buildQueryParams() {
const assigned = document.getElementById('filterAssigned').value;
if (assigned === 'null') {
params.set('assigned_user_id', '');
params.set('assigned_user_id', 'null');
} else if (assigned) {
params.set('assigned_user_id', assigned);
}

View 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

View 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"]