Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand All @@ -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

Expand Down
135 changes: 132 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<none>'}'. Use .csv or .xlsx."
)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Compare supplier quotations from JSON, CSV or Excel files."
Expand All @@ -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()


Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions rfqdiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
validate_currencies,
validate_quote,
validate_weights,
write_report,
)

__version__ = "0.2.0"
Expand All @@ -27,4 +28,5 @@
"validate_currencies",
"validate_quote",
"validate_weights",
"write_report",
]
13 changes: 13 additions & 0 deletions tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
74 changes: 74 additions & 0 deletions tests/test_reports.py
Original file line number Diff line number Diff line change
@@ -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()
Loading