feat(customers): add controlled CSV and XLSX customer import

This commit is contained in:
Schubert Ferenc 2026-07-12 15:19:15 +02:00
parent 613ffdc8b7
commit 82e03877b3
566 changed files with 2752 additions and 991 deletions

View file

@ -6,7 +6,7 @@ from datetime import date
from pathlib import Path
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.db.base import Base
@ -32,6 +32,7 @@ from app.modules.orion.result import validation_result_box, validation_result_pr
from app.modules.orion.template_service import REPORT_SECTIONS, ReportTemplateService
from app.modules.helios.service import HeliosImportService
from app.services.report_settings import OrionReportSettingsService
from app.services.customer_import import CustomerImportService
def session() -> Session:
@ -1564,3 +1565,113 @@ def test_orion_report_settings_affect_html_without_removing_summary_result(tmp_p
assert '<header class="report-header"' not in html
assert '<footer class="report-footer"' not in html
assert html.count("result-box") >= 1
def xlsx_bytes(headers: list[str], rows: list[list[str]]) -> bytes:
from zipfile import ZIP_DEFLATED, ZipFile
import io
def cell_ref(index: int, row_number: int) -> str:
return f"{chr(65 + index)}{row_number}"
sheet_rows = []
for row_number, row in enumerate([headers, *rows], start=1):
cells = "".join(
f'<c r="{cell_ref(index, row_number)}" t="inlineStr"><is><t>{value}</t></is></c>'
for index, value in enumerate(row)
)
sheet_rows.append(f'<row r="{row_number}">{cells}</row>')
buffer = io.BytesIO()
with ZipFile(buffer, "w", ZIP_DEFLATED) as archive:
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>')
archive.writestr("xl/workbook.xml", '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheets><sheet name="Tabelle1" sheetId="1" r:id="rId1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/></sheets></workbook>')
archive.writestr("xl/worksheets/sheet1.xml", f'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>{"".join(sheet_rows)}</sheetData></worksheet>')
return buffer.getvalue()
def test_customer_import_csv_semicolon_preview_and_confirm():
db = session()
admin = seed_admin(db)
content = "Kundennr;Firma;PLZ;Ort;E-Mail;Ansprechpartner\nD4-1;Praxis Müller;10115;Berlin;info@praxis.de;Max Mustermann\n".encode("utf-8-sig")
service = CustomerImportService(db)
preview = service.preview("desk4.csv", content)
assert preview["columns"] == ["Kundennr", "Firma", "PLZ", "Ort", "E-Mail", "Ansprechpartner"]
assert preview["mapping"]["Kundennr"] == "customer_number"
assert preview["rows"][0]["action"] == "NEU_ANLEGEN"
assert db.scalar(select(func.count()).select_from(Customer)) == 0
result = service.confirm("desk4.csv", content, preview["mapping"], {}, True, admin)
assert result["summary"]["created_customers"] == 1
assert result["summary"]["created_locations"] == 1
assert result["summary"]["created_contacts"] == 1
customer = db.scalar(select(Customer).where(Customer.external_id == "D4-1"))
assert customer is not None
assert customer.source_system == "desk4"
assert customer.name == "Praxis Müller"
def test_customer_import_csv_comma_existing_customer_and_ignore_empty_values():
db = session()
admin = seed_admin(db)
existing = Customer(
customer_type=CustomerType.practice,
source_system="desk4",
external_id="D4-2",
name="Alt",
email="alt@example.de",
)
db.add(existing)
db.commit()
content = "Kundennr,Firma,E-Mail\nD4-2,Neu,\n".encode()
service = CustomerImportService(db)
preview = service.preview("desk4.csv", content)
assert preview["rows"][0]["action"] == "BESTEHENDEN_AKTUALISIEREN"
service.confirm("desk4.csv", content, preview["mapping"], {}, True, admin)
db.refresh(existing)
assert existing.name == "Neu"
assert existing.email == "alt@example.de"
def test_customer_import_detects_possible_duplicate_and_invalid_email():
db = session()
db.add(Customer(customer_type=CustomerType.practice, name="Praxis A", postal_code="12345"))
db.commit()
service = CustomerImportService(db)
duplicate = service.preview("desk4.csv", "Firma;PLZ\nPraxis A;12345\n".encode())
invalid = service.preview("desk4.csv", "Firma;E-Mail\nPraxis B;nicht-mail\n".encode())
assert duplicate["rows"][0]["action"] == "UEBERSPRINGEN"
assert duplicate["summary"]["duplicates"] == 1
assert invalid["rows"][0]["action"] == "ERROR"
assert "ungültig" in invalid["rows"][0]["errors"][0]
def test_customer_import_xlsx_and_invalid_files():
db = session()
service = CustomerImportService(db)
preview = service.preview(
"desk4.xlsx",
xlsx_bytes(["Kundennr", "Firma", "Ort"], [["X-1", "Praxis XLSX", "Hamburg"]]),
)
assert preview["rows"][0]["recognized"]["customer"]["name"] == "Praxis XLSX"
with pytest.raises(ValueError):
service.preview("desk4.txt", b"Firma\nTest")
with pytest.raises(ValueError):
service.preview("desk4.csv", b"")
def test_customer_import_missing_required_mapping_is_error():
db = session()
service = CustomerImportService(db)
content = "Telefon\n123\n".encode()
preview = service.preview("desk4.csv", content, {"Telefon": "customer_phone"})
assert preview["rows"][0]["action"] == "ERROR"
assert "Kundennummer oder Firmenname fehlt." in preview["rows"][0]["errors"]