diff --git a/README.md b/README.md index 4a4bf50..208c63f 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ It does: - use explicit default or user-supplied commercial scoring weights; - produce deterministic supplier scores; - return machine-readable JSON for downstream decision systems; +- export ranked comparison reports as CSV or Excel; - preserve upstream normalization metadata when present in JSON inputs. It intentionally does **not**: @@ -89,6 +90,13 @@ Write the same integration payload to a file: rfqdiff samples/quotations.csv --output rfq.json ``` +Export a ranked comparison report: + +```bash +rfqdiff samples/quotations.csv --report comparison.csv +rfqdiff samples/quotations.csv --report comparison.xlsx +``` + JSON, CSV and XLSX inputs can also be combined in one command as long as every quotation uses the same currency. ## Configurable scoring weights @@ -124,6 +132,27 @@ Weight profiles are deliberately strict: The effective profile is returned in the output payload under `weights`, keeping each recommendation auditable. +## Comparison report exports + +`--report` produces reviewer-friendly exports without replacing the machine-readable JSON contract. + +CSV reports contain one ranked supplier per row with: + +- rank; +- recommended flag; +- supplier name; +- currency and price; +- lead time; +- payment terms; +- final score. + +Excel reports contain two worksheets: + +- `Comparison` — the ranked supplier table; +- `Summary` — recommended supplier, lowest price, fastest lead time, best payment terms and the effective scoring weights. + +The report uses the same scored payload as the JSON output, so the human-facing export and downstream integration result remain aligned. + The JSON contract contains: ```json @@ -176,6 +205,13 @@ quotes = rfqdiff.load_quotes(Path("quotations.xlsx")) weights = rfqdiff.load_weights(Path("weights.json")) ``` +Comparison reports can be exported from a built result: + +```python +payload = rfqdiff.build_result(scored, "EUR", weights) +rfqdiff.write_report(payload, Path("comparison.xlsx")) +``` + ## Quotation format A JSON quotation contains one supplier: @@ -232,6 +268,7 @@ GitHub Actions validates: - JSON, CSV and Excel quotation loading; - default and configurable scoring profiles; - rejection of incomplete or unsupported weight profiles; +- CSV and Excel comparison report exports; - wheel and source-distribution builds; - package metadata with `twine check`; - installation of the built wheel and runtime dependencies; @@ -258,13 +295,12 @@ GitHub Actions validates: ## Roadmap -- Exportable comparison reports - Richer quotation provenance - Richer decision explanations ## Status -Early-stage project, currently at **v0.2**. The current line provides a stable JSON integration contract, an installable Python package and console CLI, JSON/CSV/XLSX quotation ingestion, and auditable configurable commercial scoring weights. +Early-stage project, currently at **v0.2**. The current line provides a stable JSON integration contract, an installable Python package and console CLI, JSON/CSV/XLSX quotation ingestion, auditable configurable commercial scoring weights, and CSV/XLSX comparison report export. ## License diff --git a/main.py b/main.py index 9b552b1..9fc6161 100644 --- a/main.py +++ b/main.py @@ -362,6 +362,128 @@ def write_json(payload: dict[str, Any], path: Path) -> None: ) +def write_csv_report(payload: dict[str, Any], path: Path) -> None: + fieldnames = [ + "rank", + "recommended", + "name", + "currency", + "price", + "lead_time_weeks", + "payment_days", + "score", + ] + with path.open("w", encoding="utf-8", newline="") as file: + writer = csv.DictWriter(file, fieldnames=fieldnames) + writer.writeheader() + for rank, quote in enumerate(payload["suppliers"], start=1): + writer.writerow( + { + "rank": rank, + "recommended": quote["name"] == payload["recommended_supplier"], + "name": quote["name"], + "currency": payload["currency"], + "price": quote["price"], + "lead_time_weeks": quote["lead_time_weeks"], + "payment_days": quote["payment_days"], + "score": quote["score"], + } + ) + + +def write_xlsx_report(payload: dict[str, Any], path: Path) -> None: + try: + from openpyxl import Workbook + except ImportError as error: + raise ValueError( + "Excel report export requires openpyxl. Install the project package dependencies first." + ) from error + + workbook = Workbook() + comparison = workbook.active + comparison.title = "Comparison" + comparison.append( + [ + "Rank", + "Recommended", + "Supplier", + "Currency", + "Price", + "Lead Time (Weeks)", + "Payment Days", + "Score", + ] + ) + for rank, quote in enumerate(payload["suppliers"], start=1): + comparison.append( + [ + rank, + quote["name"] == payload["recommended_supplier"], + quote["name"], + payload["currency"], + quote["price"], + quote["lead_time_weeks"], + quote["payment_days"], + quote["score"], + ] + ) + comparison.freeze_panes = "A2" + comparison.auto_filter.ref = comparison.dimensions + + summary = workbook.create_sheet("Summary") + decision = payload["decision_summary"] + summary.append(["Metric", "Supplier", "Value"]) + summary.append( + [ + "Recommended supplier", + decision["recommended_supplier"]["name"], + decision["recommended_supplier"]["score"], + ] + ) + summary.append( + [ + "Lowest price", + decision["lowest_price"]["name"], + decision["lowest_price"]["price"], + ] + ) + summary.append( + [ + "Fastest lead time", + decision["fastest_lead_time"]["name"], + decision["fastest_lead_time"]["lead_time_weeks"], + ] + ) + summary.append( + [ + "Best payment terms", + decision["best_payment_terms"]["name"], + decision["best_payment_terms"]["payment_days"], + ] + ) + summary.append([]) + summary.append(["Scoring criterion", "Weight"]) + for criterion, weight in payload["weights"].items(): + summary.append([criterion, weight]) + summary.freeze_panes = "A2" + + workbook.save(path) + workbook.close() + + +def write_report(payload: dict[str, Any], path: Path) -> None: + suffix = path.suffix.lower() + if suffix == ".csv": + write_csv_report(payload, path) + return + if suffix == ".xlsx": + write_xlsx_report(payload, path) + return + raise ValueError( + f"{path}: unsupported report format '{suffix or ''}'. Use .csv or .xlsx." + ) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Compare supplier quotations from JSON, CSV or Excel files." @@ -387,6 +509,11 @@ def parse_args() -> argparse.Namespace: type=Path, help="Write the machine-readable comparison payload to a JSON file.", ) + parser.add_argument( + "--report", + type=Path, + help="Write a comparison report as .csv or .xlsx.", + ) return parser.parse_args() @@ -401,12 +528,14 @@ def main() -> None: weights = load_weights(args.weights) if args.weights else validate_weights(WEIGHTS) scored_quotes = score_quotes(quotes, weights) payload = build_result(scored_quotes, currency, weights) + + if args.output: + write_json(payload, args.output) + if args.report: + write_report(payload, args.report) except (OSError, json.JSONDecodeError, ValueError) as error: raise SystemExit(f"Error: {error}") from error - if args.output: - write_json(payload, args.output) - if args.json: print(json.dumps(payload, indent=2, ensure_ascii=False)) else: diff --git a/rfqdiff/__init__.py b/rfqdiff/__init__.py index 853d53f..925b305 100644 --- a/rfqdiff/__init__.py +++ b/rfqdiff/__init__.py @@ -11,6 +11,7 @@ validate_currencies, validate_quote, validate_weights, + write_report, ) __version__ = "0.2.0" @@ -27,4 +28,5 @@ "validate_currencies", "validate_quote", "validate_weights", + "write_report", ] diff --git a/tests/test_package.py b/tests/test_package.py index e1084f5..dc749fa 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -32,6 +32,19 @@ def test_public_tabular_loading_api(self): self.assertEqual(len(quotes), 2) self.assertEqual(quotes[0]["name"], "A") + def test_public_report_export_api(self): + quotes = [ + {"name": "A", "currency": "EUR", "price": 100, "lead_time_weeks": 4, "payment_days": 30}, + {"name": "B", "currency": "EUR", "price": 120, "lead_time_weeks": 5, "payment_days": 0}, + ] + scored = rfqdiff.score_quotes(quotes) + payload = rfqdiff.build_result(scored, "EUR") + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "comparison.csv" + rfqdiff.write_report(payload, path) + self.assertTrue(path.exists()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..8694069 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,74 @@ +import csv +import tempfile +import unittest +from pathlib import Path + +import main as rfqdiff + + +class ReportExportTests(unittest.TestCase): + def setUp(self) -> None: + quotes = [ + { + "name": "Supplier A", + "currency": "EUR", + "price": 84200, + "lead_time_weeks": 8, + "payment_days": 30, + }, + { + "name": "Supplier B", + "currency": "EUR", + "price": 79400, + "lead_time_weeks": 14, + "payment_days": 0, + }, + ] + self.scored = rfqdiff.score_quotes(quotes) + self.payload = rfqdiff.build_result(self.scored, "EUR") + + def test_write_csv_report_exports_ranked_suppliers(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "comparison.csv" + rfqdiff.write_report(self.payload, path) + + with path.open("r", encoding="utf-8", newline="") as file: + rows = list(csv.DictReader(file)) + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["rank"], "1") + self.assertEqual(rows[0]["name"], "Supplier A") + self.assertEqual(rows[0]["recommended"], "True") + self.assertEqual(rows[0]["currency"], "EUR") + + def test_write_xlsx_report_exports_comparison_and_summary(self) -> None: + from openpyxl import load_workbook + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "comparison.xlsx" + rfqdiff.write_report(self.payload, path) + + workbook = load_workbook(path, read_only=True, data_only=True) + try: + self.assertEqual(workbook.sheetnames, ["Comparison", "Summary"]) + comparison = workbook["Comparison"] + self.assertEqual(comparison["C2"].value, "Supplier A") + self.assertTrue(comparison["B2"].value) + + summary = workbook["Summary"] + self.assertEqual(summary["A2"].value, "Recommended supplier") + self.assertEqual(summary["B2"].value, "Supplier A") + self.assertEqual(summary["A8"].value, "price") + self.assertEqual(summary["B8"].value, 0.5) + finally: + workbook.close() + + def test_write_report_rejects_unsupported_extension(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "comparison.pdf" + with self.assertRaisesRegex(ValueError, "unsupported report format"): + rfqdiff.write_report(self.payload, path) + + +if __name__ == "__main__": + unittest.main()