How-to
Automating Reporting WorkflowsDeep dive

Build a Supplier Scorecard

Score suppliers on delivery, quality and price from purchase order, receipt and inspection data with pandas: on-time and in-full delivery against the promised date, defect rates from goods inspection, price variance against the agreed price, a weighted overall score with clear thresholds, the trend against last quarter, and a colour-coded scorecard in Excel.

Purchasing teams know which suppliers are a pleasure to deal with and which ones keep causing trouble, but the impression is rarely measured. A supplier scorecard turns it into numbers that can be discussed in a quarterly review: did they deliver on time, did they deliver the full quantity, did the goods pass inspection, and did they charge the agreed price? The data already exists in the purchasing system — orders with promised dates, receipts with actual dates and quantities, inspection results and invoice prices — but scattered across exports. This page builds a quarterly scorecard with pandas: on-time in-full delivery at order-line level, defect rate, price variance, a weighted overall score with thresholds that everyone can understand, the change from the previous quarter, and a colour-coded Excel sheet ready for supplier meetings. It belongs to Building Finance and Operations Reports.

Prerequisites

Bash
pip install pandas openpyxl

The input is one row per purchase order line, already joined from the order, receipt, inspection and invoice exports: supplier, promised and received dates, quantity ordered and received, quantity rejected at goods-in inspection, and the agreed and invoiced unit prices. It covers the second and third quarters of 2026 for five suppliers.

Step 1 — The four measures per supplier and quarter

Python
import pandas as pd

lines = pd.read_excel("supplier_lines.xlsx", parse_dates=["Promised", "Received"])
lines["Quarter"] = lines["Promised"].dt.to_period("Q").dt.strftime("%Y-Q%q")
lines["On time"] = lines["Received"] <= lines["Promised"]
lines["In full"] = lines["Received qty"] >= lines["Ordered qty"]
lines["OTIF"] = lines["On time"] & lines["In full"]
lines["Spend"] = lines["Received qty"] * lines["Agreed price"]
lines["Overcharge"] = (lines["Invoiced price"] - lines["Agreed price"]) * lines["Received qty"]

measures = lines.groupby(["Supplier", "Quarter"]).agg(
    lines=("PO line", "size"), on_time=("On time", "mean"), in_full=("In full", "mean"), otif=("OTIF", "mean"),
    rejected=("Rejected qty", "sum"), received=("Received qty", "sum"), spend=("Spend", "sum"), overcharge=("Overcharge", "sum"))
measures["defect_rate"] = measures["rejected"] / measures["received"]
measures["price_variance"] = measures["overcharge"] / measures["spend"]
q3 = measures.xs("2026-Q3", level="Quarter")
print(q3[["lines", "on_time", "in_full", "otif", "defect_rate", "price_variance"]].round(3).to_string())

Each measure is calculated per order line and then averaged, so a supplier with many small orders and one with a few large ones are judged on the same basis. On time compares the receipt date with the date the supplier promised — not the date the buyer asked for, which suppliers cannot be held to if they never agreed it. In full means the whole quantity arrived. OTIF, on time and in full, is the headline delivery measure, and it is always lower than either part on its own, because a line counts only if both are true — Northfield Metals is on time on 96.5% of lines and in full on 93%, but both on only 91.2%. The defect rate is the share of received units rejected at inspection, and the price variance is the extra cost of invoiced prices above agreed prices as a share of spend at agreed prices — positive when the supplier overcharges, negative when they invoice below the agreed price.

The four measures on the scorecard On time in full is the share of order lines received by the promised date and in the full quantity. The defect rate is the share of received units rejected at inspection. Price variance is the overcharge against agreed prices as a share of spend. Together they make the weighted score. On time in full lines on time AND complete Defect rate rejected ÷ received units Price variance overcharge ÷ spend Weighted score 0–100, with thresholds

Step 2 — Score each measure and weight them

Python
def scale(value, best, worst):
    """Map a measure to 0–100 points between a 'worst acceptable' and a 'best expected' value."""
    return ((value - worst) / (best - worst)).clip(0, 1) * 100

scores = pd.DataFrame({
    "Delivery": scale(measures["otif"], best=0.98, worst=0.70),
    "Quality": scale(measures["defect_rate"], best=0.0, worst=0.05),
    "Price": scale(measures["price_variance"], best=0.0, worst=0.03),
})
WEIGHTS = {"Delivery": 0.5, "Quality": 0.3, "Price": 0.2}
scores["Score"] = sum(scores[k] * w for k, w in WEIGHTS.items()).round(0)
scores["Rating"] = pd.cut(scores["Score"], [0, 60, 80, 101], labels=["Red", "Amber", "Green"], right=False)
print(scores.xs("2026-Q3", level="Quarter").round(0).to_string())

The measures are on different scales — a share of lines, a share of units, a percentage of spend — and better is sometimes higher and sometimes lower. Mapping each one to points between a worst acceptable and a best expected value puts them on a common 0–100 scale that is easy to explain: an OTIF of 98% or better earns full delivery points, 70% or worse earns none, and anything between scores proportionally. For defects and price variance the best value is zero, and the scale runs the other way automatically because the worst value is higher than the best. The weights say what the business cares about most — here delivery half, quality three tenths, price a fifth — and the thresholds turn the score into a rating: green from 80, amber from 60, red below. Agreeing the thresholds and weights with the suppliers in advance is what makes the scorecard fair.

