From cb252253a9b1d28f243ec0126b46d2ea6e02a7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:47:59 +0300 Subject: [PATCH 1/8] feat: add CSV and Excel quotation import --- main.py | 169 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 140 insertions(+), 29 deletions(-) diff --git a/main.py b/main.py index 16606e9..a0358c5 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import argparse +import csv import json from pathlib import Path from typing import Any @@ -12,40 +13,151 @@ "payment_terms": 0.20, } +REQUIRED_FIELDS = [ + "name", + "currency", + "price", + "lead_time_weeks", + "payment_days", +] -def load_quote(path: Path) -> dict[str, Any]: - with path.open("r", encoding="utf-8") as file: - quote = json.load(file) - required_fields = [ - "name", - "currency", - "price", - "lead_time_weeks", - "payment_days", - ] +def _coerce_number(value: Any, field: str, source: str) -> int | float: + if isinstance(value, bool): + raise ValueError(f"{source}: {field} must be numeric") + + if isinstance(value, (int, float)): + number = value + else: + try: + number = float(str(value).strip()) + except (TypeError, ValueError) as error: + raise ValueError(f"{source}: {field} must be numeric") from error + + if isinstance(number, float) and number.is_integer(): + return int(number) + return number - missing = [field for field in required_fields if field not in quote] + +def validate_quote(quote: dict[str, Any], source: str) -> dict[str, Any]: + missing = [field for field in REQUIRED_FIELDS if field not in quote] if missing: raise ValueError( - f"{path}: missing required field(s): {', '.join(missing)}" + f"{source}: missing required field(s): {', '.join(missing)}" ) - if quote["price"] <= 0: - raise ValueError(f"{path}: price must be greater than 0") + validated = quote.copy() + validated["price"] = _coerce_number(validated["price"], "price", source) + validated["lead_time_weeks"] = _coerce_number( + validated["lead_time_weeks"], "lead_time_weeks", source + ) + validated["payment_days"] = _coerce_number( + validated["payment_days"], "payment_days", source + ) + + if validated["price"] <= 0: + raise ValueError(f"{source}: price must be greater than 0") + + if validated["lead_time_weeks"] <= 0: + raise ValueError(f"{source}: lead_time_weeks must be greater than 0") + + if validated["payment_days"] < 0: + raise ValueError(f"{source}: payment_days cannot be negative") + + validated["currency"] = str(validated["currency"]).strip().upper() + if not validated["currency"]: + raise ValueError(f"{source}: currency cannot be empty") + + validated["name"] = str(validated["name"]).strip() + if not validated["name"]: + raise ValueError(f"{source}: name cannot be empty") + + return validated - if quote["lead_time_weeks"] <= 0: - raise ValueError(f"{path}: lead_time_weeks must be greater than 0") - if quote["payment_days"] < 0: - raise ValueError(f"{path}: payment_days cannot be negative") +def load_quote(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as file: + quote = json.load(file) + + if not isinstance(quote, dict): + raise ValueError(f"{path}: quotation JSON must contain one object") - quote["currency"] = str(quote["currency"]).upper() - quote["name"] = str(quote["name"]).strip() - if not quote["name"]: - raise ValueError(f"{path}: name cannot be empty") + return validate_quote(quote, str(path)) - return quote + +def load_csv_quotes(path: Path) -> list[dict[str, Any]]: + with path.open("r", encoding="utf-8-sig", newline="") as file: + reader = csv.DictReader(file) + fieldnames = reader.fieldnames or [] + missing = [field for field in REQUIRED_FIELDS if field not in fieldnames] + if missing: + raise ValueError( + f"{path}: missing required column(s): {', '.join(missing)}" + ) + + quotes = [ + validate_quote(dict(row), f"{path}: row {row_number}") + for row_number, row in enumerate(reader, start=2) + if any(value not in (None, "") for value in row.values()) + ] + + if not quotes: + raise ValueError(f"{path}: no supplier quotations found") + return quotes + + +def load_xlsx_quotes(path: Path) -> list[dict[str, Any]]: + try: + from openpyxl import load_workbook + except ImportError as error: + raise ValueError( + "Excel import requires openpyxl. Install the project package dependencies first." + ) from error + + workbook = load_workbook(path, read_only=True, data_only=True) + try: + worksheet = workbook.active + rows = worksheet.iter_rows(values_only=True) + try: + header_row = next(rows) + except StopIteration as error: + raise ValueError(f"{path}: workbook is empty") from error + + fieldnames = [ + str(value).strip() if value is not None else "" for value in header_row + ] + missing = [field for field in REQUIRED_FIELDS if field not in fieldnames] + if missing: + raise ValueError( + f"{path}: missing required column(s): {', '.join(missing)}" + ) + + quotes = [] + for row_number, values in enumerate(rows, start=2): + if not any(value not in (None, "") for value in values): + continue + row = dict(zip(fieldnames, values)) + quotes.append(validate_quote(row, f"{path}: row {row_number}")) + finally: + workbook.close() + + if not quotes: + raise ValueError(f"{path}: no supplier quotations found") + return quotes + + +def load_quotes(path: Path) -> list[dict[str, Any]]: + suffix = path.suffix.lower() + if suffix == ".json": + return [load_quote(path)] + if suffix == ".csv": + return load_csv_quotes(path) + if suffix == ".xlsx": + return load_xlsx_quotes(path) + raise ValueError( + f"{path}: unsupported quotation format '{suffix or ''}'. " + "Use .json, .csv or .xlsx." + ) def validate_currencies(quotes: list[dict[str, Any]]) -> str: @@ -191,13 +303,13 @@ def write_json(payload: dict[str, Any], path: Path) -> None: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Compare supplier quotations from JSON files." + description="Compare supplier quotations from JSON, CSV or Excel files." ) parser.add_argument( "quotes", nargs="+", type=Path, - help="Paths to supplier quotation JSON files.", + help="Quotation files (.json, .csv or .xlsx). Tabular files may contain multiple suppliers.", ) parser.add_argument( "--json", @@ -215,11 +327,10 @@ def parse_args() -> argparse.Namespace: def main() -> None: args = parse_args() - if len(args.quotes) < 2: - raise SystemExit("rfqdiff needs at least two supplier quotation files.") - try: - quotes = [load_quote(path) for path in args.quotes] + quotes = [quote for path in args.quotes for quote in load_quotes(path)] + if len(quotes) < 2: + raise ValueError("rfqdiff needs at least two supplier quotations.") currency = validate_currencies(quotes) scored_quotes = score_quotes(quotes) payload = build_result(scored_quotes, currency) From 073feab88b481b17b651bafee8399dfe7d0131ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:48:17 +0300 Subject: [PATCH 2/8] packaging: add Excel import dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4b8373b..c8e327b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.11" license = "MIT" authors = [{ name = "Yiğitcan Öztürk" }] +dependencies = ["openpyxl>=3.1,<4"] keywords = ["procurement", "rfq", "quotations", "supplier-management", "decision-support", "supply-chain"] classifiers = [ "Development Status :: 3 - Alpha", From 127229560021c9b572c7120a65c71ee2e7384a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:48:41 +0300 Subject: [PATCH 3/8] test: cover CSV and Excel quotation imports --- tests/test_rfqdiff.py | 58 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/test_rfqdiff.py b/tests/test_rfqdiff.py index fbf67e0..6262eea 100644 --- a/tests/test_rfqdiff.py +++ b/tests/test_rfqdiff.py @@ -55,6 +55,64 @@ def test_load_quote_rejects_missing_required_field(self) -> None: with self.assertRaisesRegex(ValueError, "payment_days"): rfqdiff.load_quote(path) + def test_load_csv_quotes_accepts_multiple_suppliers(self) -> None: + csv_content = ( + "name,currency,price,lead_time_weeks,payment_days\n" + "Supplier A,eur,84200,8,30\n" + "Supplier B,EUR,79400,14,0\n" + ) + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "quotes.csv" + path.write_text(csv_content, encoding="utf-8") + loaded = rfqdiff.load_quotes(path) + + self.assertEqual(len(loaded), 2) + self.assertEqual(loaded[0]["name"], "Supplier A") + self.assertEqual(loaded[0]["currency"], "EUR") + self.assertEqual(loaded[0]["price"], 84200) + self.assertEqual(loaded[1]["payment_days"], 0) + + def test_load_xlsx_quotes_accepts_multiple_suppliers(self) -> None: + from openpyxl import Workbook + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "quotes.xlsx" + workbook = Workbook() + worksheet = workbook.active + worksheet.append( + ["name", "currency", "price", "lead_time_weeks", "payment_days"] + ) + worksheet.append(["Supplier A", "EUR", 84200, 8, 30]) + worksheet.append(["Supplier B", "EUR", 79400, 14, 0]) + workbook.save(path) + workbook.close() + + loaded = rfqdiff.load_quotes(path) + + self.assertEqual(len(loaded), 2) + self.assertEqual(loaded[1]["name"], "Supplier B") + self.assertEqual(loaded[1]["price"], 79400) + + def test_load_tabular_quotes_rejects_missing_required_column(self) -> None: + csv_content = ( + "name,currency,price,lead_time_weeks\n" + "Supplier A,EUR,84200,8\n" + ) + + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "quotes.csv" + path.write_text(csv_content, encoding="utf-8") + with self.assertRaisesRegex(ValueError, "payment_days"): + rfqdiff.load_quotes(path) + + def test_load_quotes_rejects_unsupported_format(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "quotes.txt" + path.write_text("unsupported", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unsupported quotation format"): + rfqdiff.load_quotes(path) + class CurrencyValidationTests(unittest.TestCase): def test_validate_currencies_accepts_single_currency(self) -> None: From 893b726fb51f6a2e77827e6642440944d4497cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:48:48 +0300 Subject: [PATCH 4/8] ci: install runtime dependencies before tests --- .github/workflows/tests.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7b5e1e5..054ab04 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,9 @@ jobs: - name: Install build tooling run: python -m pip install --upgrade pip build twine + - name: Install project dependencies + run: python -m pip install -e . + - name: Run tests run: python -m unittest discover -s tests -v From c4bf92015bd3cc45822f0195a060c37051ff26a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:48:56 +0300 Subject: [PATCH 5/8] samples: add tabular quotation example --- samples/quotations.csv | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 samples/quotations.csv diff --git a/samples/quotations.csv b/samples/quotations.csv new file mode 100644 index 0000000..9303f9e --- /dev/null +++ b/samples/quotations.csv @@ -0,0 +1,3 @@ +name,currency,price,lead_time_weeks,payment_days +Supplier A,EUR,84200,8,30 +Supplier B,EUR,79400,14,0 From 07147b92657dd52b819cfa6f52a0725ba3830836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:49:05 +0300 Subject: [PATCH 6/8] api: expose quotation loading helpers --- rfqdiff/__init__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/rfqdiff/__init__.py b/rfqdiff/__init__.py index d5025ee..3fe26f3 100644 --- a/rfqdiff/__init__.py +++ b/rfqdiff/__init__.py @@ -1,6 +1,15 @@ """Public Python API for rfqdiff.""" -from main import VERSION, WEIGHTS, build_result, load_quote, score_quotes, validate_currencies +from main import ( + VERSION, + WEIGHTS, + build_result, + load_quote, + load_quotes, + score_quotes, + validate_currencies, + validate_quote, +) __version__ = "0.2.0" @@ -10,6 +19,8 @@ "__version__", "build_result", "load_quote", + "load_quotes", "score_quotes", "validate_currencies", + "validate_quote", ] From 6f9c7dbed96a6c5ef832ec42bc0013f14cd47c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:49:30 +0300 Subject: [PATCH 7/8] docs: document CSV and Excel quotation import --- README.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 624c5e0..c554261 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ The goal is not to automate procurement judgment. The goal is to make a commerci It does: - compare price, lead time and payment terms; +- import quotations from JSON, CSV and Excel (`.xlsx`); - produce deterministic supplier scores; - return machine-readable JSON for downstream decision systems; -- preserve upstream normalization metadata when present. +- preserve upstream normalization metadata when present in JSON inputs. It intentionally does **not**: @@ -57,24 +58,38 @@ The original `python main.py ...` source-checkout workflow remains supported for ## Quick start -Compare quotations: +Compare separate JSON quotations: ```bash rfqdiff samples/supplier_a.json samples/supplier_b.json ``` +Compare multiple suppliers from one CSV file: + +```bash +rfqdiff samples/quotations.csv +``` + +Excel workbooks use the same columns and can be passed directly: + +```bash +rfqdiff quotations.xlsx +``` + Machine-readable output: ```bash -rfqdiff samples/supplier_a.json samples/supplier_b.json --json +rfqdiff samples/quotations.csv --json ``` Write the same integration payload to a file: ```bash -rfqdiff samples/supplier_a.json samples/supplier_b.json --output rfq.json +rfqdiff samples/quotations.csv --output rfq.json ``` +JSON, CSV and XLSX inputs can also be combined in one command as long as every quotation uses the same currency. + The JSON contract contains: ```json @@ -105,8 +120,19 @@ scored = rfqdiff.score_quotes([ ]) ``` +Tabular files can be loaded through the public API as well: + +```python +from pathlib import Path +import rfqdiff + +quotes = rfqdiff.load_quotes(Path("quotations.xlsx")) +``` + ## Quotation format +A JSON quotation contains one supplier: + ```json { "name": "Supplier A", @@ -117,7 +143,25 @@ scored = rfqdiff.score_quotes([ } ``` -All quotations must use the same currency. For mixed currencies, normalize them first with `currency-normalizer`, then pass the normalized files to `rfqdiff`. +CSV and Excel files use one supplier per row with these required columns: + +| Column | Meaning | +| --- | --- | +| `name` | Supplier name | +| `currency` | ISO-style currency code used by the quotation | +| `price` | Commercial quotation value | +| `lead_time_weeks` | Lead time in weeks | +| `payment_days` | Payment term length in days | + +Example CSV: + +```csv +name,currency,price,lead_time_weeks,payment_days +Supplier A,EUR,84200,8,30 +Supplier B,EUR,79400,14,0 +``` + +All quotations must use the same currency. For mixed currencies, normalize them first with `currency-normalizer`, then pass the normalized values to `rfqdiff`. ## Scoring model @@ -148,9 +192,10 @@ bidlint ──> technical compliance ────────────── GitHub Actions validates: - unit tests on Python 3.11, 3.12 and 3.13; +- JSON, CSV and Excel quotation loading; - wheel and source-distribution builds; - package metadata with `twine check`; -- installation of the built wheel; +- installation of the built wheel and runtime dependencies; - the installed `rfqdiff` console command and public package namespace. ## Engineering principles @@ -174,7 +219,6 @@ GitHub Actions validates: ## Roadmap -- Excel/CSV quotation import - Configurable commercial scoring weights - Exportable comparison reports - Richer quotation provenance @@ -182,7 +226,7 @@ GitHub Actions validates: ## Status -Early-stage project, currently at **v0.2**. The current line provides a stable JSON integration contract plus an installable Python package and console CLI. +Early-stage project, currently at **v0.2**. The current line provides a stable JSON integration contract, an installable Python package and console CLI, plus JSON/CSV/XLSX quotation ingestion. ## License From f9e0f5e358e64955f14c9f799abec7720fc0dc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:49:39 +0300 Subject: [PATCH 8/8] test: cover public tabular loading API --- tests/test_package.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_package.py b/tests/test_package.py index 534be3e..e1084f5 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -1,4 +1,6 @@ +import tempfile import unittest +from pathlib import Path import rfqdiff @@ -16,6 +18,20 @@ def test_public_scoring_api(self): scored = rfqdiff.score_quotes(quotes) self.assertEqual(scored[0]["name"], "A") + def test_public_tabular_loading_api(self): + csv_content = ( + "name,currency,price,lead_time_weeks,payment_days\n" + "A,EUR,100,4,30\n" + "B,EUR,120,5,0\n" + ) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "quotes.csv" + path.write_text(csv_content, encoding="utf-8") + quotes = rfqdiff.load_quotes(path) + + self.assertEqual(len(quotes), 2) + self.assertEqual(quotes[0]["name"], "A") + if __name__ == "__main__": unittest.main()