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
68 changes: 53 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ It does:

- compare price, lead time and payment terms;
- import quotations from JSON, CSV and Excel (`.xlsx`);
- use explicit default or user-supplied commercial scoring weights;
- produce deterministic supplier scores;
- return machine-readable JSON for downstream decision systems;
- preserve upstream normalization metadata when present in JSON inputs.
Expand All @@ -34,7 +35,7 @@ It intentionally does **not**:
- determine technical compliance;
- score operational supplier risk;
- make contractual acceptance decisions;
- hide unsupported criteria inside an opaque composite score.
- accept hidden or unsupported scoring criteria.

Those responsibilities remain separated across the engineering procurement toolchain.

Expand Down Expand Up @@ -90,6 +91,39 @@ 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.

## Configurable scoring weights

The default commercial model is:

| Criterion | Weight | Better score |
| --- | ---: | --- |
| Price | 50% | Lower |
| Lead time | 30% | Lower |
| Payment terms | 20% | Longer |

For a different procurement strategy, pass a JSON weights profile with `--weights`:

```json
{
"price": 0.40,
"lead_time": 0.40,
"payment_terms": 0.20
}
```

```bash
rfqdiff samples/quotations.csv --weights samples/weights-balanced.json
```

Weight profiles are deliberately strict:

- all three supported criteria must be present;
- no additional criteria are accepted;
- each value must be between `0` and `1`;
- values must sum to exactly `1.0` within floating-point tolerance.

The effective profile is returned in the output payload under `weights`, keeping each recommendation auditable.

The JSON contract contains:

```json
Expand All @@ -103,7 +137,12 @@ The JSON contract contains:
"name": "Supplier A",
"score": 97.1
}
]
],
"weights": {
"price": 0.5,
"lead_time": 0.3,
"payment_terms": 0.2
}
}
```

Expand All @@ -120,13 +159,21 @@ scored = rfqdiff.score_quotes([
])
```

Tabular files can be loaded through the public API as well:
Custom weights can be supplied through the public API:

```python
weights = {"price": 0.4, "lead_time": 0.4, "payment_terms": 0.2}
scored = rfqdiff.score_quotes(quotes, weights)
```

Tabular files and weight profiles can be loaded through the public API as well:

```python
from pathlib import Path
import rfqdiff

quotes = rfqdiff.load_quotes(Path("quotations.xlsx"))
weights = rfqdiff.load_weights(Path("weights.json"))
```

## Quotation format
Expand Down Expand Up @@ -163,16 +210,6 @@ 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

| Criterion | Weight | Better score |
| --- | ---: | --- |
| Price | 50% | Lower |
| Lead time | 30% | Lower |
| Payment terms | 20% | Longer |

The score is intentionally explicit so the recommendation can be reviewed rather than treated as a black box.

## Pipeline role

```text
Expand All @@ -193,6 +230,8 @@ GitHub Actions validates:

- unit tests on Python 3.11, 3.12 and 3.13;
- JSON, CSV and Excel quotation loading;
- default and configurable scoring profiles;
- rejection of incomplete or unsupported weight profiles;
- wheel and source-distribution builds;
- package metadata with `twine check`;
- installation of the built wheel and runtime dependencies;
Expand All @@ -219,14 +258,13 @@ GitHub Actions validates:

## Roadmap

- 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, an installable Python package and console CLI, plus JSON/CSV/XLSX quotation ingestion.
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.

## License

Expand Down
91 changes: 79 additions & 12 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
import csv
import json
import math
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -170,25 +171,76 @@ def validate_currencies(quotes: list[dict[str, Any]]) -> str:
return next(iter(currencies))


def score_quotes(quotes: list[dict[str, Any]]) -> list[dict[str, Any]]:
def validate_weights(weights: dict[str, Any]) -> dict[str, float]:
expected = set(WEIGHTS)
supplied = set(weights)

missing = sorted(expected - supplied)
if missing:
raise ValueError(f"weights: missing required key(s): {', '.join(missing)}")

unsupported = sorted(supplied - expected)
if unsupported:
raise ValueError(f"weights: unsupported key(s): {', '.join(unsupported)}")

validated: dict[str, float] = {}
for key in WEIGHTS:
value = weights[key]
if isinstance(value, bool):
raise ValueError(f"weights: {key} must be numeric")
try:
number = float(value)
except (TypeError, ValueError) as error:
raise ValueError(f"weights: {key} must be numeric") from error
if number < 0 or number > 1:
raise ValueError(f"weights: {key} must be between 0 and 1")
validated[key] = number

total = sum(validated.values())
if not math.isclose(total, 1.0, rel_tol=0.0, abs_tol=1e-9):
raise ValueError(f"weights: values must sum to 1.0; got {total:g}")

return validated


def load_weights(path: Path) -> dict[str, float]:
with path.open("r", encoding="utf-8") as file:
weights = json.load(file)

