mirror of
https://github.com/itsdave-de/msp.git
synced 2026-09-28 04:02:42 -03:00
feat(msp): FileHandler-Abstraktion + Dropzone-Page mit Format-Auto-Detection
Fundament für den geplanten Multi-Format-Import-Assistenten. Die bestehende Parser/Builder-Infrastruktur bleibt unverändert — die neue Handler-Schicht ist eine dünne Hülle darüber, die zusätzlich ZIP-Entpacken und Sniff-basierte Auto-Erkennung erledigt. Neue Bausteine: - msp.importers.handlers.base.BaseFileHandler mit sniff() / preview() / execute() und Dataclasses PreviewResult / ExecuteResult. - msp.importers.handlers.registry mit detect_best_handler() (Scoring aller registrierten Handler gegen Dateiinhalt + Name, Cutoff bei confidence >= 0.5). - msp.importers.zip_unpack: entpackt ZIPs mit genau einer CSV in ein Temp- Verzeichnis (wie ADN sie monatlich liefert). - msp.importers.handlers.adn_monthly_csv_handler: erster Handler, wrappt den bestehenden ADN-Parser und Orchestrator. Sniff erkennt ADN-Header mit 100% Confidence, ZIP-Dateien mit ADN-Namensmuster mit 85% Confidence. HTTP-Endpoints für die Dropzone-UI (msp.importers.assistant): - list_handlers(): Metadaten aller Handler — für manuelle Format-Auswahl in der UI, wenn Auto-Erkennung fehlschlägt. - analyze_file(file_url): Detection + DB-freie Vorschau — liefert line_count, customer_count, product_count, period_label, format_drift, credit_note_lines, suggested_profile. - process_file(file_url, handler_key, config): führt den Import aus, gibt Zusammenfassung + Supplier-Import-Run-Name zurück. Frappe-Page „Lieferanten-Import" (/app/import-dropzone): - Drag-&-Drop-Zone für CSV und ZIP, Multi-Upload. - Pro Datei eine reaktive Karte mit Lifecycle (uploading → analyzing → matched/unmatched/error), die ihre Backend-Calls selbst steuert (XHR-Upload mit Progress-Bar, anschließend msp.importers.assistant.analyze_file). - Bei Auto-Erkennung: Stats + „Assistent öffnen"-Button. Routet aktuell noch auf die bestehende Supplier-Import-Run-Form mit vorbelegten Parametern — wird in Phase 4 durch einen eigenen Wizard abgelöst. - Bei Unmatched: Dropdown mit allen verfügbaren Handlern zur manuellen Zuordnung. End-to-End im Browser validiert: CSV-Upload → Karte mit 100%-Confidence- Erkennung → 116 Zeilen, 57 Kunden, 11 Produkte, Zeitraum 02.2026–02.2027. ZIP-Upload funktioniert analog (getestet via Smoke-Skript).
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""HTTP-Endpoints für die Import-Dropzone-Page.
|
||||
|
||||
Drei whitelisted Methoden:
|
||||
- analyze_file(file_url): Detect handler + DB-freie Vorschau
|
||||
- process_file(file_url, handler_key, config): Führt den Import aus
|
||||
- list_handlers(): Metadaten aller registrierten Handler (für manuelle Auswahl)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
import frappe
|
||||
|
||||
from msp.importers.handlers.registry import all_handlers, detect_best_handler, get as get_handler
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def list_handlers() -> list[dict]:
|
||||
"""Metadaten aller registrierten Handler — für manuelle Format-Zuordnung in der UI."""
|
||||
out = []
|
||||
for cls in all_handlers():
|
||||
out.append({
|
||||
"handler_key": cls.handler_key,
|
||||
"display_name": cls.display_name,
|
||||
"accepted_extensions": cls.accepted_extensions,
|
||||
"wizard_route": cls.wizard_route,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def analyze_file(file_url: str) -> dict:
|
||||
"""Erkennt das Format einer hochgeladenen Datei und liefert eine DB-freie Vorschau.
|
||||
|
||||
``file_url`` ist die `/private/files/…`- oder `/files/…`-URL des File-Doctypes.
|
||||
"""
|
||||
path = _resolve_file_url(file_url)
|
||||
if not os.path.exists(path):
|
||||
frappe.throw(f"Datei nicht gefunden: {file_url}")
|
||||
|
||||
with open(path, "rb") as f:
|
||||
sample = f.read(8192)
|
||||
filename = os.path.basename(path)
|
||||
|
||||
best_cls, confidence, scores = detect_best_handler(sample, filename)
|
||||
result = {
|
||||
"file_url": file_url,
|
||||
"filename": filename,
|
||||
"size": os.path.getsize(path),
|
||||
"confidence": confidence,
|
||||
"scores": scores,
|
||||
"handler_key": None,
|
||||
"display_name": None,
|
||||
"wizard_route": None,
|
||||
"preview": None,
|
||||
"error": None,
|
||||
}
|
||||
if best_cls is None:
|
||||
result["error"] = "Kein passender Handler — Format nicht erkannt."
|
||||
return result
|
||||
|
||||
result["handler_key"] = best_cls.handler_key
|
||||
result["display_name"] = best_cls.display_name
|
||||
result["wizard_route"] = best_cls.wizard_route
|
||||
|
||||
try:
|
||||
preview = best_cls.preview(path)
|
||||
except Exception as e:
|
||||
result["error"] = f"Vorschau fehlgeschlagen: {e}"
|
||||
result["preview_traceback"] = traceback.format_exc(limit=5)
|
||||
return result
|
||||
|
||||
result["preview"] = {
|
||||
"line_count": preview.line_count,
|
||||
"customer_count": preview.customer_count,
|
||||
"product_count": preview.product_count,
|
||||
"period_label": preview.period_label,
|
||||
"format_drift": preview.format_drift,
|
||||
"credit_note_lines": preview.credit_note_lines,
|
||||
"warnings": preview.warnings,
|
||||
"suggested_profile": preview.supplier_profile_suggested,
|
||||
"extra": preview.extra,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def process_file(file_url: str, handler_key: str,
|
||||
config: str | dict | None = None) -> dict:
|
||||
"""Führt den Import mit dem gewählten Handler durch.
|
||||
|
||||
``config`` kann als JSON-String (vom Browser) oder als dict übergeben werden.
|
||||
Typische Felder: profile, output_mode, acknowledge_format_drift.
|
||||
"""
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
config = json.loads(config or "{}")
|
||||
except Exception:
|
||||
frappe.throw("Ungültige config — muss valides JSON sein.")
|
||||
config = config or {}
|
||||
|
||||
handler_cls = get_handler(handler_key)
|
||||
path = _resolve_file_url(file_url)
|
||||
if not os.path.exists(path):
|
||||
frappe.throw(f"Datei nicht gefunden: {file_url}")
|
||||
|
||||
result = handler_cls.execute(path, config=config)
|
||||
return {
|
||||
"status": result.status,
|
||||
"run_name": result.run_name,
|
||||
"document_count": result.document_count,
|
||||
"line_count": result.line_count,
|
||||
"error_count": result.error_count,
|
||||
"skipped_count": result.skipped_count,
|
||||
"message": result.message,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_file_url(file_url: str) -> str:
|
||||
"""Wandelt `/private/files/...` oder `/files/...` in einen absoluten Dateipfad."""
|
||||
if not file_url:
|
||||
frappe.throw("Keine Datei-URL übergeben.")
|
||||
if os.path.isabs(file_url) and os.path.exists(file_url):
|
||||
return file_url
|
||||
site_path = os.path.abspath(frappe.get_site_path())
|
||||
if file_url.startswith("/private/files/"):
|
||||
return os.path.join(site_path, "private", "files", file_url.split("/private/files/", 1)[1])
|
||||
if file_url.startswith("/files/"):
|
||||
return os.path.join(site_path, "public", "files", file_url.split("/files/", 1)[1])
|
||||
frappe.throw(f"Unbekanntes Dateipfad-Format: {file_url}")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Dev-Helper: End-to-End-Check für einen Handler (sniff + preview)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from msp.importers.handlers.registry import detect_best_handler
|
||||
|
||||
|
||||
def run(path: str) -> str:
|
||||
with open(path, "rb") as f:
|
||||
sample = f.read(8192)
|
||||
filename = path.rsplit("/", 1)[-1]
|
||||
best, conf, scores = detect_best_handler(sample, filename)
|
||||
out = {"filename": filename, "best": None, "confidence": conf, "scores": scores}
|
||||
if best:
|
||||
preview = best.preview(path)
|
||||
out["best"] = {
|
||||
"handler_key": best.handler_key,
|
||||
"display_name": best.display_name,
|
||||
"line_count": preview.line_count,
|
||||
"customer_count": preview.customer_count,
|
||||
"product_count": preview.product_count,
|
||||
"period_label": preview.period_label,
|
||||
"format_drift": preview.format_drift,
|
||||
"credit_note_lines": preview.credit_note_lines,
|
||||
"suggested_profile": preview.supplier_profile_suggested,
|
||||
"warnings": preview.warnings,
|
||||
}
|
||||
return json.dumps(out, default=str, indent=2, ensure_ascii=False)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Handler für ADNs monatliche Rechnungs-CSV (und die ZIP-verpackte Variante).
|
||||
|
||||
Wrap-Schicht über dem bestehenden Parser :class:`msp.importers.adn_monthly_csv.ADNMonthlyCSVParser`
|
||||
und dem Orchestrator. Neue Funktionalität: ZIP-Entpacken, Sniff-basierte Auto-Erkennung,
|
||||
DB-freie Vorschau.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import Iterable
|
||||
|
||||
import frappe
|
||||
|
||||
from msp.importers.adn_monthly_csv import EXPECTED_HEADER, ADNMonthlyCSVParser
|
||||
from msp.importers.base import CanonicalRow
|
||||
from msp.importers.handlers.base import (
|
||||
BaseFileHandler,
|
||||
ExecuteResult,
|
||||
PreviewResult,
|
||||
ProgressCallback,
|
||||
)
|
||||
from msp.importers.handlers.registry import register
|
||||
from msp.importers.zip_unpack import is_zip_path, unpack_single_csv
|
||||
|
||||
|
||||
@register
|
||||
class ADNMonthlyCSVHandler(BaseFileHandler):
|
||||
handler_key = "adn_monthly_csv_v1"
|
||||
display_name = "ADN Monthly CSV (Microsoft-Rechnungen)"
|
||||
accepted_extensions = [".csv", ".zip"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sniff
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_SIGNATURE_COLUMNS = {
|
||||
"RECHNUNG", "DATUM", "ENDKUNDE", "HERSTELLERNUMMER", "POSITIONSPREIS",
|
||||
"ENDKUNDE_REFERENCE", "SUBSCRIPTION_ID_EXTERNAL",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def sniff(cls, sample_bytes: bytes, filename: str) -> float:
|
||||
name = (filename or "").lower()
|
||||
|
||||
# ZIP-Dateien können wir nur nach Extension & Namensheuristik erkennen —
|
||||
# der tatsächliche Header kommt erst nach dem Entpacken. Hohe Confidence
|
||||
# bei typischen ADN-Dateinamen, moderate sonst.
|
||||
if name.endswith(".zip"):
|
||||
if "rechnungen" in name or "433148" in name:
|
||||
return 0.85
|
||||
return 0.3 # irgendein ZIP — lieber unbestimmt
|
||||
|
||||
if not name.endswith(".csv"):
|
||||
return 0.0
|
||||
|
||||
# CSV-Header inspizieren: erste Zeile (bis 8 KiB reicht)
|
||||
try:
|
||||
head = sample_bytes.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
first_line = head.split("\n", 1)[0].lstrip("\ufeff").strip()
|
||||
cols = {c.strip().upper() for c in first_line.split(";")}
|
||||
matching = cls._SIGNATURE_COLUMNS & cols
|
||||
if not matching:
|
||||
return 0.0
|
||||
ratio = len(matching) / len(cls._SIGNATURE_COLUMNS)
|
||||
# ratio=1.0 ⇒ alle Signatur-Spalten drin, perfekter Match
|
||||
return min(1.0, 0.4 + 0.6 * ratio)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Preview
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def preview(cls, file_path: str) -> PreviewResult:
|
||||
csv_path, temp_dir = cls._resolve_csv_path(file_path)
|
||||
try:
|
||||
parser = ADNMonthlyCSVParser()
|
||||
result = parser.parse(csv_path)
|
||||
|
||||
customers: set[str] = set()
|
||||
products: set[str] = set()
|
||||
period_starts: list = []
|
||||
period_ends: list = []
|
||||
credit_lines = 0
|
||||
for r in result.rows:
|
||||
if r.customer_external_ref:
|
||||
customers.add(r.customer_external_ref)
|
||||
if r.vendor_product_id:
|
||||
products.add(r.vendor_product_id)
|
||||
if r.period_start:
|
||||
period_starts.append(r.period_start)
|
||||
if r.period_end:
|
||||
period_ends.append(r.period_end)
|
||||
if (r.qty or 0) < 0 or (r.amount or 0) < 0 or r.document_type == "Gutschrift":
|
||||
credit_lines += 1
|
||||
|
||||
period_label = cls._format_period_label(period_starts, period_ends)
|
||||
|
||||
# Passendes Supplier Import Profile vorschlagen: aktiviertes Profil mit
|
||||
# unserem parser_key (wenn vorhanden)
|
||||
suggested_profile = frappe.db.get_value(
|
||||
"Supplier Import Profile",
|
||||
{"parser_key": cls.handler_key, "enabled": 1},
|
||||
"name",
|
||||
)
|
||||
|
||||
return PreviewResult(
|
||||
supplier_profile_suggested=suggested_profile,
|
||||
line_count=len(result.rows),
|
||||
customer_count=len(customers),
|
||||
product_count=len(products),
|
||||
period_label=period_label,
|
||||
format_drift=result.format_drift,
|
||||
credit_note_lines=credit_lines,
|
||||
warnings=list(result.warnings),
|
||||
extra={"invoice_date": str(result.invoice_date) if result.invoice_date else None},
|
||||
)
|
||||
finally:
|
||||
if temp_dir:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Execute
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def execute(cls, file_path: str, *, config: dict,
|
||||
progress: ProgressCallback | None = None) -> ExecuteResult:
|
||||
csv_path, temp_dir = cls._resolve_csv_path(file_path)
|
||||
created_run_name: str | None = None
|
||||
try:
|
||||
# Pfad zur Website-internen „private files"-URL normalisieren —
|
||||
# der Supplier Import Run-Orchestrator erwartet das.
|
||||
site_file_url = cls._ensure_site_file_url(csv_path)
|
||||
|
||||
profile_name = config.get("profile") or cls._default_profile_name()
|
||||
if not profile_name:
|
||||
return ExecuteResult(status="Failed", message="Kein Profil gefunden/konfiguriert.")
|
||||
|
||||
run_doc = frappe.get_doc({
|
||||
"doctype": "Supplier Import Run",
|
||||
"profile": profile_name,
|
||||
"file": site_file_url,
|
||||
"output_mode": config.get("output_mode") or None,
|
||||
"acknowledge_format_drift": 1 if config.get("acknowledge_format_drift") else 0,
|
||||
})
|
||||
run_doc.insert(ignore_permissions=True)
|
||||
created_run_name = run_doc.name
|
||||
frappe.db.commit()
|
||||
|
||||
if progress:
|
||||
progress({"phase": "starting", "run": created_run_name})
|
||||
|
||||
from msp.importers.run_orchestrator import execute_run
|
||||
summary = execute_run(run_doc)
|
||||
|
||||
return ExecuteResult(
|
||||
status=summary.get("status", "Failed"),
|
||||
run_name=created_run_name,
|
||||
document_count=summary.get("document_count", 0),
|
||||
line_count=summary.get("line_count", 0),
|
||||
error_count=summary.get("error_count", 0),
|
||||
skipped_count=summary.get("skipped_count", 0),
|
||||
)
|
||||
finally:
|
||||
if temp_dir:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def _resolve_csv_path(cls, file_path: str) -> tuple[str, str | None]:
|
||||
"""Wenn ``file_path`` ein ZIP ist, entpacken und Pfad der CSV zurückgeben.
|
||||
Rückgabe: (csv_path, temp_dir_to_clean_up_or_None)."""
|
||||
if is_zip_path(file_path):
|
||||
temp_dir, csv_path = unpack_single_csv(file_path)
|
||||
return csv_path, temp_dir
|
||||
return file_path, None
|
||||
|
||||
@staticmethod
|
||||
def _format_period_label(starts: Iterable, ends: Iterable) -> str | None:
|
||||
starts = [d for d in starts if d]
|
||||
ends = [d for d in ends if d]
|
||||
if not (starts and ends):
|
||||
return None
|
||||
min_start = min(starts)
|
||||
max_end = max(ends)
|
||||
if min_start.strftime("%Y-%m") == max_end.strftime("%Y-%m"):
|
||||
return min_start.strftime("%m.%Y")
|
||||
return f"{min_start.strftime('%m.%Y')} – {max_end.strftime('%m.%Y')}"
|
||||
|
||||
@staticmethod
|
||||
def _default_profile_name() -> str | None:
|
||||
return frappe.db.get_value(
|
||||
"Supplier Import Profile",
|
||||
{"parser_key": "adn_monthly_csv_v1", "enabled": 1},
|
||||
"name",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_site_file_url(csv_path: str) -> str:
|
||||
"""Gibt den /private/files/...-Pfad zurück, den der Orchestrator erwartet.
|
||||
|
||||
Wenn die CSV außerhalb des private-files-Verzeichnisses liegt (z. B. nach
|
||||
ZIP-Entpacken in /tmp), wird eine Kopie nach private/files/ angelegt und
|
||||
dieser Pfad zurückgegeben, damit das Supplier Import Run-File-Feld
|
||||
auflösbar ist.
|
||||
"""
|
||||
import shutil as _shutil
|
||||
|
||||
site_path = os.path.abspath(frappe.get_site_path())
|
||||
private_files = os.path.join(site_path, "private", "files")
|
||||
csv_abs = os.path.abspath(csv_path)
|
||||
if csv_abs.startswith(private_files + os.sep):
|
||||
relative = csv_abs[len(private_files) + 1:]
|
||||
return f"/private/files/{relative}"
|
||||
|
||||
# Kopie nach private/files/ mit Original-Dateinamen
|
||||
basename = os.path.basename(csv_abs)
|
||||
target = os.path.join(private_files, basename)
|
||||
if not os.path.exists(target):
|
||||
_shutil.copyfile(csv_abs, target)
|
||||
return f"/private/files/{basename}"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""FileHandler-Abstraktion für den Import-Assistenten.
|
||||
|
||||
Ein ``FileHandler`` weiß, ob er eine hochgeladene Datei verarbeiten kann
|
||||
(``sniff``), liefert eine DB-freie Vorschau (``preview``) und kann den
|
||||
eigentlichen Import ausführen (``execute``). Er ist die Einheit, die der
|
||||
Dropzone-/Assistent-UI zugrunde liegt.
|
||||
|
||||
Bestehende Parser (``msp.importers.base.BaseSupplierParser``) bleiben
|
||||
darunter erhalten — ein Handler nutzt sie als innere Mechanik.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
|
||||
ProgressCallback = Callable[[dict], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreviewResult:
|
||||
"""Leichtgewichtige Statistik für die Dropzone-Karte."""
|
||||
|
||||
supplier_profile_suggested: str | None = None # Supplier Import Profile-Name
|
||||
line_count: int = 0
|
||||
customer_count: int = 0
|
||||
product_count: int = 0
|
||||
period_label: str | None = None # z. B. „03.2026" oder „09/2025 – 02/2026"
|
||||
format_drift: bool = False
|
||||
credit_note_lines: int = 0
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecuteResult:
|
||||
"""Zusammenfassung eines fertigen Imports."""
|
||||
|
||||
status: str # Succeeded / Partial / Failed
|
||||
run_name: str | None = None # Supplier Import Run-Name, falls angelegt
|
||||
document_count: int = 0
|
||||
line_count: int = 0
|
||||
error_count: int = 0
|
||||
skipped_count: int = 0
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class BaseFileHandler:
|
||||
"""Basisklasse für alle Datei-Handler.
|
||||
|
||||
Subklassen registrieren sich über :func:`msp.importers.handlers.registry.register`.
|
||||
"""
|
||||
|
||||
#: Eindeutiger Schlüssel, unter dem der Handler in der Registry liegt.
|
||||
handler_key: str = ""
|
||||
|
||||
#: Menschlich lesbarer Name (für UI-Karten).
|
||||
display_name: str = ""
|
||||
|
||||
#: Welche Dateiendungen der Handler prinzipiell akzeptiert (kleingeschrieben, mit Punkt).
|
||||
accepted_extensions: list[str] = []
|
||||
|
||||
#: Optionaler abweichender Wizard-Pfad. ``None`` ⇒ Default-Wizard wird genutzt.
|
||||
wizard_route: str | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Abstrakte Methoden — in Subklassen überschreiben
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def sniff(cls, sample_bytes: bytes, filename: str) -> float:
|
||||
"""Gibt Confidence 0.0–1.0 zurück, dass dieser Handler die Datei verarbeiten kann.
|
||||
|
||||
``sample_bytes`` enthält die ersten ~8 KiB der (ggf. schon entpackten) Datei.
|
||||
``filename`` ist der ursprüngliche Dateiname (hilfreich bei Extension-Matching).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def preview(cls, file_path: str) -> PreviewResult:
|
||||
"""Leichte Analyse ohne DB-Writes. Wird vom Dropzone-Analyze-Endpoint aufgerufen."""
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def execute(cls, file_path: str, *, config: dict,
|
||||
progress: ProgressCallback | None = None) -> ExecuteResult:
|
||||
"""Führt den tatsächlichen Import durch.
|
||||
|
||||
``config`` enthält mindestens: ``profile`` (Supplier Import Profile-Name),
|
||||
``output_mode`` (leer / "Sales Invoice" / "Delivery Note"),
|
||||
``acknowledge_format_drift`` (bool). Weitere Felder je Handler.
|
||||
``progress`` wird (falls gegeben) regelmäßig mit Fortschritts-Dicts aufgerufen,
|
||||
z. B. ``{"phase": "importing", "processed": 47, "total": 113}``.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
__all__ = ["BaseFileHandler", "PreviewResult", "ExecuteResult", "ProgressCallback"]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Registry und Dispatch der FileHandler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Type
|
||||
|
||||
from msp.importers.handlers.base import BaseFileHandler
|
||||
|
||||
|
||||
_HANDLERS: dict[str, Type[BaseFileHandler]] = {}
|
||||
|
||||
|
||||
def register(handler_cls: Type[BaseFileHandler]) -> Type[BaseFileHandler]:
|
||||
key = getattr(handler_cls, "handler_key", "")
|
||||
if not key:
|
||||
raise ValueError(f"{handler_cls.__name__} ohne handler_key kann nicht registriert werden")
|
||||
if key in _HANDLERS:
|
||||
raise ValueError(f"handler_key bereits belegt: {key}")
|
||||
_HANDLERS[key] = handler_cls
|
||||
return handler_cls
|
||||
|
||||
|
||||
def get(handler_key: str) -> Type[BaseFileHandler]:
|
||||
_autoload()
|
||||
if handler_key not in _HANDLERS:
|
||||
raise KeyError(f"Kein Handler mit key='{handler_key}' registriert. "
|
||||
f"Verfügbar: {sorted(_HANDLERS)}")
|
||||
return _HANDLERS[handler_key]
|
||||
|
||||
|
||||
def all_handlers() -> list[Type[BaseFileHandler]]:
|
||||
_autoload()
|
||||
return list(_HANDLERS.values())
|
||||
|
||||
|
||||
def detect_best_handler(sample_bytes: bytes, filename: str,
|
||||
*, min_confidence: float = 0.5,
|
||||
) -> tuple[Type[BaseFileHandler] | None, float, list[dict]]:
|
||||
"""Sniff alle Handler, liefert besten Match + Score + komplette Ranking-Liste.
|
||||
|
||||
Rückgabe:
|
||||
``(best_handler_cls | None, best_confidence, scores)`` — best_handler_cls ist
|
||||
``None``, wenn keiner über ``min_confidence`` liegt.
|
||||
``scores`` ist eine nach Confidence absteigend sortierte Liste
|
||||
``[{"handler_key": ..., "display_name": ..., "confidence": ...}]``.
|
||||
"""
|
||||
_autoload()
|
||||
scores = []
|
||||
for cls in _HANDLERS.values():
|
||||
try:
|
||||
c = float(cls.sniff(sample_bytes, filename) or 0)
|
||||
except Exception:
|
||||
c = 0.0
|
||||
scores.append({
|
||||
"handler_key": cls.handler_key,
|
||||
"display_name": cls.display_name,
|
||||
"confidence": c,
|
||||
})
|
||||
scores.sort(key=lambda x: x["confidence"], reverse=True)
|
||||
top = scores[0] if scores else None
|
||||
best_cls = _HANDLERS.get(top["handler_key"]) if top and top["confidence"] >= min_confidence else None
|
||||
best_conf = top["confidence"] if top else 0.0
|
||||
return best_cls, best_conf, scores
|
||||
|
||||
|
||||
def _autoload() -> None:
|
||||
"""Importiert bekannte Handler-Module, damit @register-Dekoratoren laufen."""
|
||||
from msp.importers.handlers import adn_monthly_csv_handler # noqa: F401
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Helper zum Entpacken von ZIPs, die genau eine CSV enthalten.
|
||||
|
||||
ADN liefert die Monatsrechnungen gezippt (eine CSV pro ZIP). Dieser Helper
|
||||
extrahiert die CSV in ein Temp-Verzeichnis und gibt den Pfad zurück. Aufrufer
|
||||
sind dafür verantwortlich, das Temp-Verzeichnis nach Verarbeitung wieder
|
||||
aufzuräumen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ZipUnpackError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def is_zip_path(path: str) -> bool:
|
||||
return path.lower().endswith(".zip")
|
||||
|
||||
|
||||
def unpack_single_csv(zip_path: str) -> tuple[str, str]:
|
||||
"""Entpackt ein ZIP in ein Temp-Verzeichnis und erwartet genau eine CSV drin.
|
||||
|
||||
Rückgabe: ``(temp_dir_path, csv_file_path)``. Aufrufer muss ``temp_dir_path``
|
||||
aufräumen (z. B. ``shutil.rmtree(temp_dir_path)``).
|
||||
"""
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
raise ZipUnpackError(f"Keine gültige ZIP-Datei: {zip_path}")
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as z:
|
||||
csv_members = [m for m in z.namelist()
|
||||
if m.lower().endswith(".csv") and not m.endswith("/")]
|
||||
if len(csv_members) == 0:
|
||||
raise ZipUnpackError("ZIP enthält keine CSV-Datei.")
|
||||
if len(csv_members) > 1:
|
||||
raise ZipUnpackError(
|
||||
f"ZIP enthält {len(csv_members)} CSV-Dateien; genau eine wird erwartet. "
|
||||
f"Gefunden: {csv_members[:3]}{' …' if len(csv_members) > 3 else ''}"
|
||||
)
|
||||
member = csv_members[0]
|
||||
temp_dir = tempfile.mkdtemp(prefix="msp_import_unzip_")
|
||||
z.extract(member, temp_dir)
|
||||
# Normalisieren: Mitglieder-Pfad kann Unterverzeichnisse enthalten
|
||||
extracted = os.path.join(temp_dir, member)
|
||||
# Falls ZIP ein führendes Verzeichnis hatte, in den temp_dir-root mappen
|
||||
final_path = os.path.join(temp_dir, Path(member).name)
|
||||
if extracted != final_path:
|
||||
os.replace(extracted, final_path)
|
||||
return temp_dir, final_path
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Frappe-Page „Lieferanten-Import" — Drag-&-Drop-Dropzone für CSV/ZIP mit
|
||||
* automatischer Format-Erkennung.
|
||||
*
|
||||
* Pro Datei: Upload → Analyse (msp.importers.assistant.analyze_file) →
|
||||
* Karte mit Statistik und „Assistent öffnen"-Button. Unbekannte Formate
|
||||
* bieten manuelle Handler-Auswahl an.
|
||||
*/
|
||||
|
||||
frappe.pages["import-dropzone"].on_page_load = function (wrapper) {
|
||||
const page = frappe.ui.make_app_page({
|
||||
parent: wrapper,
|
||||
title: __("Lieferanten-Import"),
|
||||
single_column: true,
|
||||
});
|
||||
|
||||
// Frappe übergibt ``page.main`` als jQuery-Wrapper — wir brauchen das native Element.
|
||||
const root = page.main[0] || page.main;
|
||||
root.innerHTML = `
|
||||
<div class="msp-dropzone-page">
|
||||
<div class="msp-dropzone" tabindex="0">
|
||||
<div class="msp-dropzone__icon">⬆</div>
|
||||
<div class="msp-dropzone__label">${__("Dateien per Drag & Drop ablegen oder")}
|
||||
<button class="btn btn-primary btn-sm msp-dropzone__browse">
|
||||
${__("Datei auswählen")}
|
||||
</button>
|
||||
</div>
|
||||
<div class="msp-dropzone__hint">
|
||||
${__("Unterstützt: CSV · ZIP (mit einer CSV)")}
|
||||
</div>
|
||||
<input type="file" class="msp-dropzone__input" multiple hidden
|
||||
accept=".csv,.zip,application/zip,application/x-zip-compressed,text/csv" />
|
||||
</div>
|
||||
<div class="msp-dropzone-cards"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Styles inline — spart einen zusätzlichen Bundle-Step.
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.msp-dropzone-page { padding: 10px; }
|
||||
.msp-dropzone {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
transition: background-color .15s, border-color .15s;
|
||||
cursor: pointer;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
.msp-dropzone.is-dragover {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-light-gray);
|
||||
}
|
||||
.msp-dropzone__icon { font-size: 42px; opacity: .6; margin-bottom: 8px; }
|
||||
.msp-dropzone__label { font-size: 15px; margin-bottom: 6px; }
|
||||
.msp-dropzone__hint { color: var(--text-muted); font-size: 12px; }
|
||||
.msp-dropzone__browse { margin-left: 6px; }
|
||||
.msp-dropzone-cards {
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.msp-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 14px 16px;
|
||||
background: var(--card-bg, var(--fg-color));
|
||||
display: flex; gap: 16px; align-items: flex-start;
|
||||
}
|
||||
.msp-card__icon {
|
||||
width: 40px; height: 40px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; flex: 0 0 40px;
|
||||
background: var(--bg-light-gray);
|
||||
}
|
||||
.msp-card__body { flex: 1; min-width: 0; }
|
||||
.msp-card__title { font-weight: 600; margin-bottom: 2px; display: flex; gap: 8px; align-items: baseline; }
|
||||
.msp-card__subtitle { color: var(--text-muted); font-size: 12px; }
|
||||
.msp-card__stats { margin-top: 8px; font-size: 13px; color: var(--text-color); }
|
||||
.msp-card__stats .stat { display: inline-block; margin-right: 14px; }
|
||||
.msp-card__stats .stat strong { color: var(--heading-color); }
|
||||
.msp-card__warn { color: var(--orange-500); font-size: 12px; margin-top: 6px; }
|
||||
.msp-card__error { color: var(--red-600); font-size: 12px; margin-top: 6px; white-space: pre-wrap; }
|
||||
.msp-card__actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.msp-card--uploading, .msp-card--analyzing { opacity: .85; }
|
||||
.msp-card--matched { border-left: 3px solid var(--green-500); }
|
||||
.msp-card--unmatched { border-left: 3px solid var(--orange-500); }
|
||||
.msp-card--error { border-left: 3px solid var(--red-500); }
|
||||
`;
|
||||
root.appendChild(style);
|
||||
|
||||
const dz = root.querySelector(".msp-dropzone");
|
||||
const cards = root.querySelector(".msp-dropzone-cards");
|
||||
const fileInput = root.querySelector(".msp-dropzone__input");
|
||||
|
||||
// Drag & Drop
|
||||
["dragenter", "dragover"].forEach((ev) =>
|
||||
dz.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dz.classList.add("is-dragover");
|
||||
})
|
||||
);
|
||||
["dragleave", "drop"].forEach((ev) =>
|
||||
dz.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dz.classList.remove("is-dragover");
|
||||
})
|
||||
);
|
||||
dz.addEventListener("drop", (e) => handle_files(e.dataTransfer.files));
|
||||
dz.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("msp-dropzone__browse") || e.target === dz) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
fileInput.addEventListener("change", (e) => handle_files(e.target.files));
|
||||
|
||||
function handle_files(file_list) {
|
||||
if (!file_list || !file_list.length) return;
|
||||
for (const file of file_list) {
|
||||
process_one_file(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Für manuelle Zuordnung: Liste aller Handler beim Seitenaufruf laden
|
||||
let HANDLERS = [];
|
||||
frappe.call({
|
||||
method: "msp.importers.assistant.list_handlers",
|
||||
callback: (r) => (HANDLERS = r.message || []),
|
||||
});
|
||||
|
||||
async function process_one_file(file) {
|
||||
const card = make_card(file);
|
||||
cards.prepend(card.el);
|
||||
|
||||
try {
|
||||
card.set_state("uploading");
|
||||
const file_url = await upload_file(file, (pct) => card.set_progress(pct));
|
||||
|
||||
card.set_state("analyzing");
|
||||
const analysis = await frappe.call({
|
||||
method: "msp.importers.assistant.analyze_file",
|
||||
args: { file_url },
|
||||
});
|
||||
render_analysis(card, analysis.message, file_url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
card.set_error(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function make_card(file) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "msp-card msp-card--uploading";
|
||||
el.innerHTML = `
|
||||
<div class="msp-card__icon">${icon_for(file.name)}</div>
|
||||
<div class="msp-card__body">
|
||||
<div class="msp-card__title">
|
||||
<span class="name"></span>
|
||||
<span class="msp-card__subtitle"></span>
|
||||
</div>
|
||||
<div class="msp-card__state"><span class="spinner">⏳</span> ${__("Warte …")}</div>
|
||||
<div class="msp-card__stats" hidden></div>
|
||||
<div class="msp-card__warn" hidden></div>
|
||||
<div class="msp-card__error" hidden></div>
|
||||
<div class="msp-card__actions" hidden></div>
|
||||
</div>
|
||||
`;
|
||||
el.querySelector(".name").textContent = file.name;
|
||||
el.querySelector(".msp-card__subtitle").textContent = format_size(file.size);
|
||||
|
||||
return {
|
||||
el,
|
||||
set_state(state, text) {
|
||||
el.classList.remove(
|
||||
"msp-card--uploading", "msp-card--analyzing",
|
||||
"msp-card--matched", "msp-card--unmatched", "msp-card--error"
|
||||
);
|
||||
el.classList.add(`msp-card--${state}`);
|
||||
const state_el = el.querySelector(".msp-card__state");
|
||||
const label = {
|
||||
uploading: __("Lade hoch …"),
|
||||
analyzing: __("Analysiere …"),
|
||||
}[state] || text || "";
|
||||
if (label) state_el.innerHTML = `<span class="spinner">⏳</span> ${label}`;
|
||||
else state_el.innerHTML = "";
|
||||
},
|
||||
set_progress(pct) {
|
||||
el.querySelector(".msp-card__state").innerHTML =
|
||||
`<span class="spinner">⏳</span> ${__("Lade hoch …")} ${Math.round(pct)}%`;
|
||||
},
|
||||
set_error(msg) {
|
||||
el.classList.add("msp-card--error");
|
||||
el.querySelector(".msp-card__state").innerHTML = `❌ ${__("Fehler")}`;
|
||||
const err_el = el.querySelector(".msp-card__error");
|
||||
err_el.hidden = false;
|
||||
err_el.textContent = msg;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function render_analysis(card, result, file_url) {
|
||||
const el = card.el;
|
||||
const state_el = el.querySelector(".msp-card__state");
|
||||
const stats_el = el.querySelector(".msp-card__stats");
|
||||
const warn_el = el.querySelector(".msp-card__warn");
|
||||
const err_el = el.querySelector(".msp-card__error");
|
||||
const actions_el = el.querySelector(".msp-card__actions");
|
||||
|
||||
if (result.error) {
|
||||
card.set_error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.handler_key) {
|
||||
// Format nicht erkannt — manuelle Zuordnung ermöglichen
|
||||
el.classList.remove("msp-card--analyzing");
|
||||
el.classList.add("msp-card--unmatched");
|
||||
state_el.innerHTML = `⚠ ${__("Format nicht erkannt")}`;
|
||||
warn_el.hidden = false;
|
||||
warn_el.textContent = __(
|
||||
"Keiner der verfügbaren Handler konnte die Datei zuverlässig erkennen. Bitte manuell zuordnen:"
|
||||
);
|
||||
actions_el.hidden = false;
|
||||
actions_el.innerHTML = `
|
||||
<select class="form-control form-control-sm" style="max-width: 280px;">
|
||||
<option value="">${__("Handler wählen …")}</option>
|
||||
${HANDLERS.map((h) =>
|
||||
`<option value="${h.handler_key}">${frappe.utils.escape_html(h.display_name)}</option>`
|
||||
).join("")}
|
||||
</select>
|
||||
<button class="btn btn-default btn-sm btn-open-assistant" disabled>
|
||||
${__("Assistent öffnen")}
|
||||
</button>
|
||||
`;
|
||||
const select = actions_el.querySelector("select");
|
||||
const btn = actions_el.querySelector(".btn-open-assistant");
|
||||
select.addEventListener("change", () => {
|
||||
btn.disabled = !select.value;
|
||||
});
|
||||
btn.addEventListener("click", () => open_assistant(file_url, select.value));
|
||||
return;
|
||||
}
|
||||
|
||||
// Erkannt
|
||||
el.classList.remove("msp-card--analyzing");
|
||||
el.classList.add("msp-card--matched");
|
||||
state_el.innerHTML = `✓ ${__("Erkannt")}: <strong>${frappe.utils.escape_html(result.display_name)}</strong>`
|
||||
+ ` <span class="msp-card__subtitle">(${Math.round(result.confidence * 100)}%)</span>`;
|
||||
|
||||
const preview = result.preview || {};
|
||||
const stat_parts = [];
|
||||
if (preview.line_count != null) stat_parts.push(`<span class="stat"><strong>${preview.line_count}</strong> ${__("Zeilen")}</span>`);
|
||||
if (preview.customer_count != null) stat_parts.push(`<span class="stat"><strong>${preview.customer_count}</strong> ${__("Kunden")}</span>`);
|
||||
if (preview.product_count != null) stat_parts.push(`<span class="stat"><strong>${preview.product_count}</strong> ${__("Produkte")}</span>`);
|
||||
if (preview.period_label) stat_parts.push(`<span class="stat">${frappe.utils.escape_html(preview.period_label)}</span>`);
|
||||
if (preview.credit_note_lines) stat_parts.push(`<span class="stat">${preview.credit_note_lines} ${__("Gutschriftzeilen")}</span>`);
|
||||
stats_el.hidden = false;
|
||||
stats_el.innerHTML = stat_parts.join(" ");
|
||||
|
||||
const warnings = preview.warnings || [];
|
||||
if (preview.format_drift) warnings.unshift(__("Format weicht vom erwarteten Layout ab"));
|
||||
if (warnings.length) {
|
||||
warn_el.hidden = false;
|
||||
warn_el.innerHTML = warnings.map((w) => `⚠ ${frappe.utils.escape_html(w)}`).join("<br>");
|
||||
}
|
||||
|
||||
actions_el.hidden = false;
|
||||
actions_el.innerHTML = `
|
||||
<button class="btn btn-primary btn-sm btn-open-assistant">
|
||||
${__("Assistent öffnen")} →
|
||||
</button>
|
||||
`;
|
||||
actions_el
|
||||
.querySelector(".btn-open-assistant")
|
||||
.addEventListener("click", () =>
|
||||
open_assistant(file_url, result.handler_key, {
|
||||
wizard_route: result.wizard_route,
|
||||
format_drift: preview.format_drift,
|
||||
suggested_profile: preview.suggested_profile,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function open_assistant(file_url, handler_key, opts = {}) {
|
||||
// Phase 4: eigenes Assistent-Routing (/import-assistant/<handler>).
|
||||
// Bis das da ist: öffne den Supplier Import Run mit vorbelegtem File + Profil.
|
||||
const route_base = opts.wizard_route || "import-assistant";
|
||||
if (route_base === "import-assistant") {
|
||||
// Aktuell als Sprung auf die bestehende Run-Form mit ?file=…&profile=…
|
||||
const params = new URLSearchParams({ file: file_url });
|
||||
if (opts.suggested_profile) params.set("profile", opts.suggested_profile);
|
||||
if (opts.format_drift) params.set("acknowledge_format_drift", "1");
|
||||
const url = `/app/supplier-import-run/new?${params.toString()}`;
|
||||
window.location.href = url;
|
||||
} else {
|
||||
// Spezial-Wizard-Routen (Future): frappe.set_route(...)
|
||||
frappe.set_route(opts.wizard_route, file_url, handler_key);
|
||||
}
|
||||
}
|
||||
|
||||
async function upload_file(file, on_progress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
form.append("is_private", "1");
|
||||
form.append("folder", "Home");
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/method/upload_file");
|
||||
xhr.setRequestHeader("X-Frappe-CSRF-Token", frappe.csrf_token);
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (on_progress && e.lengthComputable) on_progress((e.loaded / e.total) * 100);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
const file_url = (json.message || {}).file_url;
|
||||
if (!file_url) return reject(new Error("Upload: keine file_url in Antwort"));
|
||||
resolve(file_url);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Upload fehlgeschlagen (HTTP ${xhr.status})`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Upload-Netzwerkfehler"));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
function format_size(bytes) {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / 1024 / 1024).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
function icon_for(filename) {
|
||||
const name = (filename || "").toLowerCase();
|
||||
if (name.endsWith(".zip")) return "🗜️";
|
||||
if (name.endsWith(".csv")) return "📄";
|
||||
if (name.endsWith(".xlsx") || name.endsWith(".xls")) return "📊";
|
||||
return "📎";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"content": null,
|
||||
"creation": "2026-04-14 20:00:00.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Page",
|
||||
"icon": "octicon octicon-cloud-upload",
|
||||
"idx": 0,
|
||||
"modified": "2026-04-14 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "MSP",
|
||||
"name": "import-dropzone",
|
||||
"owner": "Administrator",
|
||||
"page_name": "import-dropzone",
|
||||
"roles": [
|
||||
{
|
||||
"role": "System Manager"
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
}
|
||||
],
|
||||
"script": null,
|
||||
"standard": "Yes",
|
||||
"style": null,
|
||||
"system_page": 0,
|
||||
"title": "Lieferanten-Import"
|
||||
}
|
||||
Reference in New Issue
Block a user