diff --git a/CHANGELOG.md b/CHANGELOG.md index 5677ac3..d18c0cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- ERP Enrichment Dry Run für ausgewählte ERP-Felder und bestehenden RollCalc-Artikelbestand ergänzt. - ERP Explorer für CSV-Profiling, Dublettenanalyse und optionalen RollCalc-Abgleich ergänzt. - RollCalc-JSON-Importer mit strikter Struktur- und Typvalidierung ergänzt. - Initiale Projektstruktur angelegt. diff --git a/PROJECT_KNOWLEDGE.md b/PROJECT_KNOWLEDGE.md index b2861d6..3d332eb 100644 --- a/PROJECT_KNOWLEDGE.md +++ b/PROJECT_KNOWLEDGE.md @@ -40,7 +40,7 @@ Produktive ERP-Daten, lokale Quelldaten, generierte Dateien und Reports sind per ## Aktueller Stand -Phase 0: Struktur, Dokumentation, Fixtures, minimale Python-Bausteine, Tests und RollCalc-JSON-Importer. +Phase 0: Struktur, Dokumentation, Fixtures, minimale Python-Bausteine, Tests, RollCalc-JSON-Importer, ERP Explorer und ERP Enrichment Dry Run. ## Offene fachliche Fragen @@ -55,6 +55,7 @@ Siehe `docs/data-model.md` und `docs/merge-rules.md`. ## Wichtige Dateien - `docs/data-dictionary.md` +- `docs/erp-enrichment.md` - `docs/importers.md` - `docs/merge-rules.md` - `tests/fixtures/` diff --git a/README.md b/README.md index e9fdf05..ef690ce 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,14 @@ write_erp_profile_report( Der Textreport wird standardmäßig unter `data/reports/erp_profile_report.txt` erzeugt. Die Quelldaten werden nicht verändert. +## ERP Enrichment Dry Run + +Der ERP Enrichment Dry Run ergänzt vorhandene RollCalc-Artikel um ein separates `erp`-Objekt mit ausgewählten ERP-Produktionsinformationen. Die RollCalc-Datei definiert die relevante Artikelmenge; ERP-Artikel ohne RollCalc-Entsprechung werden ignoriert. + +Übernommen werden nur `ROP_PRODUCT_WIDTH`, `ROP_RATE_OF_PRODUCTION`, `SL_MINIMUM_PRODUCTION_QUANTITY` und `WPL_WORKPLACE_TEXT`. QC-Daten wie `area_weight` werden nicht aus dem ERP übernommen. `SL_PRODUCTION_SPEED` wird wegen artikelabhängiger Einheit nicht verwendet. + +Details stehen in `docs/erp-enrichment.md`. + ## Tests ```bash diff --git a/docs/erp-enrichment.md b/docs/erp-enrichment.md new file mode 100644 index 0000000..6f63504 --- /dev/null +++ b/docs/erp-enrichment.md @@ -0,0 +1,94 @@ +# ERP Enrichment Dry Run + +Der ERP Enrichment Dry Run reichert ausschließlich vorhandene RollCalc-Artikel mit ausgewählten ERP-Feldern an. Die RollCalc-Datei definiert die relevante Artikelmenge und Reihenfolge. ERP-Artikel ohne RollCalc-Entsprechung werden ignoriert. + +Es handelt sich nicht um einen vollständigen ERP-Import, keinen Merge-Prozess und keinen produktiven RollCalc-Export. + +## Öffentliche API + +```python +from pathlib import Path + +from article_data_manager.enrichment.erp import ( + enrich_rollcalc_articles_from_erp, + write_enrichment_outputs, +) + +result = enrich_rollcalc_articles_from_erp( + Path("data/source/rollcalc/article-data.json"), + Path("data/source/erp/production-key-data.csv"), +) +write_enrichment_outputs( + result, + Path("data/generated/article-data.erp-enriched-dry-run.json"), + Path("data/reports/erp-enrichment-report.csv"), +) +``` + +## Übernommene ERP-Felder + +| ERP-Feld | Zielfeld | Bedeutung | Einheit | +| --- | --- | --- | --- | +| `ROP_PRODUCT_WIDTH` | `product_width_m` | ERP-Produktbreite | m | +| `ROP_RATE_OF_PRODUCTION` | `line_speed_m_min` | Liniengeschwindigkeit | m/min | +| `SL_MINIMUM_PRODUCTION_QUANTITY` | `minimum_production_quantity` | Mindestproduktionsmenge | ERP-Originaleinheit | +| `WPL_WORKPLACE_TEXT` | `workplace` | geplanter Arbeitsplatz beziehungsweise geplante Anlage | Text | + +`ROP_RATE_OF_PRODUCTION` ist die für Rollenwechselbetrachtungen maßgebliche Liniengeschwindigkeit in m/min. `SL_PRODUCTION_SPEED` wird nicht verwendet, weil dessen Einheit artikelabhängig sein kann. `kg_qm` wird nicht übernommen, weil `area_weight` aus QC beziehungsweise aus der bestehenden RollCalc-Datenquelle stammt. + +`ROP_PRODUCT_WIDTH` wird als ERP-Produktbreite übernommen, ohne Sonderfälle fachlich zu interpretieren. + +## Internes Dry-Run-Format + +Die vorhandene RollCalc-Struktur bleibt erhalten und wird um ein separates `erp`-Objekt ergänzt: + +```json +{ + "nr": "214700", + "name": "Stex H 751 (Tfix 751), 6,00 x 50 m", + "thickness": 6.722, + "area_weight": 0.0, + "core_type": 0.0, + "erp": { + "product_width_m": 6.0, + "line_speed_m_min": 18.5, + "minimum_production_quantity": 5000.0, + "workplace": "Anlage 3" + } +} +``` + +Das `erp`-Objekt ist auch vorhanden, wenn kein ERP-Treffer existiert. Fehlende, widersprüchliche oder ungültige ERP-Werte erscheinen dort als `null`. + +Vor produktiver Verwendung in RollCalc muss dessen Ladeverhalten für das zusätzliche `erp`-Objekt geprüft werden. Langfristig soll ein eigener RollCalc-Export nur die für RollCalc vorgesehenen Felder ausgeben. ERP-Produktionsinformationen müssen nicht zwangsläufig Bestandteil des RollCalc-Exports werden. + +## Mehrfachtreffer und Status + +ERP-Zeilen werden ausschließlich über `SL_ITEM_NO` der RollCalc-Artikelnummer `nr` zugeordnet. Artikelnummern werden nicht numerisch konvertiert oder umformatiert. + +Mehrfachtreffer werden feldweise ausgewertet: + +- keine ERP-Zeile: `no_erp_match` +- keine gefüllten Werte: `missing` +- genau ein Wert: `unique` +- mehrere Zeilen mit identischem Wert: `same_value_multiple_rows` +- mehrere unterschiedliche Werte: `conflict` +- nicht interpretierbarer numerischer Wert: `invalid` + +Leere Werte zählen nicht als eigener Konfliktwert. Konflikte und ungültige Werte werden berichtet und nicht automatisch aufgelöst. + +Artikelstatus: + +- `complete`: alle vier Felder haben `unique` oder `same_value_multiple_rows` +- `partial`: mindestens ein Feld ist `missing`, aber kein Feld ist `conflict` oder `invalid` +- `conflict`: mindestens ein Feld ist `conflict` oder `invalid` +- `no_erp_match`: keine ERP-Zeile zur RollCalc-Artikelnummer + +## Ausgaben + +Standardpfade: + +- `data/generated/article-data.erp-enriched-dry-run.json` +- `data/reports/erp-enrichment-report.csv` + +Die Quelldateien werden nicht verändert. Produktive Dry-Run-Ergebnisse werden nicht versioniert. diff --git a/docs/importers.md b/docs/importers.md index 48eda5f..69c3350 100644 --- a/docs/importers.md +++ b/docs/importers.md @@ -75,3 +75,7 @@ write_erp_profile_report(Path("data/source/erp/production-key-data.csv")) ``` Der Report wird standardmäßig als `data/reports/erp_profile_report.txt` geschrieben. Der Explorer verändert weder ERP-CSV noch RollCalc-JSON und erzeugt keine `article-data.json`. + +## ERP Enrichment Dry Run + +Der ERP Enrichment Dry Run ist unter `docs/erp-enrichment.md` dokumentiert. Er ist kein vollständiger ERP-Importer, sondern erzeugt ein internes Dry-Run-Artefakt mit separatem `erp`-Objekt und einen CSV-Report. diff --git a/src/article_data_manager/enrichment/__init__.py b/src/article_data_manager/enrichment/__init__.py new file mode 100644 index 0000000..22ac971 --- /dev/null +++ b/src/article_data_manager/enrichment/__init__.py @@ -0,0 +1,2 @@ +"""Read-only enrichment dry runs.""" + diff --git a/src/article_data_manager/enrichment/erp.py b/src/article_data_manager/enrichment/erp.py new file mode 100644 index 0000000..0d95463 --- /dev/null +++ b/src/article_data_manager/enrichment/erp.py @@ -0,0 +1,355 @@ +"""Dry-run enrichment of existing RollCalc articles with selected ERP fields.""" + +from __future__ import annotations + +import csv +import json +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +from article_data_manager.importers.rollcalc_json import load_rollcalc_articles + +ERP_ARTICLE_NUMBER_FIELD = "SL_ITEM_NO" +DEFAULT_JSON_OUTPUT_PATH = Path("data/generated/article-data.erp-enriched-dry-run.json") +DEFAULT_REPORT_OUTPUT_PATH = Path("data/reports/erp-enrichment-report.csv") + +FieldStatus = Literal[ + "unique", + "same_value_multiple_rows", + "missing", + "conflict", + "invalid", + "no_erp_match", +] +ArticleStatus = Literal["complete", "partial", "conflict", "no_erp_match"] + + +@dataclass(frozen=True, slots=True) +class ErpFieldRule: + """Mapping rule for one explicitly approved ERP field.""" + + erp_field: str + output_field: str + numeric: bool + + +ERP_FIELD_RULES: tuple[ErpFieldRule, ...] = ( + ErpFieldRule("ROP_PRODUCT_WIDTH", "product_width_m", True), + ErpFieldRule("ROP_RATE_OF_PRODUCTION", "line_speed_m_min", True), + ErpFieldRule("SL_MINIMUM_PRODUCTION_QUANTITY", "minimum_production_quantity", True), + ErpFieldRule("WPL_WORKPLACE_TEXT", "workplace", False), +) + + +@dataclass(frozen=True, slots=True) +class EnrichedField: + """Resolved dry-run value and status for one ERP target field.""" + + value: float | str | None + status: FieldStatus + + +@dataclass(frozen=True, slots=True) +class EnrichedArticle: + """One RollCalc article with separate ERP dry-run data.""" + + nr: str + name: str + source: dict[str, Any] + erp_match_count: int + fields: dict[str, EnrichedField] + status: ArticleStatus + + def to_output_dict(self) -> dict[str, Any]: + """Return the source article plus the separate `erp` object.""" + + enriched = dict(self.source) + enriched["erp"] = { + rule.output_field: self.fields[rule.output_field].value for rule in ERP_FIELD_RULES + } + return enriched + + +@dataclass(frozen=True, slots=True) +class FieldSummary: + """Status counts for one output field.""" + + field_name: str + unique: int = 0 + same_value_multiple_rows: int = 0 + missing: int = 0 + conflict: int = 0 + invalid: int = 0 + no_erp_match: int = 0 + + +@dataclass(frozen=True, slots=True) +class EnrichmentSummary: + """Compact summary of one enrichment dry run.""" + + rollcalc_article_count: int + with_erp_match_count: int + without_erp_match_count: int + complete_count: int + partial_count: int + conflict_count: int + field_summaries: dict[str, FieldSummary] + + +@dataclass(frozen=True, slots=True) +class EnrichmentResult: + """Complete dry-run enrichment result.""" + + articles: tuple[EnrichedArticle, ...] + summary: EnrichmentSummary + + +@dataclass(frozen=True, slots=True) +class _CollectedValues: + valid_values: list[float | str] = field(default_factory=list) + invalid_values: list[str] = field(default_factory=list) + + +def enrich_rollcalc_articles_from_erp( + rollcalc_path: Path, + erp_csv_path: Path, +) -> EnrichmentResult: + """Enrich existing RollCalc articles with selected ERP fields as a dry run. + + The RollCalc file defines the output article set and order. ERP rows without + matching RollCalc article number are ignored. Source files are read only. + """ + + rollcalc_articles = load_rollcalc_articles(Path(rollcalc_path)) + raw_rollcalc_articles = _load_raw_rollcalc_articles(Path(rollcalc_path)) + erp_rows_by_article_number = _load_erp_rows_by_article_number(Path(erp_csv_path)) + + enriched_articles: list[EnrichedArticle] = [] + for article, source in zip(rollcalc_articles, raw_rollcalc_articles, strict=True): + matching_rows = erp_rows_by_article_number.get(article.nr, []) + fields = { + rule.output_field: _resolve_field(rule, matching_rows) + for rule in ERP_FIELD_RULES + } + enriched_articles.append( + EnrichedArticle( + nr=article.nr, + name=article.name, + source=source, + erp_match_count=len(matching_rows), + fields=fields, + status=_resolve_article_status(len(matching_rows), fields), + ) + ) + + articles_tuple = tuple(enriched_articles) + return EnrichmentResult( + articles=articles_tuple, + summary=_build_summary(articles_tuple), + ) + + +def write_enrichment_outputs( + result: EnrichmentResult, + json_path: Path, + report_path: Path, +) -> None: + """Write deterministic dry-run JSON and CSV report files.""" + + json_destination = Path(json_path) + report_destination = Path(report_path) + json_destination.parent.mkdir(parents=True, exist_ok=True) + report_destination.parent.mkdir(parents=True, exist_ok=True) + + output_articles = [article.to_output_dict() for article in result.articles] + json_destination.write_text( + json.dumps(output_articles, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + _write_report(result, report_destination) + + +def run_enrichment_dry_run( + rollcalc_path: Path, + erp_csv_path: Path, + *, + json_path: Path = DEFAULT_JSON_OUTPUT_PATH, + report_path: Path = DEFAULT_REPORT_OUTPUT_PATH, +) -> EnrichmentResult: + """Run the dry-run enrichment and write the standard output artifacts.""" + + result = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_csv_path) + write_enrichment_outputs(result, json_path, report_path) + return result + + +def _load_raw_rollcalc_articles(path: Path) -> list[dict[str, Any]]: + payload = json.loads(path.read_text(encoding="utf-8")) + return [dict(item) for item in payload] + + +def _load_erp_rows_by_article_number(path: Path) -> dict[str, list[dict[str, str]]]: + rows_by_article_number: dict[str, list[dict[str, str]]] = defaultdict(list) + with path.open("r", encoding="utf-8-sig", newline="") as csv_file: + sample = csv_file.read(4096) + csv_file.seek(0) + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + reader = csv.DictReader(csv_file, dialect=dialect) + for row in reader: + article_number = row.get(ERP_ARTICLE_NUMBER_FIELD, "") + if article_number != "": + rows_by_article_number[article_number].append(row) + return dict(rows_by_article_number) + + +def _resolve_field(rule: ErpFieldRule, matching_rows: list[dict[str, str]]) -> EnrichedField: + if not matching_rows: + return EnrichedField(value=None, status="no_erp_match") + + collected = _collect_values(rule, matching_rows) + if collected.invalid_values: + return EnrichedField(value=None, status="invalid") + if not collected.valid_values: + return EnrichedField(value=None, status="missing") + + unique_values = set(collected.valid_values) + if len(unique_values) > 1: + return EnrichedField(value=None, status="conflict") + + value = collected.valid_values[0] + status: FieldStatus = ( + "same_value_multiple_rows" if len(collected.valid_values) > 1 else "unique" + ) + return EnrichedField(value=value, status=status) + + +def _collect_values(rule: ErpFieldRule, matching_rows: list[dict[str, str]]) -> _CollectedValues: + valid_values: list[float | str] = [] + invalid_values: list[str] = [] + for row in matching_rows: + raw_value = row.get(rule.erp_field, "") + if raw_value == "": + continue + if rule.numeric: + parsed_value = _parse_erp_number(raw_value) + if parsed_value is None: + invalid_values.append(raw_value) + else: + valid_values.append(parsed_value) + else: + valid_values.append(raw_value) + return _CollectedValues(valid_values=valid_values, invalid_values=invalid_values) + + +def _parse_erp_number(value: str) -> float | None: + normalized = value.strip() + if normalized == "": + return None + if normalized.count(",") + normalized.count(".") > 1: + return None + normalized = normalized.replace(",", ".") + try: + return float(normalized) + except ValueError: + return None + + +def _resolve_article_status( + erp_match_count: int, + fields: dict[str, EnrichedField], +) -> ArticleStatus: + if erp_match_count == 0: + return "no_erp_match" + field_statuses = {field.status for field in fields.values()} + if "conflict" in field_statuses or "invalid" in field_statuses: + return "conflict" + if field_statuses <= {"unique", "same_value_multiple_rows"}: + return "complete" + return "partial" + + +def _build_summary(articles: tuple[EnrichedArticle, ...]) -> EnrichmentSummary: + article_status_counts = Counter(article.status for article in articles) + field_summaries = { + rule.output_field: _build_field_summary(rule.output_field, articles) + for rule in ERP_FIELD_RULES + } + return EnrichmentSummary( + rollcalc_article_count=len(articles), + with_erp_match_count=sum(1 for article in articles if article.erp_match_count > 0), + without_erp_match_count=sum(1 for article in articles if article.erp_match_count == 0), + complete_count=article_status_counts["complete"], + partial_count=article_status_counts["partial"], + conflict_count=article_status_counts["conflict"], + field_summaries=field_summaries, + ) + + +def _build_field_summary( + field_name: str, + articles: tuple[EnrichedArticle, ...], +) -> FieldSummary: + counts = Counter(article.fields[field_name].status for article in articles) + return FieldSummary( + field_name=field_name, + unique=counts["unique"], + same_value_multiple_rows=counts["same_value_multiple_rows"], + missing=counts["missing"], + conflict=counts["conflict"], + invalid=counts["invalid"], + no_erp_match=counts["no_erp_match"], + ) + + +def _write_report(result: EnrichmentResult, report_path: Path) -> None: + with report_path.open("w", encoding="utf-8-sig", newline="") as csv_file: + writer = csv.DictWriter(csv_file, fieldnames=_report_fieldnames(), delimiter=";") + writer.writeheader() + for article in result.articles: + writer.writerow(_report_row(article)) + + +def _report_fieldnames() -> list[str]: + return [ + "nr", + "name", + "erp_match_count", + "status", + "product_width_m", + "product_width_status", + "line_speed_m_min", + "line_speed_status", + "minimum_production_quantity", + "minimum_production_quantity_status", + "workplace", + "workplace_status", + ] + + +def _report_row(article: EnrichedArticle) -> dict[str, str | int]: + return { + "nr": article.nr, + "name": article.name, + "erp_match_count": article.erp_match_count, + "status": article.status, + "product_width_m": _format_report_value(article.fields["product_width_m"].value), + "product_width_status": article.fields["product_width_m"].status, + "line_speed_m_min": _format_report_value(article.fields["line_speed_m_min"].value), + "line_speed_status": article.fields["line_speed_m_min"].status, + "minimum_production_quantity": _format_report_value( + article.fields["minimum_production_quantity"].value + ), + "minimum_production_quantity_status": article.fields[ + "minimum_production_quantity" + ].status, + "workplace": _format_report_value(article.fields["workplace"].value), + "workplace_status": article.fields["workplace"].status, + } + + +def _format_report_value(value: float | str | None) -> str: + if value is None: + return "" + return str(value) diff --git a/tests/integration/test_erp_enrichment_dry_run.py b/tests/integration/test_erp_enrichment_dry_run.py new file mode 100644 index 0000000..f6bd6e3 --- /dev/null +++ b/tests/integration/test_erp_enrichment_dry_run.py @@ -0,0 +1,77 @@ +import csv +import json +from pathlib import Path + +from article_data_manager.enrichment.erp import ( + enrich_rollcalc_articles_from_erp, + write_enrichment_outputs, +) + + +def test_enrichment_dry_run_writes_json_and_csv_outputs(tmp_path: Path) -> None: + rollcalc_path = tmp_path / "article-data.json" + erp_path = tmp_path / "erp.csv" + json_path = tmp_path / "generated" / "article-data.erp-enriched-dry-run.json" + report_path = tmp_path / "reports" / "erp-enrichment-report.csv" + + rollcalc_path.write_text( + json.dumps( + [ + { + "nr": "214700", + "name": "RollCalc A", + "thickness": 6.722, + "area_weight": 750.0, + "core_type": 0.0, + }, + { + "nr": "999999", + "name": "RollCalc Only", + "thickness": 1.0, + "area_weight": 0.0, + "core_type": 0.0, + }, + ], + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + erp_path.write_text( + "\n".join( + [ + "SL_ITEM_NO,ROP_PRODUCT_WIDTH,ROP_RATE_OF_PRODUCTION," + "SL_MINIMUM_PRODUCTION_QUANTITY,WPL_WORKPLACE_TEXT,kg_qm,SL_PRODUCTION_SPEED", + "214700,\"6,00\",\"18,5\",5000,Anlage 3,999,999", + "ERPONLY,9.99,1.0,1,Anlage X,1,1", + ] + ), + encoding="utf-8", + ) + + result = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path) + write_enrichment_outputs(result, json_path, report_path) + + output_articles = json.loads(json_path.read_text(encoding="utf-8")) + assert [article["nr"] for article in output_articles] == ["214700", "999999"] + assert output_articles[0]["area_weight"] == 750.0 + assert output_articles[0]["erp"] == { + "product_width_m": 6.0, + "line_speed_m_min": 18.5, + "minimum_production_quantity": 5000.0, + "workplace": "Anlage 3", + } + assert output_articles[1]["erp"] == { + "product_width_m": None, + "line_speed_m_min": None, + "minimum_production_quantity": None, + "workplace": None, + } + + with report_path.open("r", encoding="utf-8-sig", newline="") as report_file: + rows = list(csv.DictReader(report_file, delimiter=";")) + + assert [row["nr"] for row in rows] == ["214700", "999999"] + assert rows[0]["status"] == "complete" + assert rows[1]["status"] == "no_erp_match" diff --git a/tests/unit/enrichment/test_erp_enrichment.py b/tests/unit/enrichment/test_erp_enrichment.py new file mode 100644 index 0000000..4b3c32f --- /dev/null +++ b/tests/unit/enrichment/test_erp_enrichment.py @@ -0,0 +1,387 @@ +import csv +import json +from pathlib import Path + +from article_data_manager.enrichment.erp import ( + enrich_rollcalc_articles_from_erp, + write_enrichment_outputs, +) + + +def write_rollcalc(path: Path, article_numbers: list[str]) -> Path: + payload = [ + { + "nr": nr, + "name": f"RollCalc {nr}", + "thickness": float(index + 1), + "area_weight": 100.0 + index, + "core_type": 0.0, + } + for index, nr in enumerate(article_numbers) + ] + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return path + + +def write_erp(path: Path, rows: list[dict[str, str]]) -> Path: + fieldnames = [ + "SL_ITEM_NO", + "ROP_PRODUCT_WIDTH", + "ROP_RATE_OF_PRODUCTION", + "SL_MINIMUM_PRODUCTION_QUANTITY", + "WPL_WORKPLACE_TEXT", + "SL_PRODUCTION_SPEED", + "kg_qm", + ] + with path.open("w", encoding="utf-8", newline="") as csv_file: + writer = csv.DictWriter(csv_file, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({field: row.get(field, "") for field in fieldnames}) + return path + + +def test_unique_erp_match_enriches_selected_fields(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + { + "SL_ITEM_NO": "214700", + "ROP_PRODUCT_WIDTH": "6,00", + "ROP_RATE_OF_PRODUCTION": "18.5", + "SL_MINIMUM_PRODUCTION_QUANTITY": "5000", + "WPL_WORKPLACE_TEXT": "Anlage 3", + } + ], + ) + + article = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0] + + assert article.status == "complete" + assert article.to_output_dict()["erp"] == { + "product_width_m": 6.0, + "line_speed_m_min": 18.5, + "minimum_production_quantity": 5000.0, + "workplace": "Anlage 3", + } + + +def test_no_erp_match_keeps_rollcalc_article_with_null_erp_object(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["999999"]) + erp_path = write_erp(tmp_path / "erp.csv", [{"SL_ITEM_NO": "214700"}]) + + article = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0] + + assert article.status == "no_erp_match" + assert article.erp_match_count == 0 + assert article.to_output_dict()["erp"] == { + "product_width_m": None, + "line_speed_m_min": None, + "minimum_production_quantity": None, + "workplace": None, + } + assert {field.status for field in article.fields.values()} == {"no_erp_match"} + + +def test_multiple_erp_rows_with_identical_width_are_collapsed(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": "6,00"}, + {"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": "6.00"}, + ], + ) + + field = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0].fields[ + "product_width_m" + ] + + assert field.value == 6.0 + assert field.status == "same_value_multiple_rows" + + +def test_conflicting_width_is_reported_as_null_field(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": "6,00"}, + {"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": "6.10"}, + ], + ) + + article = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0] + + assert article.status == "conflict" + assert article.fields["product_width_m"].value is None + assert article.fields["product_width_m"].status == "conflict" + + +def test_identical_line_speeds_in_multiple_rows_are_collapsed(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "ROP_RATE_OF_PRODUCTION": "18,5"}, + {"SL_ITEM_NO": "214700", "ROP_RATE_OF_PRODUCTION": "18.50"}, + ], + ) + + field = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0].fields[ + "line_speed_m_min" + ] + + assert field.value == 18.5 + assert field.status == "same_value_multiple_rows" + + +def test_conflicting_line_speeds_are_reported(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "ROP_RATE_OF_PRODUCTION": "18,5"}, + {"SL_ITEM_NO": "214700", "ROP_RATE_OF_PRODUCTION": "19,0"}, + ], + ) + + article = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0] + + assert article.status == "conflict" + assert article.fields["line_speed_m_min"].status == "conflict" + + +def test_missing_minimum_production_quantity_is_reported(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp(tmp_path / "erp.csv", [{"SL_ITEM_NO": "214700"}]) + + field = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0].fields[ + "minimum_production_quantity" + ] + + assert field.value is None + assert field.status == "missing" + + +def test_identical_workplaces_in_multiple_rows_are_collapsed(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "WPL_WORKPLACE_TEXT": "Anlage 3"}, + {"SL_ITEM_NO": "214700", "WPL_WORKPLACE_TEXT": "Anlage 3"}, + ], + ) + + field = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0].fields[ + "workplace" + ] + + assert field.value == "Anlage 3" + assert field.status == "same_value_multiple_rows" + + +def test_different_workplaces_are_reported_as_conflict(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "WPL_WORKPLACE_TEXT": "Anlage 3"}, + {"SL_ITEM_NO": "214700", "WPL_WORKPLACE_TEXT": "Anlage 4"}, + ], + ) + + field = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0].fields[ + "workplace" + ] + + assert field.value is None + assert field.status == "conflict" + + +def test_decimal_values_with_comma_and_point_are_supported(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700", "000123"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": "6,00"}, + {"SL_ITEM_NO": "000123", "ROP_PRODUCT_WIDTH": "1.20"}, + ], + ) + + articles = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles + + assert articles[0].fields["product_width_m"].value == 6.0 + assert articles[1].fields["product_width_m"].value == 1.2 + + +def test_invalid_numeric_value_sets_field_to_null(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [{"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": "not-a-number"}], + ) + + article = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0] + + assert article.status == "conflict" + assert article.fields["product_width_m"].value is None + assert article.fields["product_width_m"].status == "invalid" + + +def test_empty_erp_fields_do_not_create_conflicts(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": ""}, + {"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": "6.00"}, + ], + ) + + field = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0].fields[ + "product_width_m" + ] + + assert field.value == 6.0 + assert field.status == "unique" + + +def test_leading_zero_article_number_matches_exactly(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["000123"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [{"SL_ITEM_NO": "000123", "ROP_PRODUCT_WIDTH": "1.20"}], + ) + + article = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0] + + assert article.nr == "000123" + assert article.fields["product_width_m"].value == 1.2 + + +def test_erp_article_without_rollcalc_match_is_not_output(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "214700", "ROP_PRODUCT_WIDTH": "6.00"}, + {"SL_ITEM_NO": "ERPONLY", "ROP_PRODUCT_WIDTH": "9.99"}, + ], + ) + + result = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path) + + assert [article.nr for article in result.articles] == ["214700"] + + +def test_rollcalc_order_is_preserved(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["300", "100", "200"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + {"SL_ITEM_NO": "100"}, + {"SL_ITEM_NO": "200"}, + {"SL_ITEM_NO": "300"}, + ], + ) + + result = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path) + + assert [article.nr for article in result.articles] == ["300", "100", "200"] + + +def test_existing_area_weight_remains_unchanged_and_erp_weight_fields_are_ignored( + tmp_path: Path, +) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + { + "SL_ITEM_NO": "214700", + "kg_qm": "999", + "SL_PRODUCTION_SPEED": "999", + "ROP_PRODUCT_WIDTH": "6.00", + } + ], + ) + + output = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).articles[0].to_output_dict() + + assert output["area_weight"] == 100.0 + assert "kg_qm" not in output["erp"] + assert "SL_PRODUCTION_SPEED" not in output["erp"] + + +def test_source_files_remain_unchanged(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp(tmp_path / "erp.csv", [{"SL_ITEM_NO": "214700"}]) + original_rollcalc = rollcalc_path.read_bytes() + original_erp = erp_path.read_bytes() + + result = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path) + write_enrichment_outputs(result, tmp_path / "generated.json", tmp_path / "report.csv") + + assert rollcalc_path.read_bytes() == original_rollcalc + assert erp_path.read_bytes() == original_erp + + +def test_json_output_is_deterministic(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp(tmp_path / "erp.csv", [{"SL_ITEM_NO": "214700"}]) + result = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path) + first_json = tmp_path / "first.json" + second_json = tmp_path / "second.json" + + write_enrichment_outputs(result, first_json, tmp_path / "first.csv") + write_enrichment_outputs(result, second_json, tmp_path / "second.csv") + + assert first_json.read_text(encoding="utf-8") == second_json.read_text(encoding="utf-8") + + +def test_csv_report_is_deterministic_and_contains_statuses(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["214700"]) + erp_path = write_erp(tmp_path / "erp.csv", [{"SL_ITEM_NO": "214700"}]) + result = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path) + first_report = tmp_path / "first.csv" + second_report = tmp_path / "second.csv" + + write_enrichment_outputs(result, tmp_path / "first.json", first_report) + write_enrichment_outputs(result, tmp_path / "second.json", second_report) + + first_content = first_report.read_text(encoding="utf-8-sig") + assert first_content == second_report.read_text(encoding="utf-8-sig") + assert "nr;name;erp_match_count;status" in first_content + assert "missing" in first_content + + +def test_summary_counts_articles_and_field_statuses(tmp_path: Path) -> None: + rollcalc_path = write_rollcalc(tmp_path / "article-data.json", ["A", "B", "C"]) + erp_path = write_erp( + tmp_path / "erp.csv", + [ + { + "SL_ITEM_NO": "A", + "ROP_PRODUCT_WIDTH": "1.0", + "ROP_RATE_OF_PRODUCTION": "2.0", + "SL_MINIMUM_PRODUCTION_QUANTITY": "3", + "WPL_WORKPLACE_TEXT": "Anlage", + }, + {"SL_ITEM_NO": "B", "ROP_PRODUCT_WIDTH": "bad"}, + ], + ) + + summary = enrich_rollcalc_articles_from_erp(rollcalc_path, erp_path).summary + + assert summary.rollcalc_article_count == 3 + assert summary.with_erp_match_count == 2 + assert summary.without_erp_match_count == 1 + assert summary.complete_count == 1 + assert summary.partial_count == 0 + assert summary.conflict_count == 1 + assert summary.field_summaries["product_width_m"].unique == 1 + assert summary.field_summaries["product_width_m"].invalid == 1 + assert summary.field_summaries["product_width_m"].no_erp_match == 1