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