fix(msp): Hornet-Run-Vergleich gegen Legacy bit-genau + Run-Backlinks/UI-Totals

Validierung der Hornet-Pipeline gegen drei historische Monate
(10.2025 / 11.2025 / 02.2026, je 16 Sales Invoices in der Test-DB).
Pro Customer und in Summe identisch zu den vom Legacy-adnconnect-
Modul erzeugten Belegen (z. B. Südsee-Camp 634,50 €, Saxlund 234 €,
KFS Fensterbau 162 €, Total 1.836,36 €).

Notwendige Korrekturen unterwegs:

- Hornet-Parser: ENDKUNDE_REFERENCE ist in den realen ADN-Exporten
  praktisch immer leer; die Mail-Domain steht in ENDKUNDE. Legacy-
  Verhalten (adnconnect.adn_hornet_import) gespiegelt: bei leerem
  Reference-Feld auf ENDKUNDE als Domain zurückfallen, sofern es wie
  eine Domain aussieht.

- Sniff inhaltsbasiert: ADN exportiert für Microsoft und Hornet
  dieselbe Spaltenstruktur (inkl. BILLINGPLAN/MSERP/VERTRAGSDAUER) —
  rein kolumnenbasierte Discrimination versagt. Beide Handler
  inspizieren jetzt die ersten Datenzeilen: Hornet gewinnt, wenn die
  MS-Spalten leer sind, MS gewinnt, wenn sie Werte tragen. Mit echten
  CSVs verifiziert (Hornet 1.00/MS 0.55 vs. MS 1.00/Hornet 0.33).

- Title-Template kennt jetzt {invoice_month} (CSV-DATUM, MM.YYYY) —
  ADN stellt für Hornet stets im Folgemonat in Rechnung; Legacy nutzt
  den Rechnungsmonat im Title, nicht die Wartungsperiode. Hornet-
  Profil-Default angepasst.

- DocumentBuilder annotiert erfolgreiche Outcomes mit (target_doctype,
  target_name, resolved_customer). Der Run-Orchestrator-Persist
  verlinkt Supplier Import Lines damit auch dann zurück, wenn keine
  Supply-Subscription-Events erzeugt wurden (Hornet ohne Vertrag-
  Spalte). Behebt 'customer=null'-Anzeige im Wizard-Step-3 und die
  leere Dokumenten-Tabelle.

- run_summary / run_documents zeigen jetzt das Verkaufs-Total der
  erzeugten Sales-Invoice/Delivery-Note (vorher: ADN-Einkaufspreis aus
  Supplier Import Line.amount, was bei Hornet ≠ Verkaufspreis ist).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Malinowski