From a measure to points Each measure is placed between an agreed worst acceptable value, worth 0 points, and a best expected value, worth 100. Values beyond either end are clipped. An OTIF of 91.2 percent, between 70 and 98 percent, earns 76 delivery points. Measure e.g. OTIF 91.2% Worst = 70% → 0 agreed in advance Best = 98% → 100 agreed in advance Linear in between 91.2% → 76 points × weight delivery 0.5

The thresholds deserve as much thought as the weights. Set the best value too high and nobody ever scores full points, which makes the scorecard feel unwinnable; set the worst too low and poor performance still earns a respectable score. A good starting point is the contract: if the agreement promises 95% on-time delivery, a value around there is the natural point at which a supplier is doing what it signed up for.

Step 3 — The trend against last quarter

Python
wide = scores["Score"].unstack("Quarter")
wide["Change"] = wide["2026-Q3"] - wide["2026-Q2"]
board = (scores.xs("2026-Q3", level="Quarter")
         .join(wide["Change"])
         .join(q3[["otif", "defect_rate", "price_variance", "spend"]])
         .sort_values("Score", ascending=False))
print(board[["Score", "Rating", "Change", "otif", "defect_rate", "price_variance"]].round(3).to_string())

A single quarter's score says where a supplier stands; the change from last quarter says where they are heading, which often matters more in a review. A supplier that moved from red to amber after an improvement plan deserves recognition; a green supplier sliding by fifteen points needs a conversation before it turns amber. Joining the scores to the raw measures keeps the explanation next to the number. In the third quarter only Northfield Metals reaches green, at exactly 80, though it slipped six points; Apex Fasteners is amber, held back by an OTIF of 86% despite the best quality and pricing. Three suppliers are red, for different reasons that need different conversations: Coastline Plastics has improved ten points but still delivers late a quarter of the time, Summit Electronics fell thirteen points because 5.5% of its units failed inspection, and Delta Packaging delivered on time on only 63% of lines and invoiced 3.2% above the agreed prices.

The Q3 2026 scorecard Northfield Metals scores 80 and rates green. Apex Fasteners scores 75, amber. Coastline Plastics improved by 10 points but is still red at 30. Summit Electronics fell 13 points to 29 because of a 5.5 percent defect rate, and Delta Packaging scores 18 with 63 percent OTIF and invoices 3.2 percent above agreed prices. A B C D E 1 Supplier Score Rating Change vs Q2 Main issue 2 Northfield Metals 80 Green −6 — 3 Apex Fasteners 75 Amber −4 OTIF 86% 4 Coastline Plastics 30 Red +10 OTIF 76%, defects 3.3% 5 Summit Electronics 29 Red −13 defects 5.5% 6 Delta Packaging 18 Red −12 OTIF 63%, price +3.2%

Step 4 — The scorecard workbook

Python
from openpyxl import load_workbook
from openpyxl.styles import PatternFill

out = board.reset_index()[["Supplier", "Score", "Rating", "Change", "Delivery", "Quality", "Price", "otif", "defect_rate", "price_variance", "spend"]]
out = out.rename(columns={"otif": "OTIF", "defect_rate": "Defect rate", "price_variance": "Price variance", "spend": "Spend"})
path = "supplier_scorecard_2026-Q3.xlsx"
with pd.ExcelWriter(path) as xw:
    out.to_excel(xw, sheet_name="Scorecard", index=False)
    lines[lines["Quarter"] == "2026-Q3"].to_excel(xw, sheet_name="Order lines", index=False)
wb = load_workbook(path)
ws = wb["Scorecard"]
fills = {"Green": "C6EFCE", "Amber": "FFEB9C", "Red": "FFC7CE"}
for row in ws.iter_rows(min_row=2):
    row[2].fill = PatternFill("solid", fgColor=fills[row[2].value])
    for cell, fmt in zip(row[7:11], ["0.0%", "0.00%", "+0.00%;-0.00%", "#,##0"]):
        cell.number_format = fmt
wb.save(path)
print(out[["Supplier", "Score", "Rating", "Change"]].to_string(index=False))

The scorecard sheet lists suppliers from best to worst with the overall score, the coloured rating, the change from last quarter, the three component scores and the underlying measures, formatted as percentages so they read naturally. The order lines for the quarter sit on a second sheet: when a supplier disputes a late delivery, the line, the promised date and the receipt date are right there. Sending each supplier its own row and lines, rather than the whole table, is a small extension using a loop over suppliers, as in Send Each Recipient Their Own Excel Report with Python.

Common pitfalls

  • Measuring against the requested date. Use the date the supplier promised.
  • Averaging on-time and in-full separately. OTIF needs both on the same line.
  • Raw measures on different scales. Map each to points between agreed worst and best values.
  • Weights decided after the fact. Agree weights and thresholds before the quarter starts.
  • A score without the reason. Show the measures next to the score so the conversation is specific.

Conclusion

A supplier scorecard needs four measures per supplier and period — on-time in-full delivery against the promised date, defect rate, and price variance against agreed prices — each turned into 0–100 points between an agreed worst and best value and combined with agreed weights. Ratings from fixed thresholds, the change from the previous quarter and the underlying measures make it a basis for supplier reviews rather than a league table.

Frequently asked questions

How do I calculate OTIF in pandas? Flag each order line as on time if received by the promised date and in full if the whole quantity arrived, then take the mean of the combined flag per supplier.

How do I combine measures on different scales into one score? Map each measure to points between a worst acceptable and a best expected value, clip to 0–100, and take a weighted sum.

Should price variance be part of a supplier scorecard? Usually yes, with a lower weight than delivery and quality; it catches invoices above the agreed price.

How do I show a supplier's trend? Calculate the score for each quarter and show the difference from the previous quarter next to the current score.

More on report layouts & templates

More on report layouts & templates →