- Added customer_id and contact_id filters to the list_opportunities endpoint for improved querying. - Implemented a redirect for opportunity detail pages to a new format. - Refactored SubscriptionMatrixService to load invoices from a local snapshot instead of an external service, improving performance and reliability. - Updated settings to include 'pipeline' as a case type and added a new section for managing ignored product texts in the invoice error finder. - Introduced a new user_sag_create_preferences table to store per-user default case types for new cases. - Enhanced frontend settings page with invoice error finder configuration options and improved handling of ignored product texts. - Added migrations to support new features, including resolved status for invoice error finder issues and user-specific case type preferences.
581 lines
20 KiB
Python
581 lines
20 KiB
Python
import asyncio
|
|
import json
|
|
import sys
|
|
from datetime import date
|
|
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_economic_import_service_defaults_missing_line_amounts(monkeypatch):
|
|
from app.modules.invoice_error_finder.services.economic_import_service import EconomicImportService
|
|
|
|
captured = []
|
|
|
|
def fake_execute_query(query, params=None):
|
|
if "INSERT INTO invoice_error_finder_economic_invoice_lines" in query:
|
|
captured.append(params)
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
"app.modules.invoice_error_finder.services.economic_import_service.execute_query",
|
|
fake_execute_query,
|
|
)
|
|
|
|
service = EconomicImportService()
|
|
service._persist_lines(
|
|
99,
|
|
[
|
|
{
|
|
"lineNumber": 1,
|
|
"description": "Mobilnummer",
|
|
"quantity": None,
|
|
"unitNetPrice": None,
|
|
"totalNetAmount": None,
|
|
"discountPercentage": None,
|
|
}
|
|
],
|
|
)
|
|
|
|
assert len(captured) == 1
|
|
assert captured[0][5] == 0.0
|
|
assert captured[0][6] == 0.0
|
|
assert captured[0][7] == 0.0
|
|
assert captured[0][8] == 0.0
|
|
|
|
|
|
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_detection_service_ignores_non_hub_customer_keys(monkeypatch):
|
|
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
|
|
|
def fake_execute_query_single(query, params=None):
|
|
if "SELECT id FROM customers" in query:
|
|
return None
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
|
fake_execute_query_single,
|
|
)
|
|
|
|
service = DetectionService()
|
|
customer_id = service._resolve_hub_customer_id(
|
|
{
|
|
"customer_key": 56283338,
|
|
"hub_customer_id": None,
|
|
}
|
|
)
|
|
|
|
assert customer_id is None
|
|
|
|
|
|
def test_detection_service_upsert_nulls_unknown_customer_id(monkeypatch):
|
|
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
|
|
|
inserted = {}
|
|
|
|
def fake_execute_query_single(query, params=None):
|
|
if "SELECT id, status" in query and "FROM invoice_error_finder_issues" in query:
|
|
return None
|
|
if "SELECT id FROM customers" in query:
|
|
return None
|
|
if "INSERT INTO invoice_error_finder_issues" in query:
|
|
inserted["params"] = params
|
|
return {"id": 21}
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
|
fake_execute_query_single,
|
|
)
|
|
|
|
service = DetectionService()
|
|
issue_id = service._upsert_issue(
|
|
issue_type="missing_line",
|
|
customer_id=702222153,
|
|
customer_name="Unknown Mapping",
|
|
product_number="INET-1000",
|
|
reference_period_start=date(2026, 7, 1),
|
|
reference_period_end=date(2026, 7, 31),
|
|
)
|
|
|
|
assert issue_id == 21
|
|
assert inserted["params"][2] is None
|
|
|
|
|
|
def test_detection_service_analyze_sweeps_historical_months(monkeypatch):
|
|
from app.modules.invoice_error_finder.services.detection_service import DetectionService
|
|
|
|
scanned_months = []
|
|
current_month = date.today().replace(day=1)
|
|
first_month = current_month - __import__("dateutil.relativedelta").relativedelta.relativedelta(months=3)
|
|
|
|
def fake_execute_query_single(query, params=None):
|
|
if "MIN(invoice_date)" in query and "MAX(invoice_date)" in query:
|
|
return {
|
|
"first_month": first_month,
|
|
"last_month": current_month,
|
|
}
|
|
return None
|
|
|
|
def fake_analyze_single_month(self, month):
|
|
scanned_months.append(month)
|
|
return {
|
|
"missing_line": 1,
|
|
"open_order_not_invoiced": 0,
|
|
"quantity_drop": 0,
|
|
"price_change": 0,
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"app.modules.invoice_error_finder.services.detection_service.execute_query_single",
|
|
fake_execute_query_single,
|
|
)
|
|
monkeypatch.setattr(
|
|
DetectionService,
|
|
"_analyze_single_month",
|
|
fake_analyze_single_month,
|
|
)
|
|
|
|
service = DetectionService()
|
|
counts = service.analyze()
|
|
|
|
assert scanned_months == [
|
|
first_month + __import__("dateutil.relativedelta").relativedelta.relativedelta(months=1),
|
|
first_month + __import__("dateutil.relativedelta").relativedelta.relativedelta(months=2),
|
|
current_month,
|
|
]
|
|
assert counts["missing_line"] == 3
|
|
|
|
|
|
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"]
|
|
|
|
|
|
def test_list_issues_returns_resolved_product_name(monkeypatch):
|
|
from app.modules.invoice_error_finder.backend.router import list_issues
|
|
|
|
def fake_execute_query_single(query, params=None):
|
|
if "COUNT(*) AS c" in query:
|
|
return {"c": 1}
|
|
return None
|
|
|
|
def fake_execute_query(query, params=None):
|
|
return [
|
|
{
|
|
"id": 44,
|
|
"customer_name": "Karise Anlæg & Byg A/S",
|
|
"product_number": "PRO563",
|
|
"product_name": None,
|
|
"resolved_product_name": "Fiberforbindelse 1/1 Gbit.",
|
|
"status": "open",
|
|
"issue_type": "missing_line",
|
|
}
|
|
]
|
|
|
|
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(
|
|
status=None,
|
|
issue_type=None,
|
|
customer_id=None,
|
|
assigned_user_id=None,
|
|
limit=100,
|
|
offset=0,
|
|
current_user={},
|
|
)
|
|
)
|
|
|
|
assert payload["items"][0]["resolved_product_name"] == "Fiberforbindelse 1/1 Gbit."
|
|
|
|
|
|
def test_get_issue_invoice_history_returns_reference_window(monkeypatch):
|
|
from app.modules.invoice_error_finder.backend.router import get_issue_invoice_history
|
|
|
|
def fake_execute_query_single(query, params=None):
|
|
if "FROM invoice_error_finder_issues" in query:
|
|
return {
|
|
"id": 14,
|
|
"customer_id": 77,
|
|
"customer_name": "ACME",
|
|
"product_number": "INET-1000",
|
|
"product_name": "1/1 Gbit Internet",
|
|
"reference_period_start": date(2026, 7, 1),
|
|
"reference_period_end": date(2026, 7, 31),
|
|
}
|
|
if "FROM customers WHERE id = %s" in query:
|
|
return {
|
|
"id": 77,
|
|
"name": "ACME",
|
|
"economic_customer_number": 56283338,
|
|
}
|
|
return None
|
|
|
|
def fake_execute_query(query, params=None):
|
|
if "FROM month_window mw" in query:
|
|
return [
|
|
{
|
|
"month_start": date(2026, 6, 1),
|
|
"line_count": 1,
|
|
"total_quantity": 1,
|
|
"total_amount": 999.0,
|
|
"invoice_numbers": ["18912"],
|
|
"invoice_dates": ["2026-06-03"],
|
|
"descriptions": ["1/1 Gbit Internet"],
|
|
"is_reference_month": False,
|
|
},
|
|
{
|
|
"month_start": date(2026, 7, 1),
|
|
"line_count": 0,
|
|
"total_quantity": 0,
|
|
"total_amount": 0,
|
|
"invoice_numbers": [],
|
|
"invoice_dates": [],
|
|
"descriptions": [],
|
|
"is_reference_month": True,
|
|
},
|
|
{
|
|
"month_start": date(2026, 8, 1),
|
|
"line_count": 1,
|
|
"total_quantity": 1,
|
|
"total_amount": 999.0,
|
|
"invoice_numbers": ["19001"],
|
|
"invoice_dates": ["2026-08-04"],
|
|
"descriptions": ["1/1 Gbit Internet"],
|
|
"is_reference_month": False,
|
|
},
|
|
]
|
|
if "WITH ranked_invoices AS" in query:
|
|
return [
|
|
{
|
|
"month_start": date(2026, 6, 1),
|
|
"invoice_id": 101,
|
|
"source_invoice_number": "18912",
|
|
"invoice_date": date(2026, 6, 3),
|
|
"total_amount": 1248.75,
|
|
"net_amount": 999.0,
|
|
"vat_amount": 249.75,
|
|
"currency": "DKK",
|
|
"source_type": "booked",
|
|
"heading": "Periode June 2026",
|
|
"note_text": "Kundeperiode juni\nEkstra note",
|
|
"line_number": 1,
|
|
"product_number": "INET-1000",
|
|
"product_name": None,
|
|
"description": "1/1 Gbit Internet",
|
|
"quantity": 1,
|
|
"unit_price": 999.0,
|
|
"line_net_amount": 999.0,
|
|
},
|
|
{
|
|
"month_start": date(2026, 6, 1),
|
|
"invoice_id": 101,
|
|
"source_invoice_number": "18912",
|
|
"invoice_date": date(2026, 6, 3),
|
|
"total_amount": 1248.75,
|
|
"net_amount": 999.0,
|
|
"vat_amount": 249.75,
|
|
"currency": "DKK",
|
|
"source_type": "booked",
|
|
"heading": "Periode June 2026",
|
|
"note_text": "Kundeperiode juni\nEkstra note",
|
|
"line_number": 2,
|
|
"product_number": "RTR-1",
|
|
"product_name": None,
|
|
"description": "Leje af router",
|
|
"quantity": 1,
|
|
"unit_price": 249.0,
|
|
"line_net_amount": 249.0,
|
|
},
|
|
{
|
|
"month_start": date(2026, 8, 1),
|
|
"invoice_id": 102,
|
|
"source_invoice_number": "19001",
|
|
"invoice_date": date(2026, 8, 4),
|
|
"total_amount": 1248.75,
|
|
"net_amount": 999.0,
|
|
"vat_amount": 249.75,
|
|
"currency": "DKK",
|
|
"source_type": "booked",
|
|
"heading": "Periode August 2026",
|
|
"note_text": None,
|
|
"line_number": 1,
|
|
"product_number": "INET-1000",
|
|
"product_name": None,
|
|
"description": "1/1 Gbit Internet",
|
|
"quantity": 1,
|
|
"unit_price": 999.0,
|
|
"line_net_amount": 999.0,
|
|
},
|
|
]
|
|
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(get_issue_invoice_history(14, current_user={}))
|
|
|
|
assert payload["customer_name"] == "ACME"
|
|
assert payload["product_number"] == "INET-1000"
|
|
assert len(payload["months"]) == 3
|
|
assert payload["months"][1]["is_reference_month"] is True
|
|
assert payload["months"][1]["line_count"] == 0
|
|
assert payload["months"][0]["invoices"][0]["invoice_number"] == "18912"
|
|
assert payload["months"][0]["invoices"][0]["note_text"] == "Kundeperiode juni\nEkstra note"
|
|
assert len(payload["months"][0]["invoices"][0]["lines"]) == 2
|
|
|
|
|
|
def test_get_issue_invoice_history_deduplicates_same_invoice_number(monkeypatch):
|
|
from app.modules.invoice_error_finder.backend.router import get_issue_invoice_history
|
|
|
|
def fake_execute_query_single(query, params=None):
|
|
if "FROM invoice_error_finder_issues" in query:
|
|
return {
|
|
"id": 15,
|
|
"customer_id": 77,
|
|
"customer_name": "ACME",
|
|
"product_number": "INET-1000",
|
|
"product_name": "1/1 Gbit Internet",
|
|
"reference_period_start": date(2026, 7, 1),
|
|
"reference_period_end": date(2026, 7, 31),
|
|
}
|
|
if "FROM customers WHERE id = %s" in query:
|
|
return {
|
|
"id": 77,
|
|
"name": "ACME",
|
|
"economic_customer_number": 56283338,
|
|
}
|
|
return None
|
|
|
|
def fake_execute_query(query, params=None):
|
|
if "FROM month_window mw" in query:
|
|
return [
|
|
{
|
|
"month_start": date(2026, 7, 1),
|
|
"line_count": 2,
|
|
"total_quantity": 2,
|
|
"total_amount": 1444.0,
|
|
"invoice_numbers": ["20098", "20098"],
|
|
"invoice_dates": ["2026-03-13", "2026-03-13"],
|
|
"descriptions": ["Fiberforbindelse", "Fiberforbindelse"],
|
|
"is_reference_month": True,
|
|
},
|
|
]
|
|
if "WITH ranked_invoices AS" in query:
|
|
return [
|
|
{
|
|
"month_start": date(2026, 7, 1),
|
|
"invoice_id": 201,
|
|
"source_invoice_number": "20098",
|
|
"invoice_date": date(2026, 3, 13),
|
|
"total_amount": 902.5,
|
|
"net_amount": 722.0,
|
|
"vat_amount": 180.5,
|
|
"currency": "DKK",
|
|
"source_type": "paid",
|
|
"heading": None,
|
|
"note_text": None,
|
|
"line_number": 1,
|
|
"product_number": "INET-1000",
|
|
"product_name": None,
|
|
"description": "Fiberforbindelse",
|
|
"quantity": 1,
|
|
"unit_price": 722.0,
|
|
"line_net_amount": 722.0,
|
|
}
|
|
]
|
|
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(get_issue_invoice_history(15, current_user={}))
|
|
|
|
assert len(payload["months"][0]["invoices"]) == 1
|
|
assert payload["months"][0]["invoices"][0]["source_type"] == "paid"
|