if not isinstance(weights, dict):
raise ValueError(f"{path}: weights JSON must contain one object")

try:
return validate_weights(weights)
except ValueError as error:
raise ValueError(f"{path}: {error}") from error


def score_quotes(
quotes: list[dict[str, Any]],
weights: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
effective_weights = validate_weights(WEIGHTS if weights is None else weights)
min_price = min(quote["price"] for quote in quotes)
min_lead_time = min(quote["lead_time_weeks"] for quote in quotes)
max_payment_days = max(quote["payment_days"] for quote in quotes)

scored_quotes = []

for quote in quotes:
price_score = (min_price / quote["price"]) * WEIGHTS["price"] * 100
price_score = (
min_price / quote["price"]
) * effective_weights["price"] * 100
lead_time_score = (
min_lead_time / quote["lead_time_weeks"]
) * WEIGHTS["lead_time"] * 100
) * effective_weights["lead_time"] * 100

if max_payment_days == 0:
payment_score = WEIGHTS["payment_terms"] * 100
payment_score = effective_weights["payment_terms"] * 100
else:
payment_score = (
quote["payment_days"] / max_payment_days
) * WEIGHTS["payment_terms"] * 100
) * effective_weights["payment_terms"] * 100

total_score = price_score + lead_time_score + payment_score

Expand All @@ -205,7 +257,9 @@ def score_quotes(quotes: list[dict[str, Any]]) -> list[dict[str, Any]]:
def build_result(
scored_quotes: list[dict[str, Any]],
currency: str,
weights: dict[str, Any] | None = None,
) -> dict[str, Any]:
effective_weights = validate_weights(WEIGHTS if weights is None else weights)
winner = scored_quotes[0]
cheapest = min(scored_quotes, key=lambda item: item["price"])
fastest = min(scored_quotes, key=lambda item: item["lead_time_weeks"])
Expand Down Expand Up @@ -235,15 +289,20 @@ def build_result(
"payment_days": best_terms["payment_days"],
},
},
"weights": WEIGHTS,
"weights": effective_weights,
}


def money(value: float, currency: str) -> str:
return f"{currency} {value:,.2f}"


def print_report(quotes: list[dict[str, Any]], currency: str) -> None:
def print_report(
quotes: list[dict[str, Any]],
currency: str,
weights: dict[str, Any] | None = None,
) -> None:
effective_weights = validate_weights(WEIGHTS if weights is None else weights)
print(f"\nRFQDIFF v{VERSION}")
print("=" * 86)
print(
Expand All @@ -264,7 +323,7 @@ def print_report(quotes: list[dict[str, Any]], currency: str) -> None:
f"{str(quote['score']) + '/100':>12}"
)

payload = build_result(quotes, currency)
payload = build_result(quotes, currency, effective_weights)
summary = payload["decision_summary"]
winner = summary["recommended_supplier"]
cheapest = summary["lowest_price"]
Expand All @@ -289,7 +348,9 @@ def print_report(quotes: list[dict[str, Any]], currency: str) -> None:

print("\nScoring weights")
print(
"Price 50% | Lead time 30% | Payment terms 20%\n"
f"Price {effective_weights['price']:.0%} | "
f"Lead time {effective_weights['lead_time']:.0%} | "
f"Payment terms {effective_weights['payment_terms']:.0%}\n"
"Lower price and lead time score higher; longer payment terms score higher."
)

Expand All @@ -311,6 +372,11 @@ def parse_args() -> argparse.Namespace:
type=Path,
help="Quotation files (.json, .csv or .xlsx). Tabular files may contain multiple suppliers.",
)
parser.add_argument(
"--weights",
type=Path,
help="JSON file with price, lead_time and payment_terms weights summing to 1.0.",
)
parser.add_argument(
"--json",
action="store_true",
Expand All @@ -332,8 +398,9 @@ def main() -> None:
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)
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)
except (OSError, json.JSONDecodeError, ValueError) as error:
raise SystemExit(f"Error: {error}") from error

Expand All @@ -343,7 +410,7 @@ def main() -> None:
if args.json:
print(json.dumps(payload, indent=2, ensure_ascii=False))
else:
print_report(scored_quotes, currency)
print_report(scored_quotes, currency, weights)


if __name__ == "__main__":
Expand Down
4 changes: 4 additions & 0 deletions rfqdiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
build_result,
load_quote,
load_quotes,
load_weights,
score_quotes,
validate_currencies,
validate_quote,
validate_weights,
)

__version__ = "0.2.0"
Expand All @@ -20,7 +22,9 @@
"build_result",
"load_quote",
"load_quotes",
"load_weights",
"score_quotes",
"validate_currencies",
"validate_quote",
"validate_weights",
]
5 changes: 5 additions & 0 deletions samples/weights-balanced.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"price": 0.40,
"lead_time": 0.40,
"payment_terms": 0.20
}
Loading
Loading