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 = ""
|
||||
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)
|
||||
|
||||
@ -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!');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
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