2026-04-15 09:54:22 +02:00
co-authored by Claude Opus 4.6
parent 6d3619992c
commit d288220d40
8 changed files with 191 additions and 77 deletions
+2 -2
View File
@@ -50,8 +50,8 @@
"supplier": "SUPP-00887", "supplier": "SUPP-00887",
"title_prefix": "Abrechnung Hornetsecurity ", "title_prefix": "Abrechnung Hornetsecurity ",
"title_prefix_credit_note": "Gutschrift Hornetsecurity ", "title_prefix_credit_note": "Gutschrift Hornetsecurity ",
"title_template": "{prefix} {period}", "title_template": "{prefix} {invoice_month}",
"title_template_credit_note": "{prefix} {period}", "title_template_credit_note": "{prefix} {invoice_month}",
"vendor": "Hornetsecurity" "vendor": "Hornetsecurity"
} }
] ]
+8
View File
@@ -117,7 +117,15 @@ class ADNHornetCSVParser(BaseSupplierParser):
return out return out
def _to_canonical(self, d: dict[str, str], source_row: int) -> CanonicalRow | None: def _to_canonical(self, d: dict[str, str], source_row: int) -> CanonicalRow | None:
# ADN-Hornet-CSVs lassen ENDKUNDE_REFERENCE in der Praxis fast immer
# leer und tragen die Mail-Domain stattdessen direkt in ENDKUNDE ein.
# Legacy-Verhalten (adnconnect.adn_hornet_import.get_invoice_dict_from_csv)
# fällt in dem Fall auf ENDKUNDE als Domain zurück.
raw_ref = (d.get("ENDKUNDE_REFERENCE") or "").strip() or None raw_ref = (d.get("ENDKUNDE_REFERENCE") or "").strip() or None
endkunde = (d.get("ENDKUNDE") or "").strip() or None
if not raw_ref and endkunde and "." in endkunde and " " not in endkunde:
# Sieht nach einer Domain aus → als Hornet-Lookup-Schlüssel nutzen.
raw_ref = endkunde
raw_qty = parse_german_decimal(d.get("MENGE")) or 0.0 raw_qty = parse_german_decimal(d.get("MENGE")) or 0.0
list_price = parse_german_decimal(d.get("LISTPREIS")) list_price = parse_german_decimal(d.get("LISTPREIS"))
+66 -17
View File
@@ -103,11 +103,21 @@ def run_documents(run_name: str) -> list[dict]:
``frappe.client.get_list`` auf Child-Doctypes die `fields` ignoriert.""" ``frappe.client.get_list`` auf Child-Doctypes die `fields` ignoriert."""
if not run_name: if not run_name:
return [] return []
# Beträge aus dem erzeugten Ziel-Doc nehmen (Sales Invoice / Delivery Note),
# nicht aus l.amount — das ist der ADN-Einkaufspreis. Der Endkunde sieht
# den Verkaufspreis aus dem SI/DN.
rows = frappe.db.sql( rows = frappe.db.sql(
""" """
SELECT l.customer, c.customer_name, SELECT l.customer, c.customer_name,
l.target_doc_type, l.target_doc_name, l.target_doc_type, l.target_doc_name,
COUNT(*) AS line_count, SUM(COALESCE(l.amount, 0)) AS total_amount COUNT(*) AS line_count,
CASE
WHEN l.target_doc_type = 'Sales Invoice'
THEN COALESCE((SELECT total FROM `tabSales Invoice` WHERE name = l.target_doc_name), 0)
WHEN l.target_doc_type = 'Delivery Note'
THEN COALESCE((SELECT total FROM `tabDelivery Note` WHERE name = l.target_doc_name), 0)
ELSE 0
END AS total_amount
FROM `tabSupplier Import Line` l FROM `tabSupplier Import Line` l
LEFT JOIN `tabCustomer` c ON c.name = l.customer LEFT JOIN `tabCustomer` c ON c.name = l.customer
WHERE l.parent = %(run)s WHERE l.parent = %(run)s
@@ -290,28 +300,67 @@ def run_summary(run_name: str, top_n: int = 5) -> dict:
return {} return {}
totals = frappe.db.sql( totals = frappe.db.sql(
""" """
SELECT COUNT(DISTINCT customer) AS customers, SELECT COUNT(DISTINCT l.customer) AS customers,
COUNT(DISTINCT target_doc_name) AS documents, COUNT(DISTINCT l.target_doc_name) AS documents,
COALESCE(SUM(CASE WHEN line_status = 'Created' THEN amount END), 0) AS total_created, SUM(CASE WHEN l.line_status = 'Created' THEN 1 ELSE 0 END) AS lines_created,
SUM(CASE WHEN line_status = 'Created' THEN 1 ELSE 0 END) AS lines_created, SUM(CASE WHEN l.line_status = 'Skipped' THEN 1 ELSE 0 END) AS lines_skipped,
SUM(CASE WHEN line_status = 'Skipped' THEN 1 ELSE 0 END) AS lines_skipped, SUM(CASE WHEN l.line_status = 'Error' THEN 1 ELSE 0 END) AS lines_error
SUM(CASE WHEN line_status = 'Error' THEN 1 ELSE 0 END) AS lines_error FROM `tabSupplier Import Line` l
FROM `tabSupplier Import Line` WHERE l.parent = %(run)s
WHERE parent = %(run)s
""", """,
{"run": run_name}, {"run": run_name},
as_dict=True, as_dict=True,
) )
# Verkaufs-Total über alle erzeugten Ziel-Docs (DISTINCT, damit Multi-Line-
# SIs nicht vervielfacht werden).
totals_sum = frappe.db.sql(
"""
SELECT COALESCE(SUM(t.doc_total), 0) AS total_created
FROM (
SELECT DISTINCT l.target_doc_type, l.target_doc_name,
CASE
WHEN l.target_doc_type = 'Sales Invoice'
THEN COALESCE((SELECT total FROM `tabSales Invoice` si WHERE si.name = l.target_doc_name), 0)
WHEN l.target_doc_type = 'Delivery Note'
THEN COALESCE((SELECT total FROM `tabDelivery Note` dn WHERE dn.name = l.target_doc_name), 0)
ELSE 0
END AS doc_total
FROM `tabSupplier Import Line` l
WHERE l.parent = %(run)s AND l.line_status = 'Created'
AND l.target_doc_name IS NOT NULL AND l.target_doc_name != ''
) t
""",
{"run": run_name},
as_dict=True,
)
if totals and totals_sum:
totals[0]["total_created"] = float(totals_sum[0]["total_created"] or 0)
# Top-Kunden: erst pro (customer, target_doc) das Doc-Total holen,
# dann pro Customer summieren.
top = frappe.db.sql( top = frappe.db.sql(
""" """
SELECT l.customer, c.customer_name, SELECT customer, MAX(customer_name) AS customer_name,
COUNT(DISTINCT l.target_doc_name) AS documents, COUNT(DISTINCT target_doc_name) AS documents,
COUNT(*) AS line_count, SUM(line_count) AS line_count,
COALESCE(SUM(l.amount), 0) AS total_amount SUM(doc_total) AS total_amount
FROM `tabSupplier Import Line` l FROM (
LEFT JOIN `tabCustomer` c ON c.name = l.customer SELECT l.customer, c.customer_name, l.target_doc_type, l.target_doc_name,
WHERE l.parent = %(run)s AND l.line_status = 'Created' COUNT(*) AS line_count,
GROUP BY l.customer, c.customer_name CASE
WHEN l.target_doc_type = 'Sales Invoice'
THEN COALESCE((SELECT total FROM `tabSales Invoice` si WHERE si.name = l.target_doc_name), 0)
WHEN l.target_doc_type = 'Delivery Note'
THEN COALESCE((SELECT total FROM `tabDelivery Note` dn WHERE dn.name = l.target_doc_name), 0)
ELSE 0
END AS doc_total
FROM `tabSupplier Import Line` l
LEFT JOIN `tabCustomer` c ON c.name = l.customer
WHERE l.parent = %(run)s AND l.line_status = 'Created'
AND l.target_doc_name IS NOT NULL AND l.target_doc_name != ''
GROUP BY l.customer, c.customer_name, l.target_doc_type, l.target_doc_name
) t
GROUP BY customer
ORDER BY total_amount DESC ORDER BY total_amount DESC
LIMIT %(limit)s LIMIT %(limit)s
""", """,
+14
View File
@@ -106,6 +106,16 @@ class DocumentBuilder:
result.created_documents.append((doc.doctype, doc.name)) result.created_documents.append((doc.doctype, doc.name))
# Erfolgreiche Outcomes mit dem Ziel-Dokument annotieren — damit der
# Persist-Schritt im Run-Orchestrator die Zeilen rückverlinken kann,
# auch wenn keine Subscription-Events erzeugt wurden (z. B. Hornet
# ohne Vertrag-Spalte).
for o in outcomes_this_group:
if o.get("status") == "Created":
o.setdefault("target_doctype", doc.doctype)
o.setdefault("target_name", doc.name)
o.setdefault("resolved_customer", customer)
# Subscription-Events erst nach erfolgreichem Insert, damit wir die # Subscription-Events erst nach erfolgreichem Insert, damit wir die
# echten Item-Row-IDs nutzen können. # echten Item-Row-IDs nutzen können.
self._post_create_subscriptions(doc, rows, result) self._post_create_subscriptions(doc, rows, result)
@@ -327,10 +337,14 @@ class DocumentBuilder:
return f"{prefix.strip()} {period}".strip() return f"{prefix.strip()} {period}".strip()
customer_name = frappe.db.get_value("Customer", customer, "customer_name") or customer customer_name = frappe.db.get_value("Customer", customer, "customer_name") or customer
invoice_month = (
posting_date.strftime("%m.%Y") if hasattr(posting_date, "strftime") else ""
)
try: try:
rendered = template.format( rendered = template.format(
prefix=prefix.strip(), prefix=prefix.strip(),
period=period, period=period,
invoice_month=invoice_month,
customer=customer, customer=customer,
customer_name=customer_name, customer_name=customer_name,
vendor=profile.vendor or "", vendor=profile.vendor or "",
@@ -42,21 +42,18 @@ class ADNHornetCSVHandler(BaseFileHandler):
"ENDKUNDE_REFERENCE", "VERTRAG", "ENDKUNDE_REFERENCE", "VERTRAG",
"WARTUNGSBEGINN", "WARTUNGSENDE", "WARTUNGSBEGINN", "WARTUNGSENDE",
} }
# Spalten, die exklusiv im Microsoft-Format vorkommen — sind sie da,
# spricht das gegen Hornet.
_NEGATIVE_COLUMNS = {"BILLINGPLAN", "VERTRAGSDAUER", "MSERP", "ADDITIONALID"}
@classmethod @classmethod
def sniff(cls, sample_bytes: bytes, filename: str) -> float: def sniff(cls, sample_bytes: bytes, filename: str) -> float:
name = (filename or "").lower() name = (filename or "").lower()
if name.endswith(".zip"): if name.endswith(".zip"):
first_line = cls._peek_first_line_in_zip(sample_bytes) lines = cls._peek_lines_in_zip(sample_bytes)
if first_line is None: if not lines:
if "hornet" in name: if "hornet" in name:
return 0.65 return 0.65
return 0.0 return 0.0
score = cls._score_first_line(first_line) score = cls._score_lines(lines)
if "hornet" in name: if "hornet" in name:
score = min(1.0, score + 0.1) score = min(1.0, score + 0.1)
return score return score
@@ -69,46 +66,71 @@ class ADNHornetCSVHandler(BaseFileHandler):
except Exception: except Exception:
return 0.0 return 0.0
first_line = head.split("\n", 1)[0] lines = head.split("\n", 5)[:5]
score = cls._score_first_line(first_line) score = cls._score_lines(lines)
if "hornet" in name: if "hornet" in name:
score = min(1.0, score + 0.1) score = min(1.0, score + 0.1)
return score return score
@classmethod @classmethod
def _score_first_line(cls, first_line: str) -> float: def _score_lines(cls, lines: list[str]) -> float:
first_line = (first_line or "").lstrip("\ufeff").strip() if not lines:
if not first_line:
return 0.0 return 0.0
cols = {c.strip().upper() for c in first_line.split(";")} header_line = (lines[0] or "").lstrip("\ufeff").strip()
matching = cls._SIGNATURE_COLUMNS & cols if not header_line:
return 0.0
header_cols = [c.strip() for c in header_line.split(";")]
header_upper = {c.upper() for c in header_cols if c}
matching = cls._SIGNATURE_COLUMNS & header_upper
if not matching: if not matching:
return 0.0 return 0.0
ratio = len(matching) / len(cls._SIGNATURE_COLUMNS) ratio = len(matching) / len(cls._SIGNATURE_COLUMNS)
base = 0.4 + 0.55 * ratio # max ≈ 0.95 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: # Inhaltsbasierter Discriminator: wenn die MS-spezifischen Spalten
base = base * 0.4 # (BILLINGPLAN/MSERP/VERTRAGSDAUER) zwar im Header stehen, aber in den
# ersten Datenzeilen leer bleiben → Hornet-Variante.
ms_payload = cls._has_ms_payload(header_cols, lines[1:])
if ms_payload:
# MS-Werte vorhanden — wir sind hier nicht zuständig.
base = base * 0.35
else:
base = min(1.0, base + 0.1)
return min(1.0, base) return min(1.0, base)
@staticmethod
def _has_ms_payload(header_cols: list[str], data_lines: list[str]) -> bool:
header_upper = [c.strip().upper() for c in header_cols]
ms_indices = [i for i, c in enumerate(header_upper)
if c in {"BILLINGPLAN", "MSERP", "VERTRAGSDAUER"}]
if not ms_indices:
return False
for line in data_lines:
if not line or not line.strip():
continue
cells = [c.strip().strip('"') for c in line.split(";")]
if any(i < len(cells) and cells[i] for i in ms_indices):
return True
return False
@classmethod @classmethod
def _peek_first_line_in_zip(cls, sample_bytes: bytes) -> str | None: def _peek_lines_in_zip(cls, sample_bytes: bytes) -> list[str]:
import io import io
import zipfile import zipfile
if not sample_bytes: if not sample_bytes:
return None return []
try: try:
with zipfile.ZipFile(io.BytesIO(sample_bytes)) as z: with zipfile.ZipFile(io.BytesIO(sample_bytes)) as z:
csvs = [m for m in z.namelist() csvs = [m for m in z.namelist()
if m.lower().endswith(".csv") and not m.endswith("/")] if m.lower().endswith(".csv") and not m.endswith("/")]
if not csvs: if not csvs:
return None return []
with z.open(csvs[0]) as fh: with z.open(csvs[0]) as fh:
head = fh.read(2048).decode("utf-8", errors="replace") head = fh.read(8192).decode("utf-8", errors="replace")
return head.split("\n", 1)[0] return head.split("\n", 5)[:5]
except (zipfile.BadZipFile, EOFError, KeyError): except (zipfile.BadZipFile, EOFError, KeyError):
return None return []
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Preview # Preview
@@ -47,72 +47,88 @@ class ADNMonthlyCSVHandler(BaseFileHandler):
def sniff(cls, sample_bytes: bytes, filename: str) -> float: def sniff(cls, sample_bytes: bytes, filename: str) -> float:
name = (filename or "").lower() name = (filename or "").lower()
# ZIP: Inhalt auspacken und die erste CSV-Kopfzeile prüfen. So funktioniert # ZIP: Inhalt auspacken und die ersten Zeilen prüfen.
# die Erkennung auch bei umbenannten ZIPs.
if name.endswith(".zip"): if name.endswith(".zip"):
first_line = cls._peek_first_line_in_zip(sample_bytes) lines = cls._peek_lines_in_zip(sample_bytes)
if first_line is None: if not lines:
# Kein CSV im ZIP zu sehen (vielleicht verschlüsselt oder zu groß
# im Sample) — greifen zurück auf Namens-Heuristik.
if "rechnungen" in name or "433148" in name: if "rechnungen" in name or "433148" in name:
return 0.65 return 0.65
return 0.0 return 0.0
return cls._score_first_line(first_line) return cls._score_lines(lines)
if not name.endswith(".csv"): if not name.endswith(".csv"):
return 0.0 return 0.0
# CSV-Header inspizieren: erste Zeile (bis 8 KiB reicht)
try: try:
head = sample_bytes.decode("utf-8", errors="replace") head = sample_bytes.decode("utf-8", errors="replace")
except Exception: except Exception:
return 0.0 return 0.0
first_line = head.split("\n", 1)[0] lines = head.split("\n", 5)[:5]
return cls._score_first_line(first_line) return cls._score_lines(lines)
@classmethod @classmethod
def _score_first_line(cls, first_line: str) -> float: def _score_lines(cls, lines: list[str]) -> float:
first_line = (first_line or "").lstrip("\ufeff").strip() if not lines:
if not first_line:
return 0.0 return 0.0
cols = {c.strip().upper() for c in first_line.split(";")} header_line = (lines[0] or "").lstrip("\ufeff").strip()
matching = cls._SIGNATURE_COLUMNS & cols if not header_line:
return 0.0
header_cols = [c.strip() for c in header_line.split(";")]
header_upper = {c.upper() for c in header_cols if c}
matching = cls._SIGNATURE_COLUMNS & header_upper
if not matching: if not matching:
return 0.0 return 0.0
ratio = len(matching) / len(cls._SIGNATURE_COLUMNS) ratio = len(matching) / len(cls._SIGNATURE_COLUMNS)
base = 0.4 + 0.55 * ratio # max ≈ 0.95 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. # Inhaltsbasierter Discriminator: ADN exportiert für Hornet dieselbe
discriminators = cls._DISCRIMINATOR_COLUMNS & cols # Spaltenstruktur (inkl. BILLINGPLAN/MSERP), füllt die MS-spezifischen
if discriminators: # Spalten dort aber nicht. Wir prüfen darum die ersten Datenzeilen auf
base = min(1.0, base + 0.05 * len(discriminators)) # Werte in BILLINGPLAN / MSERP / VERTRAGSDAUER.
ms_evidence = cls._has_ms_payload(header_cols, lines[1:])
if ms_evidence:
base = min(1.0, base + 0.15)
else: else:
# Ohne MS-Discriminatoren ist es entweder Hornet oder ein älterer # Spalten da, Werte aber leer → typisch Hornet, MS verliert.
# MS-Export — wir reichen unter 1.0, damit Hornet entscheiden kann. base = min(0.55, base)
base = min(0.7, base)
return min(1.0, base) return min(1.0, base)
@staticmethod
def _has_ms_payload(header_cols: list[str], data_lines: list[str]) -> bool:
header_upper = [c.strip().upper() for c in header_cols]
ms_indices = [i for i, c in enumerate(header_upper)
if c in {"BILLINGPLAN", "MSERP", "VERTRAGSDAUER"}]
if not ms_indices:
return False
for line in data_lines:
if not line or not line.strip():
continue
cells = [c.strip().strip('"') for c in line.split(";")]
if any(i < len(cells) and cells[i] for i in ms_indices):
return True
return False
@classmethod @classmethod
def _peek_first_line_in_zip(cls, sample_bytes: bytes) -> str | None: def _peek_lines_in_zip(cls, sample_bytes: bytes) -> list[str]:
"""Öffnet das ZIP in-memory und gibt die erste Zeile der enthaltenen CSV """Öffnet das ZIP in-memory und gibt die ersten ~5 Zeilen der enthaltenen
zurück. None, wenn keine CSV gefunden oder der Sample zu klein ist.""" CSV zurück. Leere Liste, wenn keine CSV gefunden oder Sample zu klein."""
import io import io
import zipfile import zipfile
if not sample_bytes: if not sample_bytes:
return None return []
try: try:
with zipfile.ZipFile(io.BytesIO(sample_bytes)) as z: with zipfile.ZipFile(io.BytesIO(sample_bytes)) as z:
csvs = [m for m in z.namelist() csvs = [m for m in z.namelist()
if m.lower().endswith(".csv") and not m.endswith("/")] if m.lower().endswith(".csv") and not m.endswith("/")]
if not csvs: if not csvs:
return None return []
with z.open(csvs[0]) as fh: with z.open(csvs[0]) as fh:
head = fh.read(2048).decode("utf-8", errors="replace") head = fh.read(8192).decode("utf-8", errors="replace")
return head.split("\n", 1)[0] return head.split("\n", 5)[:5]
except (zipfile.BadZipFile, EOFError, KeyError): except (zipfile.BadZipFile, EOFError, KeyError):
return None return []
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Preview # Preview
+9 -4
View File
@@ -181,11 +181,12 @@ def _persist_lines(run, outcomes: Iterable[dict]) -> None:
run.set("lines", []) run.set("lines", [])
for o in outcomes: for o in outcomes:
row: CanonicalRow = o["row"] row: CanonicalRow = o["row"]
target_doctype = None target_doctype = o.get("target_doctype")
target_name = None target_name = o.get("target_name")
target_row = None target_row = None
if o.get("status") == "Created": if o.get("status") == "Created":
# Aus Subscription-Event das Ziel rückgewinnen (best effort) # Aus Subscription-Event das Ziel rückgewinnen (überschreibt das vom
# Builder gesetzte Backup, falls genauer — z. B. Item-Row-ID).
event_name = o.get("event") event_name = o.get("event")
if event_name: if event_name:
ev = frappe.db.get_value( ev = frappe.db.get_value(
@@ -204,9 +205,13 @@ def _persist_lines(run, outcomes: Iterable[dict]) -> None:
target_name = ev["delivery_note"] target_name = ev["delivery_note"]
target_row = ev.get("delivery_note_item_row") target_row = ev.get("delivery_note_item_row")
# Customer-Ref: bei Lookup-Field-Profilen (Hornet) ist die Ref eine
# Domain — der Builder hat dann die echte CUST-ID resolved.
customer_field = o.get("resolved_customer") or _customer_or_null(row.customer_external_ref)
run.append("lines", { run.append("lines", {
"source_row": row.source_row, "source_row": row.source_row,
"customer": _customer_or_null(row.customer_external_ref), "customer": customer_field,
"customer_external_ref": row.customer_external_ref, "customer_external_ref": row.customer_external_ref,
"vendor_product_id": row.vendor_product_id, "vendor_product_id": row.vendor_product_id,
"qty": row.qty or 0, "qty": row.qty or 0,
@@ -177,7 +177,7 @@
"fieldname": "title_template", "fieldname": "title_template",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Title Template (Invoice)", "label": "Title Template (Invoice)",
"description": "Optionales Format-String für den Beleg-Titel. Platzhalter: {prefix}, {period}, {customer}, {customer_name}, {vendor}, {posting_date}, {document_type}. Leer = '{prefix} {period}'." "description": "Optionales Format-String für den Beleg-Titel. Platzhalter: {prefix}, {period}, {invoice_month}, {customer}, {customer_name}, {vendor}, {posting_date}, {document_type}. Leer = '{prefix} {period}'."
}, },
{ {
"fieldname": "title_template_credit_note", "fieldname": "title_template_credit_note",