fix(msp): Kundenrechnung mit Listpreis, Einkaufspreis + Marge separat

Wichtige Semantik-Korrektur: Der Distributor-Rabatt in ADN-CSVs (z. B.
17,5 %) ist die Wiederverkäufer-Marge von itsdave und darf NICHT an den
Endkunden durchgereicht werden. Der Kunde zahlt den LISTPREIS; itsdave
bezahlt an ADN den EINZELPREIS (= Listpreis × (1 − Rabatt)).

Bisher hat der Builder fälschlich den Einzelpreis als Sales-Invoice-Rate
verwendet — der Kunde hätte dadurch rund 17,5 % zu wenig berechnet
bekommen.

Änderungen:
- builder.py: rate = row.list_price (Fallback: unit_price, amount/qty)
- subscriptions.py: Supply Subscription Event trägt jetzt sowohl
  Kundensicht (rate, amount) als auch Einkaufssicht (cost_rate,
  cost_amount, reseller_discount_pct).
- supply_subscription_event: neue Sektion „Einkauf & Marge" mit
  cost_rate, cost_amount, reseller_discount_pct, margin_amount
  (automatisch berechnet: amount − cost_amount).

Diff-Test Feb-2026-CSV:
50 von 57 Kunden nun deckungsgleich mit adnconnect.
7 verbleibende Diffs:
  5 echte adnconnect-Bugs (Split-Logik / leere SIs) — in Summe 762 €
    Unterabrechnung gegenüber korrektem Listpreis
  2 Testsystem-Tax-Template-Drift (Power BI Pro) — kein Code-Problem
This commit is contained in:
David Malinowski
2026-04-14 12:56:42 +02:00
parent 1a92d4c545
commit 0fa8c70655
4 changed files with 71 additions and 15 deletions
+7 -11
View File
@@ -165,15 +165,16 @@ class DocumentBuilder:
if is_return and qty > 0: if is_return and qty > 0:
qty = -qty qty = -qty
# ERPNext berechnet amount = qty * rate. `rate` muss daher der # WICHTIG: Kundenabrechnungen nutzen den LISTPREIS, nicht den Einkaufspreis.
# tatsächliche Netto-Einzelpreis nach Rabatt sein (EINZELPREIS aus ADN). # Der Lieferanten-Rabatt (z. B. 17,5 % bei ADN) ist unsere Marge und
# Fallback-Reihenfolge: unit_price → amount/qty → list_price # darf nicht an den Endkunden durchgereicht werden.
if row.unit_price is not None: # Fallback-Reihenfolge: list_price → unit_price → amount/qty
if row.list_price is not None:
rate = row.list_price
elif row.unit_price is not None:
rate = row.unit_price rate = row.unit_price
elif row.amount is not None and qty: elif row.amount is not None and qty:
rate = row.amount / qty rate = row.amount / qty
elif row.list_price is not None:
rate = row.list_price
else: else:
rate = 0 rate = 0
@@ -183,11 +184,6 @@ class DocumentBuilder:
"rate": rate, "rate": rate,
"description": self._format_description(row), "description": self._format_description(row),
} }
# Listenpreis + Rabatt zur Nachvollziehbarkeit in der UI mitführen
if row.list_price is not None:
item_row["price_list_rate"] = row.list_price
if row.discount_pct:
item_row["discount_percentage"] = row.discount_pct
item = doc.append("items", item_row) item = doc.append("items", item_row)
+17 -4
View File
@@ -61,15 +61,28 @@ def upsert_supply_subscription(row: CanonicalRow, *, customer: str,
def create_event(row: CanonicalRow, *, supply_subscription: str, def create_event(row: CanonicalRow, *, supply_subscription: str,
target_doctype: str, target_name: str, target_row: str | None = None, target_doctype: str, target_name: str, target_row: str | None = None,
supplier_import_line_row: str | None = None) -> str: supplier_import_line_row: str | None = None) -> str:
"""Erzeugt ein Supply Subscription Event für eine abgerechnete Periode.""" """Erzeugt ein Supply Subscription Event für eine abgerechnete Periode.
``rate`` und ``amount`` spiegeln den **Kundenpreis** (Listenpreis × Menge),
``cost_rate`` und ``cost_amount`` den Einkaufspreis bei Lieferant.
Differenz = itsdaves Marge.
"""
qty = int(row.qty) if row.qty else 0
list_price = row.list_price
customer_rate = list_price if list_price is not None else (row.unit_price or 0)
customer_amount = qty * customer_rate
event = frappe.get_doc({ event = frappe.get_doc({
"doctype": "Supply Subscription Event", "doctype": "Supply Subscription Event",
"supply_subscription": supply_subscription, "supply_subscription": supply_subscription,
"period_start": row.period_start, "period_start": row.period_start,
"period_end": row.period_end, "period_end": row.period_end,
"qty": int(row.qty) if row.qty else 0, "qty": qty,
"rate": row.unit_price or row.list_price or 0, "rate": customer_rate,
"amount": row.amount or 0, "amount": customer_amount,
"cost_rate": row.unit_price,
"cost_amount": row.amount,
"reseller_discount_pct": row.discount_pct,
"booking_type": row.booking_type or "Billing", "booking_type": row.booking_type or "Billing",
"sales_invoice": target_name if target_doctype == "Sales Invoice" else None, "sales_invoice": target_name if target_doctype == "Sales Invoice" else None,
"sales_invoice_item_row": target_row if target_doctype == "Sales Invoice" else None, "sales_invoice_item_row": target_row if target_doctype == "Sales Invoice" else None,
@@ -20,6 +20,12 @@
"qty", "qty",
"rate", "rate",
"amount", "amount",
"cost_section",
"cost_rate",
"cost_amount",
"column_break_cost",
"reseller_discount_pct",
"margin_amount",
"link_section", "link_section",
"sales_invoice", "sales_invoice",
"sales_invoice_item_row", "sales_invoice_item_row",
@@ -116,6 +122,42 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Amount" "label": "Amount"
}, },
{
"fieldname": "cost_section",
"fieldtype": "Section Break",
"label": "Einkauf & Marge",
"collapsible": 1,
"description": "Interne Daten (was itsdave an den Lieferanten bezahlt hat) — dient nicht der Kundenabrechnung."
},
{
"fieldname": "cost_rate",
"fieldtype": "Currency",
"label": "Cost Rate",
"description": "Einzelpreis nach Wiederverkäufer-Rabatt (z. B. ADN EINZELPREIS)"
},
{
"fieldname": "cost_amount",
"fieldtype": "Currency",
"label": "Cost Amount",
"description": "qty × cost_rate (z. B. ADN POSITIONSPREIS)"
},
{
"fieldname": "column_break_cost",
"fieldtype": "Column Break"
},
{
"fieldname": "reseller_discount_pct",
"fieldtype": "Float",
"label": "Reseller Discount (%)",
"description": "Wiederverkäufer-Rabatt gegenüber Listenpreis"
},
{
"fieldname": "margin_amount",
"fieldtype": "Currency",
"label": "Margin",
"read_only": 1,
"description": "amount − cost_amount"
},
{ {
"fieldname": "link_section", "fieldname": "link_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@@ -3,6 +3,11 @@ from frappe.model.document import Document
class SupplySubscriptionEvent(Document): class SupplySubscriptionEvent(Document):
def before_save(self):
# Marge berechnen, sofern beide Seiten bekannt sind
if self.amount is not None and self.cost_amount is not None:
self.margin_amount = float(self.amount) - float(self.cost_amount)
def on_update(self): def on_update(self):
self._refresh_subscription_denormals() self._refresh_subscription_denormals()