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