diff --git a/VERSION b/VERSION
index 56f55dc..3d55c7a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.3.24
+2.3.25
diff --git a/app/contacts/backend/router_simple.py b/app/contacts/backend/router_simple.py
index c9afe6e..713fe2d 100644
--- a/app/contacts/backend/router_simple.py
+++ b/app/contacts/backend/router_simple.py
@@ -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)
diff --git a/app/contacts/frontend/contacts.html b/app/contacts/frontend/contacts.html
index 5ef9f39..5104b6f 100644
--- a/app/contacts/frontend/contacts.html
+++ b/app/contacts/frontend/contacts.html
@@ -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 = '
|
';
@@ -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!');
diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html
index 2517549..60fc59d 100644
--- a/app/customers/frontend/customer_detail.html
+++ b/app/customers/frontend/customer_detail.html
@@ -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
diff --git a/app/modules/invoice_error_finder/backend/router.py b/app/modules/invoice_error_finder/backend/router.py
index 4a118a8..335c677 100644
--- a/app/modules/invoice_error_finder/backend/router.py
+++ b/app/modules/invoice_error_finder/backend/router.py
@@ -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)
diff --git a/app/modules/invoice_error_finder/services/detection_service.py b/app/modules/invoice_error_finder/services/detection_service.py
index 51a52e0..3802ffa 100644
--- a/app/modules/invoice_error_finder/services/detection_service.py
+++ b/app/modules/invoice_error_finder/services/detection_service.py
@@ -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 (
diff --git a/app/modules/invoice_error_finder/services/economic_import_service.py b/app/modules/invoice_error_finder/services/economic_import_service.py
index b474bc7..7270eb5 100644
--- a/app/modules/invoice_error_finder/services/economic_import_service.py
+++ b/app/modules/invoice_error_finder/services/economic_import_service.py
@@ -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),
),
)
diff --git a/app/modules/invoice_error_finder/services/simply_import_service.py b/app/modules/invoice_error_finder/services/simply_import_service.py
index 2828e2e..3e474c4 100644
--- a/app/modules/invoice_error_finder/services/simply_import_service.py
+++ b/app/modules/invoice_error_finder/services/simply_import_service.py
@@ -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),
diff --git a/app/modules/invoice_error_finder/templates/issues.html b/app/modules/invoice_error_finder/templates/issues.html
index 267c1df..814b2ab 100644
--- a/app/modules/invoice_error_finder/templates/issues.html
+++ b/app/modules/invoice_error_finder/templates/issues.html
@@ -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);
}
diff --git a/tests/test_contacts_router_simple.py b/tests/test_contacts_router_simple.py
new file mode 100644
index 0000000..77db046
--- /dev/null
+++ b/tests/test_contacts_router_simple.py
@@ -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
diff --git a/tests/test_invoice_error_finder.py b/tests/test_invoice_error_finder.py
new file mode 100644
index 0000000..5d8a30e
--- /dev/null
+++ b/tests/test_invoice_error_finder.py
@@ -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"]