mirror of
https://github.com/itsdave-de/msp.git
synced 2026-09-28 04:02:42 -03:00
feat(msp): zweiter Lieferanten-Handler — ADN Hornetsecurity-CSV
Bisher konnte der MSP-Importer nur die monatliche Microsoft-CSV von ADN verarbeiten. ADN liefert dieselbe Dateifamilie auch für Hornetsecurity- Abrechnungen, bisher manuell oder über das alte adnconnect-Modul verbucht (493 historische Sales Invoices im System). Neuer Handler "ADN Hornet CSV (Hornetsecurity)" — zweiter Eintrag in der Auto-Detection der Dropzone, gleiches Wizard-Erlebnis. Hornet- spezifische Punkte: - Subscription-Identifikation via 'Vertrag' (statt SUBSCRIPTION_ID_EXTERNAL) - Mengen werden anteilig auf den Abrechnungsmonat skaliert (Wartungsbeginn/-ende ergeben einen Tagesanteil — voller Monat → 1.0) - Customer-Auflösung über das Custom-Field 'hornet_domain' statt direktem Customer-Name-Lookup Generische Erweiterungen, von beiden Profilen nutzbar: - Supplier Import Profile.customer_lookup_field: optionales Custom-Field für Customer-Resolution per Domain/Identifier - resolve_customer() akzeptiert lookup_field-Argument - Microsoft-Sniff bekommt Discriminator-Bonus auf BILLINGPLAN/MSERP/ VERTRAGSDAUER, damit die beiden Handler bei strukturell ähnlichen CSVs nicht beide 100% scoren Sniff-Validierung: Hornet-CSV (Filename "hornet"): Hornet 1.00 / MS 0.70 → Hornet Hornet-CSV (Filename "Rechnungen"): Hornet 0.95 / MS 0.70 → Hornet Microsoft-CSV (real): Hornet 0.38 / MS 1.00 → Microsoft End-to-end gegen synthetische Hornet-CSV verifiziert: SI mit Title "Abrechnung Hornetsecurity 04.2026", Customer korrekt über hornet_domain aufgelöst, anteilige Menge (13,33 statt 25 für 16/30 Tage) korrekt berechnet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
db6b4e07fa
commit
6d3619992c
@@ -25,5 +25,33 @@
|
||||
"title_template": "{prefix} {period}",
|
||||
"title_template_credit_note": "{prefix} {period}",
|
||||
"vendor": "Microsoft"
|
||||
},
|
||||
{
|
||||
"customer_lookup_field": "hornet_domain",
|
||||
"default_company": "itsdave GmbH",
|
||||
"default_cost_center": null,
|
||||
"default_output_mode": "Sales Invoice",
|
||||
"default_payment_terms_template": null,
|
||||
"default_taxes_and_charges": "MwSt. 19%",
|
||||
"default_tc_name": "itsdave Standard AGBs",
|
||||
"default_warehouse": "SST - IG",
|
||||
"docstatus": 0,
|
||||
"doctype": "Supplier Import Profile",
|
||||
"enabled": 1,
|
||||
"fallback_customer": null,
|
||||
"file_type": "CSV",
|
||||
"intro_text_credit_note": null,
|
||||
"intro_text_invoice": null,
|
||||
"list_price_discount_pct": 0.0,
|
||||
"modified": "2026-04-15 09:00:00.000000",
|
||||
"name": "ADN Hornet CSV v1",
|
||||
"parser_key": "adn_hornet_csv_v1",
|
||||
"profile_name": "ADN Hornet CSV v1",
|
||||
"supplier": "SUPP-00887",
|
||||
"title_prefix": "Abrechnung Hornetsecurity ",
|
||||
"title_prefix_credit_note": "Gutschrift Hornetsecurity ",
|
||||
"title_template": "{prefix} {period}",
|
||||
"title_template_credit_note": "{prefix} {period}",
|
||||
"vendor": "Hornetsecurity"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Parser für die ADN-Hornetsecurity-Abrechnungs-CSVs.
|
||||
|
||||
Das Format ist nahezu identisch zum monatlichen Microsoft-CSV (gleiche
|
||||
Spaltenfamilie für Kopfdaten / Endkunde / Position), unterscheidet sich
|
||||
inhaltlich aber in vier Punkten:
|
||||
|
||||
* Die Subscription-Identifikation läuft über ``Vertrag`` (statt
|
||||
``SUBSCRIPTION_ID_EXTERNAL``).
|
||||
* Mengen werden anteilig auf den Abrechnungsmonat heruntergerechnet
|
||||
(``Wartungsbeginn``/``Wartungsende`` ergeben einen Faktor).
|
||||
* Hornet-CSVs enthalten **nicht** die Microsoft-spezifischen Spalten
|
||||
``BILLINGPLAN`` / ``MSERP`` / ``VERTRAGSDAUER``.
|
||||
* Die Endkunden-Referenz ist eine Mail-Domain (``Customer.hornet_domain``)
|
||||
und keine ERP-Customer-ID — die Auflösung übernimmt der Builder über
|
||||
das Profilfeld ``customer_lookup_field``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import csv
|
||||
from datetime import date
|
||||
from typing import Iterable
|
||||
|
||||
from msp.importers.base import (
|
||||
BaseSupplierParser,
|
||||
CanonicalRow,
|
||||
ParseResult,
|
||||
parse_german_date,
|
||||
parse_german_decimal,
|
||||
)
|
||||
from msp.importers.registry import register
|
||||
|
||||
|
||||
# Die Spaltenliste basiert auf der Validierung im legacy adnconnect.adn_hornet_import
|
||||
# Modul. ADN exportiert seit jeher in mixed case; wir vergleichen aber
|
||||
# case-insensitiv und tolerieren Drift.
|
||||
EXPECTED_HEADER = [
|
||||
"RECHNUNG", "DATUM", "KUNDENNR", "DEBITORKONTO", "SACHBEARBEITER",
|
||||
"LIEFERSCHEIN", "LIEFERSCHEINDATUM", "USTID",
|
||||
"RE_FIRMA", "RE_ADRESSE", "RE_PLZ", "RE_ORT", "RE_LAND",
|
||||
"LI_FIRMA", "LI_STRASSE", "LI_PLZ", "LI_ORT", "LI_LAND",
|
||||
"HA_FIRMA", "HA_STRASSE", "HA_PLZ", "HA_ORT", "HA_LAND",
|
||||
"WARENWERT", "MWST", "GESAMTBETRAG", "NETTOZAHLBARBIS",
|
||||
"LIEFERBEDINGUNGEN", "ZAHLUNGSBEDINGUNG",
|
||||
"ENDKUNDE", "POSITION", "ARTIKEL", "HERSTELLERNUMMER", "ARTIKELBEZ",
|
||||
"MENGE", "PREISME", "LISTPREIS", "RABATT", "EINZELPREIS", "POSITIONSPREIS",
|
||||
"WARTUNGSBEGINN", "WARTUNGSENDE", "VERTRAG",
|
||||
"MARKETPLACE_REF", "ORDER_REFERENCE", "ENDKUNDE_REFERENCE",
|
||||
"SUBSCRIPTION_ID_EXTERNAL", "SUBSCRIPTION_START_DATE", "BUCHUNGSTYP",
|
||||
"ORDERDATUM", "RECHNUNGSART",
|
||||
]
|
||||
|
||||
|
||||
def _strip_bom(value: str) -> str:
|
||||
return value.lstrip("\ufeff") if value else value
|
||||
|
||||
|
||||
def _normalize_header(header: Iterable[str]) -> list[str]:
|
||||
return [_strip_bom(h).strip() for h in header]
|
||||
|
||||
|
||||
@register
|
||||
class ADNHornetCSVParser(BaseSupplierParser):
|
||||
parser_key = "adn_hornet_csv_v1"
|
||||
description = "ADN Distribution — Hornetsecurity-Abrechnungs-CSV"
|
||||
|
||||
def parse(self, file_path: str) -> ParseResult:
|
||||
result = ParseResult()
|
||||
|
||||
with open(file_path, "r", encoding="utf-8", newline="") as fh:
|
||||
reader = csv.reader(fh, delimiter=";", quotechar='"')
|
||||
try:
|
||||
raw_header = next(reader)
|
||||
except StopIteration:
|
||||
result.warnings.append("Datei ist leer.")
|
||||
return result
|
||||
|
||||
header = _normalize_header(raw_header)
|
||||
result.format_drift = not self._header_matches(header)
|
||||
|
||||
for source_row, row in enumerate(reader, start=2):
|
||||
if not row or not any(cell.strip() for cell in row):
|
||||
continue
|
||||
data = self._row_to_dict(header, row)
|
||||
|
||||
# Header-Wiederholung pro Rechnung überspringen (Position 1 ohne Artikel)
|
||||
if not (data.get("ARTIKEL") or "").strip() and not (data.get("HERSTELLERNUMMER") or "").strip():
|
||||
continue
|
||||
|
||||
canonical = self._to_canonical(data, source_row)
|
||||
if canonical is not None:
|
||||
result.rows.append(canonical)
|
||||
|
||||
if result.rows:
|
||||
first_raw = result.rows[0].raw
|
||||
result.invoice_date = parse_german_date(first_raw.get("DATUM"))
|
||||
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _header_matches(self, actual: list[str]) -> bool:
|
||||
actual_upper = {h.upper() for h in actual if h}
|
||||
expected_upper = {h.upper() for h in EXPECTED_HEADER if h}
|
||||
return expected_upper.issubset(actual_upper)
|
||||
|
||||
def _row_to_dict(self, header: list[str], row: list[str]) -> dict[str, str]:
|
||||
# Robust gegen Zeilen mit abweichender Spaltenanzahl. Wir ziehen die
|
||||
# Schlüssel zudem auf upper, damit Mixed-Case-Header (Endkunde vs.
|
||||
# ENDKUNDE) keine Lookups vereiteln.
|
||||
out: dict[str, str] = {}
|
||||
for i in range(min(len(header), len(row))):
|
||||
out[header[i].upper()] = row[i]
|
||||
return out
|
||||
|
||||
def _to_canonical(self, d: dict[str, str], source_row: int) -> CanonicalRow | None:
|
||||
raw_ref = (d.get("ENDKUNDE_REFERENCE") or "").strip() or None
|
||||
|
||||
raw_qty = parse_german_decimal(d.get("MENGE")) or 0.0
|
||||
list_price = parse_german_decimal(d.get("LISTPREIS"))
|
||||
unit_price = parse_german_decimal(d.get("EINZELPREIS"))
|
||||
amount = parse_german_decimal(d.get("POSITIONSPREIS"))
|
||||
discount = parse_german_decimal(d.get("RABATT"))
|
||||
|
||||
period_start = parse_german_date(d.get("WARTUNGSBEGINN"))
|
||||
period_end = parse_german_date(d.get("WARTUNGSENDE"))
|
||||
|
||||
# Anteilige Menge: bei Hornet wird zwischen Wartungsbeginn und -ende
|
||||
# tageweise abgerechnet (z. B. neue Subscription mitten im Monat). Wir
|
||||
# multiplizieren daher mit (Wartungstage / Tage des Startmonats), wie
|
||||
# in adnconnect.adn_hornet_import._do_import.
|
||||
qty = self._apportion_quantity(raw_qty, period_start, period_end)
|
||||
|
||||
rechnungsart = (d.get("RECHNUNGSART") or "").strip()
|
||||
document_type = "Gutschrift" if "gutschrift" in rechnungsart.lower() else "Rechnung"
|
||||
if amount is not None and amount < 0:
|
||||
document_type = "Gutschrift"
|
||||
|
||||
# Vertrag fungiert als Subscription-Identifier — eindeutig pro Hornet-
|
||||
# Vertrag, damit der Idempotenz-Check (Event existiert für period?)
|
||||
# greift.
|
||||
subscription_id = (d.get("VERTRAG") or "").strip() or None
|
||||
|
||||
return CanonicalRow(
|
||||
source_row=source_row,
|
||||
supplier_invoice_no=(d.get("RECHNUNG") or "").strip() or None,
|
||||
customer_external_ref=raw_ref,
|
||||
customer_display_name=(d.get("ENDKUNDE") or "").strip() or None,
|
||||
vendor_product_id=(d.get("HERSTELLERNUMMER") or "").strip() or None,
|
||||
supplier_article_no=(d.get("ARTIKEL") or "").strip() or None,
|
||||
description=(d.get("ARTIKELBEZ") or "").strip() or None,
|
||||
qty=qty,
|
||||
list_price=list_price,
|
||||
discount_pct=discount,
|
||||
unit_price=unit_price,
|
||||
amount=amount,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
subscription_external_id=subscription_id,
|
||||
subscription_start=parse_german_date(d.get("SUBSCRIPTION_START_DATE")),
|
||||
document_type=document_type,
|
||||
marketplace_ref=(d.get("MARKETPLACE_REF") or "").strip() or None,
|
||||
order_ref=(d.get("ORDER_REFERENCE") or "").strip() or None,
|
||||
raw=d,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apportion_quantity(qty: float, start: date | None, end: date | None) -> float:
|
||||
"""Skaliert die Menge anteilig auf den Abrechnungsmonat.
|
||||
|
||||
Ist die Wartung gleich lang wie der Monat (üblicher Fall), bleibt die
|
||||
Menge unverändert. Bei mitten im Monat startenden Subscriptions
|
||||
entspricht die Menge dem Tagesanteil.
|
||||
"""
|
||||
if not start or not end or qty == 0:
|
||||
return qty
|
||||
days_in_month = calendar.monthrange(start.year, start.month)[1]
|
||||
warranty_days = (end - start).days + 1
|
||||
if days_in_month <= 0 or warranty_days <= 0:
|
||||
return qty
|
||||
factor = warranty_days / days_in_month
|
||||
# Volle Monate runden auf den Originalwert (Schutz gegen 31/30-Rauschen)
|
||||
if 0.97 <= factor <= 1.03:
|
||||
return qty
|
||||
return round(qty * factor, 4)
|
||||
@@ -60,6 +60,7 @@ class DocumentBuilder:
|
||||
first = rows[0]
|
||||
customer, is_fallback = resolve_customer(
|
||||
first.customer_external_ref, self.profile.fallback_customer,
|
||||
lookup_field=getattr(self.profile, "customer_lookup_field", None) or None,
|
||||
)
|
||||
if not customer:
|
||||
for r in rows:
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""FileHandler für ADN-Hornet-Abrechnungen.
|
||||
|
||||
Wrap-Schicht über :class:`msp.importers.adn_hornet_csv.ADNHornetCSVParser`.
|
||||
Nutzt denselben Run-Orchestrator wie der Microsoft-Handler — Hornet-spezifisch
|
||||
sind nur Sniff (Spalten-Heuristik + Filename-Hint) und der Parser.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import Iterable
|
||||
|
||||
import frappe
|
||||
|
||||
from msp.importers.adn_hornet_csv import EXPECTED_HEADER, ADNHornetCSVParser
|
||||
from msp.importers.handlers.base import (
|
||||
BaseFileHandler,
|
||||
ExecuteResult,
|
||||
PreviewResult,
|
||||
ProgressCallback,
|
||||
)
|
||||
from msp.importers.handlers.registry import register
|
||||
from msp.importers.zip_unpack import is_zip_path, unpack_single_csv
|
||||
|
||||
|
||||
@register
|
||||
class ADNHornetCSVHandler(BaseFileHandler):
|
||||
handler_key = "adn_hornet_csv_v1"
|
||||
display_name = "ADN Hornet CSV (Hornetsecurity)"
|
||||
accepted_extensions = [".csv", ".zip"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sniff
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Pflicht-Spalten zur Hornet-Erkennung; gleiche Familie wie Microsoft,
|
||||
# aber {VERTRAG, WARTUNGSBEGINN, WARTUNGSENDE} sind bei Hornet stets
|
||||
# inhaltlich gefüllt.
|
||||
_SIGNATURE_COLUMNS = {
|
||||
"RECHNUNG", "DATUM", "ENDKUNDE", "HERSTELLERNUMMER",
|
||||
"ENDKUNDE_REFERENCE", "VERTRAG",
|
||||
"WARTUNGSBEGINN", "WARTUNGSENDE",
|
||||
}
|
||||
# Spalten, die exklusiv im Microsoft-Format vorkommen — sind sie da,
|
||||
# spricht das gegen Hornet.
|
||||
_NEGATIVE_COLUMNS = {"BILLINGPLAN", "VERTRAGSDAUER", "MSERP", "ADDITIONALID"}
|
||||
|
||||
@classmethod
|
||||
def sniff(cls, sample_bytes: bytes, filename: str) -> float:
|
||||
name = (filename or "").lower()
|
||||
|
||||
if name.endswith(".zip"):
|
||||
first_line = cls._peek_first_line_in_zip(sample_bytes)
|
||||
if first_line is None:
|
||||
if "hornet" in name:
|
||||
return 0.65
|
||||
return 0.0
|
||||
score = cls._score_first_line(first_line)
|
||||
if "hornet" in name:
|
||||
score = min(1.0, score + 0.1)
|
||||
return score
|
||||
|
||||
if not name.endswith(".csv"):
|
||||
return 0.0
|
||||
|
||||
try:
|
||||
head = sample_bytes.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
first_line = head.split("\n", 1)[0]
|
||||
score = cls._score_first_line(first_line)
|
||||
if "hornet" in name:
|
||||
score = min(1.0, score + 0.1)
|
||||
return score
|
||||
|
||||
@classmethod
|
||||
def _score_first_line(cls, first_line: str) -> float:
|
||||
first_line = (first_line or "").lstrip("\ufeff").strip()
|
||||
if not first_line:
|
||||
return 0.0
|
||||
cols = {c.strip().upper() for c in first_line.split(";")}
|
||||
matching = cls._SIGNATURE_COLUMNS & cols
|
||||
if not matching:
|
||||
return 0.0
|
||||
ratio = len(matching) / len(cls._SIGNATURE_COLUMNS)
|
||||
base = 0.4 + 0.55 * ratio # max ≈ 0.95
|
||||
# Hard penalty wenn MS-Discriminatoren da sind: dann ist's Microsoft.
|
||||
if cls._NEGATIVE_COLUMNS & cols:
|
||||
base = base * 0.4
|
||||
return min(1.0, base)
|
||||
|
||||
@classmethod
|
||||
def _peek_first_line_in_zip(cls, sample_bytes: bytes) -> str | None:
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
if not sample_bytes:
|
||||
return None
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(sample_bytes)) as z:
|
||||
csvs = [m for m in z.namelist()
|
||||
if m.lower().endswith(".csv") and not m.endswith("/")]
|
||||
if not csvs:
|
||||
return None
|
||||
with z.open(csvs[0]) as fh:
|
||||
head = fh.read(2048).decode("utf-8", errors="replace")
|
||||
return head.split("\n", 1)[0]
|
||||
except (zipfile.BadZipFile, EOFError, KeyError):
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Preview
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def preview(cls, file_path: str) -> PreviewResult:
|
||||
csv_path, temp_dir = cls._resolve_csv_path(file_path)
|
||||
try:
|
||||
parser = ADNHornetCSVParser()
|
||||
result = parser.parse(csv_path)
|
||||
|
||||
customers: set[str] = set()
|
||||
products: set[str] = set()
|
||||
period_starts: list = []
|
||||
period_ends: list = []
|
||||
credit_lines = 0
|
||||
for r in result.rows:
|
||||
if r.customer_external_ref:
|
||||
customers.add(r.customer_external_ref)
|
||||
if r.vendor_product_id:
|
||||
products.add(r.vendor_product_id)
|
||||
if r.period_start:
|
||||
period_starts.append(r.period_start)
|
||||
if r.period_end:
|
||||
period_ends.append(r.period_end)
|
||||
if (r.qty or 0) < 0 or (r.amount or 0) < 0 or r.document_type == "Gutschrift":
|
||||
credit_lines += 1
|
||||
|
||||
period_label = cls._format_period_label(period_starts, period_ends)
|
||||
|
||||
suggested_profile = frappe.db.get_value(
|
||||
"Supplier Import Profile",
|
||||
{"parser_key": cls.handler_key, "enabled": 1},
|
||||
"name",
|
||||
)
|
||||
|
||||
return PreviewResult(
|
||||
supplier_profile_suggested=suggested_profile,
|
||||
line_count=len(result.rows),
|
||||
customer_count=len(customers),
|
||||
product_count=len(products),
|
||||
period_label=period_label,
|
||||
format_drift=result.format_drift,
|
||||
credit_note_lines=credit_lines,
|
||||
warnings=list(result.warnings),
|
||||
extra={"invoice_date": str(result.invoice_date) if result.invoice_date else None},
|
||||
)
|
||||
finally:
|
||||
if temp_dir:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execute
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def execute(cls, file_path: str, *, config: dict,
|
||||
progress: ProgressCallback | None = None) -> ExecuteResult:
|
||||
csv_path, temp_dir = cls._resolve_csv_path(file_path)
|
||||
created_run_name: str | None = None
|
||||
try:
|
||||
site_file_url = cls._ensure_site_file_url(csv_path)
|
||||
|
||||
profile_name = config.get("profile") or cls._default_profile_name()
|
||||
if not profile_name:
|
||||
return ExecuteResult(status="Failed", message="Kein Profil gefunden/konfiguriert.")
|
||||
|
||||
run_doc = frappe.get_doc({
|
||||
"doctype": "Supplier Import Run",
|
||||
"profile": profile_name,
|
||||
"file": site_file_url,
|
||||
"output_mode": config.get("output_mode") or None,
|
||||
"acknowledge_format_drift": 1 if config.get("acknowledge_format_drift") else 0,
|
||||
})
|
||||
run_doc.insert(ignore_permissions=True)
|
||||
created_run_name = run_doc.name
|
||||
frappe.db.commit()
|
||||
|
||||
if progress:
|
||||
progress({"phase": "starting", "run": created_run_name})
|
||||
|
||||
from msp.importers.run_orchestrator import execute_run
|
||||
summary = execute_run(run_doc)
|
||||
|
||||
return ExecuteResult(
|
||||
status=summary.get("status", "Failed"),
|
||||
run_name=created_run_name,
|
||||
document_count=summary.get("document_count", 0),
|
||||
line_count=summary.get("line_count", 0),
|
||||
error_count=summary.get("error_count", 0),
|
||||
skipped_count=summary.get("skipped_count", 0),
|
||||
)
|
||||
finally:
|
||||
if temp_dir:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers — kongruent zur Microsoft-Variante, damit sich beide Handler
|
||||
# operativ identisch anfühlen.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _resolve_csv_path(cls, file_path: str) -> tuple[str, str | None]:
|
||||
if is_zip_path(file_path):
|
||||
temp_dir, csv_path = unpack_single_csv(file_path)
|
||||
return csv_path, temp_dir
|
||||
return file_path, None
|
||||
|
||||
@staticmethod
|
||||
def _format_period_label(starts: Iterable, ends: Iterable) -> str | None:
|
||||
starts = [d for d in starts if d]
|
||||
ends = [d for d in ends if d]
|
||||
if not (starts and ends):
|
||||
return None
|
||||
min_start = min(starts)
|
||||
max_end = max(ends)
|
||||
if min_start.strftime("%Y-%m") == max_end.strftime("%Y-%m"):
|
||||
return min_start.strftime("%m.%Y")
|
||||
return f"{min_start.strftime('%m.%Y')} – {max_end.strftime('%m.%Y')}"
|
||||
|
||||
@staticmethod
|
||||
def _default_profile_name() -> str | None:
|
||||
return frappe.db.get_value(
|
||||
"Supplier Import Profile",
|
||||
{"parser_key": "adn_hornet_csv_v1", "enabled": 1},
|
||||
"name",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_site_file_url(csv_path: str) -> str:
|
||||
import shutil as _shutil
|
||||
|
||||
site_path = os.path.abspath(frappe.get_site_path())
|
||||
private_files = os.path.join(site_path, "private", "files")
|
||||
csv_abs = os.path.abspath(csv_path)
|
||||
if csv_abs.startswith(private_files + os.sep):
|
||||
relative = csv_abs[len(private_files) + 1:]
|
||||
return f"/private/files/{relative}"
|
||||
|
||||
basename = os.path.basename(csv_abs)
|
||||
target = os.path.join(private_files, basename)
|
||||
if not os.path.exists(target):
|
||||
_shutil.copyfile(csv_abs, target)
|
||||
return f"/private/files/{basename}"
|
||||
@@ -39,6 +39,9 @@ class ADNMonthlyCSVHandler(BaseFileHandler):
|
||||
"RECHNUNG", "DATUM", "ENDKUNDE", "HERSTELLERNUMMER", "POSITIONSPREIS",
|
||||
"ENDKUNDE_REFERENCE", "SUBSCRIPTION_ID_EXTERNAL",
|
||||
}
|
||||
# Spalten, die ausschließlich im Microsoft-Format vorkommen — sie heben
|
||||
# uns von Hornet-CSVs ab, die dieselbe Header-Familie nutzen.
|
||||
_DISCRIMINATOR_COLUMNS = {"BILLINGPLAN", "VERTRAGSDAUER", "MSERP", "MSERP_BILLINGPERIOD"}
|
||||
|
||||
@classmethod
|
||||
def sniff(cls, sample_bytes: bytes, filename: str) -> float:
|
||||
@@ -78,7 +81,17 @@ class ADNMonthlyCSVHandler(BaseFileHandler):
|
||||
if not matching:
|
||||
return 0.0
|
||||
ratio = len(matching) / len(cls._SIGNATURE_COLUMNS)
|
||||
return min(1.0, 0.4 + 0.6 * ratio)
|
||||
base = 0.4 + 0.55 * ratio # max ≈ 0.95
|
||||
# MS-Discriminatoren heben uns sicher von Hornet ab, das nur Vertrag
|
||||
# kennt: voller Bonus, wenn alle drei Spalten da sind, sonst anteilig.
|
||||
discriminators = cls._DISCRIMINATOR_COLUMNS & cols
|
||||
if discriminators:
|
||||
base = min(1.0, base + 0.05 * len(discriminators))
|
||||
else:
|
||||
# Ohne MS-Discriminatoren ist es entweder Hornet oder ein älterer
|
||||
# MS-Export — wir reichen unter 1.0, damit Hornet entscheiden kann.
|
||||
base = min(0.7, base)
|
||||
return min(1.0, base)
|
||||
|
||||
@classmethod
|
||||
def _peek_first_line_in_zip(cls, sample_bytes: bytes) -> str | None:
|
||||
|
||||
@@ -66,3 +66,4 @@ def detect_best_handler(sample_bytes: bytes, filename: str,
|
||||
def _autoload() -> None:
|
||||
"""Importiert bekannte Handler-Module, damit @register-Dekoratoren laufen."""
|
||||
from msp.importers.handlers import adn_monthly_csv_handler # noqa: F401
|
||||
from msp.importers.handlers import adn_hornet_csv_handler # noqa: F401
|
||||
|
||||
@@ -5,16 +5,26 @@ from __future__ import annotations
|
||||
import frappe
|
||||
|
||||
|
||||
def resolve_customer(external_ref: str | None, fallback: str | None = None) -> tuple[str | None, bool]:
|
||||
"""Gibt (customer_name, is_fallback) zurück. None wenn weder Match noch Fallback greift."""
|
||||
def resolve_customer(external_ref: str | None, fallback: str | None = None,
|
||||
lookup_field: str | None = None) -> tuple[str | None, bool]:
|
||||
"""Gibt (customer_name, is_fallback) zurück. None wenn weder Match noch Fallback greift.
|
||||
|
||||
``lookup_field`` aktiviert Lookups über ein Custom-Field statt direktem
|
||||
Name-Lookup — z. B. ``hornet_domain`` für Hornet-Imports, wo die ADN-CSV
|
||||
keine ERP-Customer-IDs liefert, sondern Mail-Domains.
|
||||
"""
|
||||
if external_ref:
|
||||
exists = frappe.db.exists("Customer", external_ref)
|
||||
if exists:
|
||||
return external_ref, False
|
||||
# Fallback-Match auf customer_name, falls ID nicht existiert
|
||||
match = frappe.db.get_value("Customer", {"customer_name": external_ref}, "name")
|
||||
if match:
|
||||
return match, False
|
||||
if lookup_field:
|
||||
match = frappe.db.get_value("Customer", {lookup_field: external_ref}, "name")
|
||||
if match:
|
||||
return match, False
|
||||
else:
|
||||
if frappe.db.exists("Customer", external_ref):
|
||||
return external_ref, False
|
||||
# Fallback-Match auf customer_name, falls ID nicht existiert
|
||||
match = frappe.db.get_value("Customer", {"customer_name": external_ref}, "name")
|
||||
if match:
|
||||
return match, False
|
||||
if fallback and frappe.db.exists("Customer", fallback):
|
||||
return fallback, True
|
||||
return None, False
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"intro_text_invoice",
|
||||
"intro_text_credit_note",
|
||||
"fallback_section",
|
||||
"fallback_customer"
|
||||
"fallback_customer",
|
||||
"customer_lookup_field"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -205,6 +206,12 @@
|
||||
"label": "Fallback Customer",
|
||||
"options": "Customer",
|
||||
"description": "Wird verwendet, wenn der Endkunde in der Datei nicht im ERP gefunden wird"
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_lookup_field",
|
||||
"fieldtype": "Data",
|
||||
"label": "Customer Lookup Field",
|
||||
"description": "Optional: Custom-Field auf Customer (z. B. 'hornet_domain'), über das die Endkunden-Referenz aufgelöst wird. Leer = direkter Customer-Name-Lookup."
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 0,
|
||||
|
||||
Reference in New Issue
Block a user