mirror of
https://github.com/itsdave-de/msp.git
synced 2026-09-28 04:02:42 -03:00
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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user