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 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 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) 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", 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", ] 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 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() 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: