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