import csv import io import uuid from dataclasses import dataclass from pydantic import EmailStr, HttpUrl, TypeAdapter, ValidationError from sqlalchemy.orm import Session from app.models.customer import Customer, CustomerAddress, CustomerContact from app.models.user import User from app.repositories.customer_repository import CustomerRepository from app.schemas.customer_import import ( CustomerImportCommitResponse, CustomerImportIssue, CustomerImportPreviewResponse, CustomerImportPreviewRow, CustomerImportSummary, ImportMode, ) from app.services.audit_service import write_audit_log CSV_COLUMNS = [ "customer_number", "company_name", "legal_name", "customer_type", "status", "industry", "website", "email", "phone", "tax_number", "vat_id", "notes", "address_type", "street", "postal_code", "city", "state", "country", "address_is_primary", "contact_first_name", "contact_last_name", "contact_position", "contact_email", "contact_phone", "contact_mobile", "contact_is_primary", "contact_notes", ] VALID_STATUSES = {"lead", "active", "inactive", "blocked", "archived"} VALID_TYPES = {"company", "private", "public_sector", "partner", "supplier"} VALID_ADDRESS_TYPES = {"billing", "shipping", "primary", "other"} TRUE_VALUES = {"1", "true", "yes", "ja", "j", "x"} FALSE_VALUES = {"0", "false", "no", "nein", "n", ""} email_adapter = TypeAdapter(EmailStr) url_adapter = TypeAdapter(HttpUrl) @dataclass class ParsedImportRow: row_number: int data: dict[str, str] action: str errors: list[CustomerImportIssue] warnings: list[CustomerImportIssue] existing_customer: Customer | None = None def get_customer_import_template() -> str: return ";".join(CSV_COLUMNS) + "\n" def decode_csv(content: bytes) -> str: try: return content.decode("utf-8-sig") except UnicodeDecodeError as exc: raise ValueError("CSV-Datei muss UTF-8-kodiert sein") from exc def parse_bool(value: str) -> bool: normalized = value.strip().lower() if normalized in TRUE_VALUES: return True if normalized in FALSE_VALUES: return False return False def parse_bool_default(value: str, default: bool = False) -> bool: if value.strip() == "": return default return parse_bool(value) def sniff_dialect(text: str) -> csv.Dialect: sample = text[:4096] try: return csv.Sniffer().sniff(sample, delimiters=",;") except csv.Error: return csv.excel def read_csv_rows(content: bytes) -> list[tuple[int, dict[str, str]]]: text = decode_csv(content) reader = csv.DictReader(io.StringIO(text), dialect=sniff_dialect(text)) rows: list[tuple[int, dict[str, str]]] = [] for index, row in enumerate(reader, start=2): normalized = {column: (row.get(column) or "").strip() for column in CSV_COLUMNS} if not any(normalized.values()): continue rows.append((index, normalized)) return rows def validate_email(value: str, row_number: int, field: str, errors: list[CustomerImportIssue]) -> str: if not value: return "" try: return str(email_adapter.validate_python(value)) except ValidationError: errors.append(CustomerImportIssue(row=row_number, field=field, message="E-Mail ist ungültig")) return value def validate_url(value: str, row_number: int, errors: list[CustomerImportIssue]) -> str: if not value: return "" candidate = value if value.startswith(("http://", "https://")) else f"https://{value}" try: return str(url_adapter.validate_python(candidate)) except ValidationError: errors.append(CustomerImportIssue(row=row_number, field="website", message="Website ist ungültig")) return value def planned_action(mode: ImportMode, existing_customer: Customer | None) -> str: if existing_customer is None: return "create" if mode in {"create_only", "upsert"} else "skip" if mode == "create_only": return "skip" return "update" class CustomerImportService: @staticmethod def preview(db: Session, content: bytes, mode: ImportMode) -> CustomerImportPreviewResponse: rows = CustomerImportService._parse_and_validate(db, content, mode) return CustomerImportService._build_preview(mode, rows) @staticmethod def commit( db: Session, content: bytes, mode: ImportMode, *, actor: User, request, ) -> CustomerImportCommitResponse: rows = CustomerImportService._parse_and_validate(db, content, mode) preview = CustomerImportService._build_preview(mode, rows) if preview.summary.valid_rows == 0: return CustomerImportCommitResponse( mode=mode, summary=preview.summary, created=0, updated=0, skipped=preview.summary.skip_count, ) created = 0 updated = 0 skipped = 0 for row in rows: if row.errors or row.action == "error": continue if row.action == "skip": skipped += 1 continue if row.action == "create": CustomerImportService._create_customer(db, row.data) created += 1 elif row.action == "update" and row.existing_customer is not None: CustomerImportService._update_customer(db, row.existing_customer, row.data) updated += 1 db.commit() write_audit_log( db, action="customers.import", entity_type="customers", actor=actor, request=request, metadata={ "mode": mode, "total_rows": preview.summary.total_rows, "created": created, "updated": updated, "skipped": skipped, "errors": preview.summary.error_count, "warnings": preview.summary.warning_count, }, ) summary = CustomerImportService._build_preview(mode, rows).summary return CustomerImportCommitResponse( mode=mode, summary=summary, created=created, updated=updated, skipped=skipped, ) @staticmethod def _parse_and_validate(db: Session, content: bytes, mode: ImportMode) -> list[ParsedImportRow]: csv_rows = read_csv_rows(content) number_rows: dict[str, int] = {} duplicates: set[str] = set() for row_number, data in csv_rows: customer_number = data["customer_number"] if not customer_number: continue if customer_number in number_rows: duplicates.add(customer_number) else: number_rows[customer_number] = row_number parsed_rows: list[ParsedImportRow] = [] for row_number, data in csv_rows: errors: list[CustomerImportIssue] = [] warnings: list[CustomerImportIssue] = [] if not data["company_name"]: errors.append(CustomerImportIssue(row=row_number, field="company_name", message="Firmenname ist erforderlich")) if not data["customer_type"]: data["customer_type"] = "company" warnings.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ fehlt und wird als company importiert")) elif data["customer_type"] not in VALID_TYPES: errors.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ ist ungültig")) if not data["status"]: data["status"] = "active" warnings.append(CustomerImportIssue(row=row_number, field="status", message="Status fehlt und wird als active importiert")) elif data["status"] not in VALID_STATUSES: errors.append(CustomerImportIssue(row=row_number, field="status", message="Status ist ungültig")) if not data["address_type"]: data["address_type"] = "primary" elif data["address_type"] not in VALID_ADDRESS_TYPES: errors.append(CustomerImportIssue(row=row_number, field="address_type", message="Adresstyp ist ungültig")) data["email"] = validate_email(data["email"], row_number, "email", errors) data["contact_email"] = validate_email(data["contact_email"], row_number, "contact_email", errors) data["website"] = validate_url(data["website"], row_number, errors) if data["customer_number"] and data["customer_number"] in duplicates: errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist in der CSV mehrfach vorhanden")) if not data["customer_number"] and mode == "update_existing": errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist für update_existing erforderlich")) if not data["customer_number"]: warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer fehlt und wird beim Erstellen generiert")) existing_customer = ( CustomerRepository.get_by_number(db, data["customer_number"]) if data["customer_number"] else None ) action = planned_action(mode, existing_customer) if action == "skip": reason = "Kunde existiert bereits" if existing_customer is not None else "Kunde existiert nicht" warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message=f"Zeile wird übersprungen: {reason}")) if errors: action = "error" parsed_rows.append( ParsedImportRow( row_number=row_number, data=data, action=action, errors=errors, warnings=warnings, existing_customer=existing_customer, ) ) return parsed_rows @staticmethod def _build_preview(mode: ImportMode, rows: list[ParsedImportRow]) -> CustomerImportPreviewResponse: preview_rows = [ CustomerImportPreviewRow( row=row.row_number, customer_number=row.data["customer_number"], company_name=row.data["company_name"], action=row.action, errors=row.errors, warnings=row.warnings, ) for row in rows ] errors = [issue for row in rows for issue in row.errors] warnings = [issue for row in rows for issue in row.warnings] duplicates = [issue for issue in errors if issue.field == "customer_number" and "mehrfach" in issue.message] summary = CustomerImportSummary( total_rows=len(rows), valid_rows=sum(1 for row in rows if not row.errors and row.action in {"create", "update"}), error_count=len(errors), warning_count=len(warnings), duplicate_count=len(duplicates), create_count=sum(1 for row in rows if not row.errors and row.action == "create"), update_count=sum(1 for row in rows if not row.errors and row.action == "update"), skip_count=sum(1 for row in rows if not row.errors and row.action == "skip"), ) return CustomerImportPreviewResponse( mode=mode, summary=summary, rows=preview_rows, errors=errors, warnings=warnings, duplicates=duplicates, ) @staticmethod def _customer_number(data: dict[str, str]) -> str: return data["customer_number"] or f"IMP-{uuid.uuid4().hex[:10].upper()}" @staticmethod def _address(data: dict[str, str]) -> CustomerAddress: return CustomerAddress( type=data["address_type"], street=data["street"], postal_code=data["postal_code"], city=data["city"], state=data["state"], country=data["country"] or "Deutschland", is_primary=parse_bool_default(data["address_is_primary"], default=True), ) @staticmethod def _contact(data: dict[str, str]) -> CustomerContact | None: if not any(data[key] for key in ("contact_first_name", "contact_last_name", "contact_email", "contact_phone", "contact_mobile")): return None return CustomerContact( first_name=data["contact_first_name"], last_name=data["contact_last_name"], position=data["contact_position"], email=data["contact_email"], phone=data["contact_phone"], mobile=data["contact_mobile"], is_primary=parse_bool(data["contact_is_primary"]), notes=data["contact_notes"], ) @staticmethod def _create_customer(db: Session, data: dict[str, str]) -> Customer: customer = Customer( customer_number=CustomerImportService._customer_number(data), company_name=data["company_name"], legal_name=data["legal_name"], customer_type=data["customer_type"], status=data["status"], industry=data["industry"], website=data["website"], email=data["email"], phone=data["phone"], tax_number=data["tax_number"], vat_id=data["vat_id"], notes=data["notes"], addresses=[CustomerImportService._address(data)], ) contact = CustomerImportService._contact(data) if contact is not None: customer.contacts = [contact] db.add(customer) return customer @staticmethod def _update_customer(db: Session, customer: Customer, data: dict[str, str]) -> Customer: customer.company_name = data["company_name"] customer.legal_name = data["legal_name"] customer.customer_type = data["customer_type"] customer.status = data["status"] customer.industry = data["industry"] customer.website = data["website"] customer.email = data["email"] customer.phone = data["phone"] customer.tax_number = data["tax_number"] customer.vat_id = data["vat_id"] customer.notes = data["notes"] customer.addresses = [CustomerImportService._address(data)] contact = CustomerImportService._contact(data) if contact is not None: if contact.is_primary: for existing_contact in customer.contacts: existing_contact.is_primary = False customer.contacts.append(contact) db.add(customer) return customer