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
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 52 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -174,15 +219,14 @@ GitHub Actions validates:

## Roadmap

- Excel/CSV quotation import
- Configurable commercial scoring weights
- 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 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

Expand Down
169 changes: 140 additions & 29 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse
import csv
import json
from pathlib import Path
from typing import Any
Expand All @@ -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 '<none>'}'. "
"Use .json, .csv or .xlsx."
)


def validate_currencies(quotes: list[dict[str, Any]]) -> str:
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion rfqdiff/__init__.py
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -10,6 +19,8 @@
"__version__",
"build_result",
"load_quote",
"load_quotes",
"score_quotes",
"validate_currencies",
"validate_quote",
]
3 changes: 3 additions & 0 deletions samples/quotations.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name,currency,price,lead_time_weeks,payment_days
Supplier A,EUR,84200,8,30
Supplier B,EUR,79400,14,0
Loading
Loading