feat(msp): License Coverage — lieferantenneutrale Inklusiv-Lizenzen mit Builder-Integration

Generalisierung des bisher nur in adnconnect existierenden Konzepts „inkludierte
Lizenzen" (über IT-Verträge gedeckte Microsoft-Seats) in einen lieferantenneutralen
DocType im MSP-Modul. Damit wird die Pauschal-Vertrags-Logik unabhängig vom
Distributor (ADN heute, ALSO/Ingram/Pax8 morgen).

Datenmodell:
- License Coverage (parent): customer + optional it_contract / valid_from /
  valid_to + enabled-Toggle + freie Notes. Mehrere Coverages pro Kunde sind
  zulässig (z. B. mehrere Standorte, parallele Verträge).
- License Coverage Item (child): item-Link (lieferantenagnostisch) + qty.
  vendor_product_id wird vom Item gefetched für Plausibilität / Dokumentation.

Builder-Integration (msp.importers.coverage + builder.py):
- Pro Importlauf wird ein CoverageCache aller relevanten Kunden einmal aus der
  DB geladen (eine SQL-Abfrage statt N pro Position).
- _append_line splittet jede CanonicalRow bei Bedarf in zwei Sales-Invoice-/
  Delivery-Note-Items: min(qty, covered) mit discount_percentage=100 +
  „ohne Berechnung"-Hinweis, plus Restmenge zum Listpreis.
- Subscription-Event referenziert die primäre (=erste) Item-Row, das
  supply_subscription-Link wird auf allen Item-Rows der kanonischen Zeile
  gesetzt.
- Coverage greift nur bei echten Rechnungen, nicht bei Gutschriften.

Migration:
- Patch migrate_adn_included_services_to_license_coverage übernimmt bestehende
  ADN Included Services (parent + items) idempotent in License Coverage. Marker
  im Notes-Feld verhindert Doppel-Migration. Auf dem Testsystem 5/5 Records
  korrekt portiert.

Validation:
- Re-Import der 5 Test-Monate (Nov 2025 – Mär 2026) zeigt: die 5 Coverage-Kunden
  produzieren jetzt deckungsgleiche Beträge zu adnconnect (z. B. CUST-21877:
  10× Premium berechnet + 11× zum 100%-Rabatt = exakt das Muster von
  SINV-269822). Verbleibende Diffs nur noch durch Power-BI-Pro-Tax-Template
  auf Testsystem.

Reconciliation-POC erweitert: erp_coverage() liest die License Coverage über
die ERPNext-REST-API; Reconciliation-Tabelle zeigt jetzt Spalten Graph / ERP /
Coverage / Billable mit klassifizierten Status-Hinweisen (z. B. „Coverage > Graph
= Upsell-Chance").
This commit is contained in:
David Malinowski
2026-04-14 14:03:56 +02:00
parent 0fa8c70655
commit f284399fec
11 changed files with 457 additions and 30 deletions
+70 -29
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
import frappe
from msp.importers.base import CanonicalRow
from msp.importers.coverage import CoverageCache
from msp.importers.resolution import resolve_customer, resolve_item
from msp.importers.subscriptions import (
create_event,
@@ -23,8 +24,9 @@ class BuildResult:
class DocumentBuilder:
def __init__(self, profile):
def __init__(self, profile, coverage: CoverageCache | None = None):
self.profile = profile
self.coverage = coverage or CoverageCache()
self.output_mode: str = "Sales Invoice" # wird pro Gruppe gesetzt
# -----------------------------------------------------------------
@@ -178,38 +180,76 @@ class DocumentBuilder:
else:
rate = 0
item_row = {
"item_code": item_code,
"qty": qty,
"rate": rate,
"description": self._format_description(row),
# License Coverage: ggf. Split in (kostenlose, abgedeckte) + (reguläre Rest-)Position.
# Gilt nur für echte Rechnungen — nicht für Gutschriften, die die Originalposition
# komplett reversieren.
covered_qty = 0
if not is_return:
covered_qty = self.coverage.get_covered_qty(doc.customer, item_code, row.period_start)
start_idx = len(doc.items)
base_description = self._format_description(row)
def _append(line_qty: float, description: str, is_covered: bool) -> None:
line = {
"item_code": item_code,
"qty": line_qty,
"rate": rate,
"description": description,
}
if is_covered:
line["discount_percentage"] = 100
item = doc.append("items", line)
if self.output_mode == "Delivery Note" and self.profile.default_warehouse:
item.warehouse = self.profile.default_warehouse
if covered_qty > 0 and qty > 0:
covered_portion = min(qty, covered_qty)
_append(covered_portion,
description=f"{base_description}<br><strong>ohne Berechnung</strong>",
is_covered=True)
remaining = qty - covered_portion
if remaining > 0:
_append(remaining, description=base_description, is_covered=False)
else:
_append(qty, description=base_description, is_covered=False)
produced_indices = list(range(start_idx, len(doc.items)))
return {
"row": row,
"status": "Created",
"error": None,
"item_indices": produced_indices,
"covered_qty": min(qty, covered_qty) if (covered_qty > 0 and qty > 0) else 0,
}
item = doc.append("items", item_row)
# Warehouse für Delivery Note erforderlich
if self.output_mode == "Delivery Note" and self.profile.default_warehouse:
item.warehouse = self.profile.default_warehouse
return {"row": row, "status": "Created", "error": None}
def _post_create_subscriptions(self, doc, rows: list[CanonicalRow], result: BuildResult) -> None:
"""Erzeugt pro successful row ein Supply Subscription Event (sofern eine Subscription-ID vorhanden ist)."""
# Wir gehen davon aus, dass doc.items in gleicher Reihenfolge wie rows angelegt wurde,
# abzüglich der error/skipped-Zeilen.
created_rows = [o for o in result.line_outcomes[-len(rows):] if o["status"] == "Created"]
if len(created_rows) != len(doc.items):
# Fallback: keine 1:1 Zuordnung — überspringen, wird später nachgezogen
return
"""Erzeugt pro successful row ein Supply Subscription Event (sofern eine Subscription-ID vorhanden ist).
for outcome, item_row in zip(created_rows, doc.items):
Bei License-Coverage-Split kann eine kanonische Zeile 1–2 Item-Rows produziert haben.
Wir verwenden die in _append_line gespeicherten ``item_indices``, um das erste
(= „primäre") Item als Anker für Subscription + Link-Feld zu nutzen, und setzen
den supply_subscription-Link auf *allen* zur Zeile gehörenden Item-Rows.
"""
created_rows = [o for o in result.line_outcomes[-len(rows):] if o["status"] == "Created"]
for outcome in created_rows:
r: CanonicalRow = outcome["row"]
indices = outcome.get("item_indices") or []
if not indices:
continue
item_rows = [doc.items[i] for i in indices if i < len(doc.items)]
if not item_rows:
continue
primary = item_rows[0]
if not r.subscription_external_id:
continue
vendor = self.profile.vendor
supplier = self.profile.supplier
sub_name = upsert_supply_subscription(
r, customer=doc.customer, supplier=supplier, vendor=vendor, item=item_row.item_code,
r, customer=doc.customer, supplier=supplier, vendor=vendor, item=primary.item_code,
)
if not sub_name:
continue
@@ -219,13 +259,14 @@ class DocumentBuilder:
supply_subscription=sub_name,
target_doctype=doc.doctype,
target_name=doc.name,
target_row=item_row.name,
)
# Link auf dem Item-Row setzen
frappe.db.set_value(
f"{doc.doctype} Item", item_row.name,
"supply_subscription", sub_name,
target_row=primary.name,
)
# Link auf allen zur kanonischen Zeile gehörenden Item-Rows setzen
for ir in item_rows:
frappe.db.set_value(
f"{doc.doctype} Item", ir.name,
"supply_subscription", sub_name,
)
outcome["event"] = event_name
outcome["supply_subscription"] = sub_name
except Exception as e:
+78
View File
@@ -0,0 +1,78 @@
"""Lookup-Helper für License Coverage.
Stellt die Inklusiv-Mengen pro (customer, item) für einen Importlauf bereit.
Pro Run werden die relevanten Coverage-Einträge **einmal** geladen und danach
aus dem In-Memory-Cache geliefert — DB-Zugriffe pro Rechnungsposition werden
so vermieden.
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date
from typing import Iterable
import frappe
@dataclass
class CoverageCache:
"""Vorab geladene Coverage-Einträge für einen Importlauf."""
# (customer, item) -> [(qty, valid_from, valid_to, coverage_name), ...]
_entries: dict[tuple[str, str], list[tuple[int, date | None, date | None, str]]] = field(
default_factory=lambda: defaultdict(list)
)
def add(self, customer: str, item: str, qty: int,
valid_from: date | None, valid_to: date | None,
coverage_name: str) -> None:
self._entries[(customer, item)].append((qty, valid_from, valid_to, coverage_name))
def get_covered_qty(self, customer: str, item: str, period: date | None) -> int:
"""Liefert die gesamte inkludierte Menge für (customer, item) zum Stichtag."""
if not (customer and item):
return 0
entries = self._entries.get((customer, item))
if not entries:
return 0
total = 0
for qty, vf, vt, _ in entries:
if period is not None:
if vf is not None and period < vf:
continue
if vt is not None and period > vt:
continue
total += qty
return total
def load_coverage_cache(customer_refs: Iterable[str]) -> CoverageCache:
"""Lädt alle aktiven Coverage-Items für die angegebenen Kunden."""
customers = [c for c in customer_refs if c]
cache = CoverageCache()
if not customers:
return cache
rows = frappe.db.sql(
"""
SELECT c.name, c.customer, c.valid_from, c.valid_to,
ci.item, ci.qty
FROM `tabLicense Coverage` c
JOIN `tabLicense Coverage Item` ci ON ci.parent = c.name
WHERE c.enabled = 1 AND c.customer IN %(customers)s
""",
{"customers": tuple(customers)},
as_dict=True,
)
for r in rows:
cache.add(
customer=r["customer"],
item=r["item"],
qty=int(r["qty"] or 0),
valid_from=r["valid_from"],
valid_to=r["valid_to"],
coverage_name=r["name"],
)
return cache
+7 -1
View File
@@ -22,6 +22,7 @@ from frappe.utils import now_datetime
from msp.importers.base import CanonicalRow, ParseResult
from msp.importers.builder import BuildResult, DocumentBuilder
from msp.importers.coverage import load_coverage_cache
from msp.importers.registry import resolve
from msp.importers.resolution import determine_effective_output_mode
@@ -55,11 +56,16 @@ def execute_run(run) -> dict:
grouped = _group_rows(parse_result.rows)
log(f"Gruppen (Kunden): {len(grouped)}")
# Coverage-Cache einmal laden (eine Query für alle Kunden des Runs)
coverage = load_coverage_cache(grouped.keys())
log(f"License-Coverage: {sum(len(v) for v in coverage._entries.values())} Einträge "
f"für {len({k[0] for k in coverage._entries})} Kunden geladen")
document_count = 0
line_outcomes: list[dict] = []
errors: list[str] = []
builder = DocumentBuilder(profile)
builder = DocumentBuilder(profile, coverage=coverage)
for customer_ref, group_rows in grouped.items():
customer_billing_mode = _customer_billing_mode(customer_ref, profile.fallback_customer)
effective_mode = determine_effective_output_mode(
@@ -0,0 +1,138 @@
{
"actions": [],
"allow_rename": 0,
"autoname": "naming_series:",
"creation": "2026-04-14 13:00:00.000000",
"doctype": "DocType",
"editable_grid": 0,
"engine": "InnoDB",
"field_order": [
"naming_series",
"customer",
"enabled",
"column_break_head",
"it_contract",
"valid_from",
"valid_to",
"items_section",
"coverage_items",
"notes_section",
"notes"
],
"fields": [
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "LCOV-.#####",
"reqd": 1
},
{
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Customer",
"options": "Customer",
"reqd": 1
},
{
"fieldname": "enabled",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Enabled",
"default": "1"
},
{
"fieldname": "column_break_head",
"fieldtype": "Column Break"
},
{
"fieldname": "it_contract",
"fieldtype": "Link",
"label": "IT Contract",
"options": "IT Contract",
"in_standard_filter": 1,
"description": "Optional: verknüpfter Vertrag. Rein dokumentarisch — Ablauf des Vertrags beendet die Coverage nicht automatisch, dafür valid_to nutzen."
},
{
"fieldname": "valid_from",
"fieldtype": "Date",
"label": "Valid From",
"description": "Optional. Zeilen werden ignoriert, wenn das Abrechnungsperioden-Start-Datum vor diesem Datum liegt."
},
{
"fieldname": "valid_to",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Valid To",
"description": "Optional. Zeilen werden ignoriert, wenn das Abrechnungsperioden-Start-Datum nach diesem Datum liegt."
},
{
"fieldname": "items_section",
"fieldtype": "Section Break",
"label": "Abgedeckte Lizenzen"
},
{
"fieldname": "coverage_items",
"fieldtype": "Table",
"label": "Coverage Items",
"options": "License Coverage Item",
"reqd": 1
},
{
"fieldname": "notes_section",
"fieldtype": "Section Break",
"label": "Notizen",
"collapsible": 1
},
{
"fieldname": "notes",
"fieldtype": "Text Editor",
"label": "Notes"
}
],
"index_web_pages_for_search": 0,
"links": [
{
"group": "Abrechnung",
"link_doctype": "Supply Subscription",
"link_fieldname": "customer",
"parent_doctype": null
}
],
"modified": "2026-04-14 13:00:00.000000",
"modified_by": "Administrator",
"module": "MSP",
"name": "License Coverage",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
@@ -0,0 +1,27 @@
import frappe
from frappe import _
from frappe.model.document import Document
class LicenseCoverage(Document):
def validate(self):
self._validate_period()
self._warn_on_duplicate_items()
def _validate_period(self):
if self.valid_from and self.valid_to and self.valid_from > self.valid_to:
frappe.throw(_("Valid From darf nicht nach Valid To liegen."))
def _warn_on_duplicate_items(self):
seen: set[str] = set()
for row in self.coverage_items or []:
if row.item in seen:
# Mehrere Zeilen für dasselbe Item innerhalb eines Records sind erlaubt
# (z. B. unterschiedliche Notizen), werden beim Lookup summiert — aber als
# Hinweis ausgeben, damit der Anwender das bewusst tut.
frappe.msgprint(
_("Item {0} kommt mehrfach in diesem Coverage-Record vor — Mengen werden beim Import summiert.").format(row.item),
alert=True,
)
return
seen.add(row.item)
@@ -0,0 +1,67 @@
{
"actions": [],
"allow_rename": 0,
"creation": "2026-04-14 13:00:00.000000",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"istable": 1,
"field_order": [
"item",
"item_name",
"vendor_product_id",
"qty",
"note"
],
"fields": [
{
"fieldname": "item",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item",
"options": "Item",
"reqd": 1
},
{
"fetch_from": "item.item_name",
"fieldname": "item_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Item Name",
"read_only": 1
},
{
"fetch_from": "item.hersteller_artikel_nummer",
"fieldname": "vendor_product_id",
"fieldtype": "Data",
"label": "Vendor Product ID",
"read_only": 1,
"description": "Hersteller-Artikel-Nummer (z. B. Microsoft Commercial Product ID)"
},
{
"fieldname": "qty",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Included Qty",
"reqd": 1,
"non_negative": 1,
"description": "Anzahl Lizenzen, die durch die Coverage ohne Einzelberechnung abgedeckt sind"
},
{
"fieldname": "note",
"fieldtype": "Small Text",
"label": "Note"
}
],
"index_web_pages_for_search": 0,
"modified": "2026-04-14 13:00:00.000000",
"modified_by": "Administrator",
"module": "MSP",
"name": "License Coverage Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "idx",
"sort_order": "ASC",
"states": [],
"track_changes": 0
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class LicenseCoverageItem(Document):
pass
+1
View File
@@ -1 +1,2 @@
msp.patches.migrate_rmm_settings
msp.patches.migrate_adn_included_services_to_license_coverage
@@ -0,0 +1,64 @@
"""Migriert bestehende `ADN Included Services` + Items in den lieferantenneutralen
`License Coverage` / `License Coverage Item`.
Idempotent — bereits migrierte Records werden übersprungen (Markierung per Notes-Feld).
"""
from __future__ import annotations
import frappe
MIGRATION_MARKER = "[migrated from ADN Included Services]"
def execute():
if not frappe.db.exists("DocType", "ADN Included Services"):
# adnconnect nicht installiert — nichts zu tun
return
sources = frappe.get_all(
"ADN Included Services",
fields=["name", "customer", "inc_serv"],
)
migrated = 0
for src in sources:
existing = frappe.db.get_all(
"License Coverage",
filters={"customer": src["customer"]},
fields=["name", "notes"],
)
if any(MIGRATION_MARKER in (e.get("notes") or "") for e in existing):
continue
items = frappe.get_all(
"ADN Included Services Item",
filters={"parent": src["name"]},
fields=["item_code", "qty", "description"],
order_by="idx",
)
if not items:
continue
cov = frappe.new_doc("License Coverage")
cov.customer = src["customer"]
cov.enabled = 1
cov.notes = _compose_notes(src.get("inc_serv"))
for it in items:
cov.append("coverage_items", {
"item": it["item_code"],
"qty": int(it["qty"] or 0),
"note": (it.get("description") or "").strip() or None,
})
cov.insert(ignore_permissions=True)
migrated += 1
if migrated:
frappe.db.commit()
print(f"License Coverage: {migrated} Records aus ADN Included Services übernommen.")
def _compose_notes(source_inc_serv: str | None) -> str:
lines = [f"<p><em>{MIGRATION_MARKER}</em></p>"]
if source_inc_serv:
lines.append(source_inc_serv.strip())
return "\n".join(lines)