[{"data":1,"prerenderedAt":2726},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcompare-supplier-quotes-with-python":3,"surround:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcompare-supplier-quotes-with-python":2691,"ancestors:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcompare-supplier-quotes-with-python":2700,"children:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcompare-supplier-quotes-with-python":2705,"related:\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcompare-supplier-quotes-with-python":2706},{"id":4,"title":5,"body":6,"dateModified":2657,"datePublished":2657,"description":2658,"extension":2659,"faq":2660,"keyword":2669,"kind":2670,"meta":2671,"navigation":95,"path":2680,"seo":2681,"slug":2684,"stem":2685,"topics":2686,"type":2689,"__hash__":2690},"docs\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcompare-supplier-quotes-with-python\u002Findex.md","Compare Supplier Quotes with Python",{"type":7,"value":8,"toc":2645},"minimark",[9,19,24,53,60,64,67,683,689,916,920,923,1070,1073,1077,1084,1411,1427,1431,1434,1851,1854,1921,1925,1928,2396,2403,2531,2535,2570,2574,2580,2584,2590,2596,2602,2608,2612,2641],[10,11,12,13,18],"p",{},"Purchasing sends a request for quotation to three suppliers and gets back three spreadsheets that cannot be compared side by side. One prices per unit in euros, another per box of ten in pounds; one has quantity price breaks, another a minimum order and a delivery charge; their column names, item codes and lead times are all in different places. Building the comparison by hand means re-keying and recalculating — exactly where a pack size or exchange rate gets missed and the \"cheapest\" supplier turns out not to be. With pandas, each quote is mapped once into a common schema, prices are normalised to a landed cost per unit at the quantity you actually need, and a weighted score combines price with lead time and payment terms. The result is a comparison workbook a buyer can defend. It belongs to ",[14,15,17],"a",{"href":16},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002F","Building Finance and Operations Reports",".",[20,21,23],"h2",{"id":22},"prerequisites","Prerequisites",[25,26,31],"pre",{"className":27,"code":28,"language":29,"meta":30,"style":30},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[32,33,34],"code",{"__ignoreMap":30},[35,36,39,43,47,50],"span",{"class":37,"line":38},"line",1,[35,40,42],{"class":41},"sMTad","pip",[35,44,46],{"class":45},"srMev"," install",[35,48,49],{"class":45}," pandas",[35,51,52],{"class":45}," openpyxl\n",[10,54,55,56,59],{},"The ",[32,57,58],{},"quotes\u002F"," folder holds three replies to a request for 300 boxes of gloves, 120 high-visibility vests and 60 rolls of tape. Acme quotes per unit in euros with price breaks at 200 and 500 boxes; Brightline quotes per pack in pounds; Cobalt quotes per unit in euros with minimum order quantities. Payment terms and delivery charges come from the covering emails and are entered in a small table in Step 4.",[20,61,63],{"id":62},"step-1-map-each-supplier-to-one-schema","Step 1 — Map each supplier to one schema",[10,65,66],{},"Write down, once per supplier, which of their columns means what, and how their prices are expressed. Everything after this step works on one shape:",[25,68,72],{"className":69,"code":70,"language":71,"meta":30,"style":30},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\nSUPPLIERS = {\n    \"Acme Supplies\": {\"file\": \"quotes\u002Facme_supplies.xlsx\", \"currency\": \"EUR\",\n                      \"cols\": {\"Item code\": \"item\", \"From qty\": \"from_qty\", \"Unit price EUR\": \"price\",\n                               \"Lead time (days)\": \"lead_days\"}},\n    \"Brightline Ltd\": {\"file\": \"quotes\u002Fbrightline_ltd.xlsx\", \"currency\": \"GBP\",\n                       \"cols\": {\"Our ref\": \"item\", \"Pack size\": \"pack\", \"Price per pack GBP\": \"price\",\n                                \"Delivery (working days)\": \"lead_days\"}, \"working_days\": True},\n    \"Cobalt Trading\": {\"file\": \"quotes\u002Fcobalt_trading.xlsx\",\n                       \"cols\": {\"SKU\": \"item\", \"Price\": \"price\", \"Currency\": \"currency\", \"MOQ\": \"moq\", \"Lead\": \"lead_days\"}},\n}\n\ndef load(name, spec):\n    df = pd.read_excel(spec[\"file\"]).rename(columns=spec[\"cols\"])[list(spec[\"cols\"].values())]\n    df[\"supplier\"] = name\n    df[\"currency\"] = df.get(\"currency\", spec.get(\"currency\"))\n    df[\"from_qty\"] = df.get(\"from_qty\", 1)\n    df[\"pack\"] = df.get(\"pack\", 1)\n    df[\"moq\"] = df.get(\"moq\", 0)\n    if spec.get(\"working_days\"):\n        df[\"lead_days\"] = (df[\"lead_days\"] * 7 \u002F 5).round().astype(int)    # working → calendar days\n    return df\n\nquotes = pd.concat([load(n, s) for n, s in SUPPLIERS.items()], ignore_index=True)\nprint(quotes[[\"supplier\", \"item\", \"from_qty\", \"pack\", \"price\", \"currency\", \"moq\", \"lead_days\"]].to_string(index=False))\n","python",[32,73,74,90,97,110,142,180,194,220,256,280,297,350,356,361,374,417,434,458,481,502,524,538,582,591,596,631],{"__ignoreMap":30},[35,75,76,80,84,87],{"class":37,"line":38},[35,77,79],{"class":78},"s-kum","import",[35,81,83],{"class":82},"skGVy"," pandas ",[35,85,86],{"class":78},"as",[35,88,89],{"class":82}," pd\n",[35,91,93],{"class":37,"line":92},2,[35,94,96],{"emptyLinePlaceholder":95},true,"\n",[35,98,100,104,107],{"class":37,"line":99},3,[35,101,103],{"class":102},"sP0c6","SUPPLIERS",[35,105,106],{"class":78}," =",[35,108,109],{"class":82}," {\n",[35,111,113,116,119,122,125,128,131,134,136,139],{"class":37,"line":112},4,[35,114,115],{"class":45},"    \"Acme Supplies\"",[35,117,118],{"class":82},": {",[35,120,121],{"class":45},"\"file\"",[35,123,124],{"class":82},": ",[35,126,127],{"class":45},"\"quotes\u002Facme_supplies.xlsx\"",[35,129,130],{"class":82},", ",[35,132,133],{"class":45},"\"currency\"",[35,135,124],{"class":82},[35,137,138],{"class":45},"\"EUR\"",[35,140,141],{"class":82},",\n",[35,143,145,148,150,153,155,158,160,163,165,168,170,173,175,178],{"class":37,"line":144},5,[35,146,147],{"class":45},"                      \"cols\"",[35,149,118],{"class":82},[35,151,152],{"class":45},"\"Item code\"",[35,154,124],{"class":82},[35,156,157],{"class":45},"\"item\"",[35,159,130],{"class":82},[35,161,162],{"class":45},"\"From qty\"",[35,164,124],{"class":82},[35,166,167],{"class":45},"\"from_qty\"",[35,169,130],{"class":82},[35,171,172],{"class":45},"\"Unit price EUR\"",[35,174,124],{"class":82},[35,176,177],{"class":45},"\"price\"",[35,179,141],{"class":82},[35,181,183,186,188,191],{"class":37,"line":182},6,[35,184,185],{"class":45},"                               \"Lead time (days)\"",[35,187,124],{"class":82},[35,189,190],{"class":45},"\"lead_days\"",[35,192,193],{"class":82},"}},\n",[35,195,197,200,202,204,206,209,211,213,215,218],{"class":37,"line":196},7,[35,198,199],{"class":45},"    \"Brightline Ltd\"",[35,201,118],{"class":82},[35,203,121],{"class":45},[35,205,124],{"class":82},[35,207,208],{"class":45},"\"quotes\u002Fbrightline_ltd.xlsx\"",[35,210,130],{"class":82},[35,212,133],{"class":45},[35,214,124],{"class":82},[35,216,217],{"class":45},"\"GBP\"",[35,219,141],{"class":82},[35,221,223,226,228,231,233,235,237,240,242,245,247,250,252,254],{"class":37,"line":222},8,[35,224,225],{"class":45},"                       \"cols\"",[35,227,118],{"class":82},[35,229,230],{"class":45},"\"Our ref\"",[35,232,124],{"class":82},[35,234,157],{"class":45},[35,236,130],{"class":82},[35,238,239],{"class":45},"\"Pack size\"",[35,241,124],{"class":82},[35,243,244],{"class":45},"\"pack\"",[35,246,130],{"class":82},[35,248,249],{"class":45},"\"Price per pack GBP\"",[35,251,124],{"class":82},[35,253,177],{"class":45},[35,255,141],{"class":82},[35,257,259,262,264,266,269,272,274,277],{"class":37,"line":258},9,[35,260,261],{"class":45},"                                \"Delivery (working days)\"",[35,263,124],{"class":82},[35,265,190],{"class":45},[35,267,268],{"class":82},"}, ",[35,270,271],{"class":45},"\"working_days\"",[35,273,124],{"class":82},[35,275,276],{"class":102},"True",[35,278,279],{"class":82},"},\n",[35,281,283,286,288,290,292,295],{"class":37,"line":282},10,[35,284,285],{"class":45},"    \"Cobalt Trading\"",[35,287,118],{"class":82},[35,289,121],{"class":45},[35,291,124],{"class":82},[35,293,294],{"class":45},"\"quotes\u002Fcobalt_trading.xlsx\"",[35,296,141],{"class":82},[35,298,300,302,304,307,309,311,313,316,318,320,322,325,327,329,331,334,336,339,341,344,346,348],{"class":37,"line":299},11,[35,301,225],{"class":45},[35,303,118],{"class":82},[35,305,306],{"class":45},"\"SKU\"",[35,308,124],{"class":82},[35,310,157],{"class":45},[35,312,130],{"class":82},[35,314,315],{"class":45},"\"Price\"",[35,317,124],{"class":82},[35,319,177],{"class":45},[35,321,130],{"class":82},[35,323,324],{"class":45},"\"Currency\"",[35,326,124],{"class":82},[35,328,133],{"class":45},[35,330,130],{"class":82},[35,332,333],{"class":45},"\"MOQ\"",[35,335,124],{"class":82},[35,337,338],{"class":45},"\"moq\"",[35,340,130],{"class":82},[35,342,343],{"class":45},"\"Lead\"",[35,345,124],{"class":82},[35,347,190],{"class":45},[35,349,193],{"class":82},[35,351,353],{"class":37,"line":352},12,[35,354,355],{"class":82},"}\n",[35,357,359],{"class":37,"line":358},13,[35,360,96],{"emptyLinePlaceholder":95},[35,362,364,367,371],{"class":37,"line":363},14,[35,365,366],{"class":78},"def",[35,368,370],{"class":369},"s_Opv"," load",[35,372,373],{"class":82},"(name, spec):\n",[35,375,377,380,383,386,388,391,395,397,400,403,406,409,412,414],{"class":37,"line":376},15,[35,378,379],{"class":82},"    df ",[35,381,382],{"class":78},"=",[35,384,385],{"class":82}," pd.read_excel(spec[",[35,387,121],{"class":45},[35,389,390],{"class":82},"]).rename(",[35,392,394],{"class":393},"sa561","columns",[35,396,382],{"class":78},[35,398,399],{"class":82},"spec[",[35,401,402],{"class":45},"\"cols\"",[35,404,405],{"class":82},"])[",[35,407,408],{"class":102},"list",[35,410,411],{"class":82},"(spec[",[35,413,402],{"class":45},[35,415,416],{"class":82},"].values())]\n",[35,418,420,423,426,429,431],{"class":37,"line":419},16,[35,421,422],{"class":82},"    df[",[35,424,425],{"class":45},"\"supplier\"",[35,427,428],{"class":82},"] ",[35,430,382],{"class":78},[35,432,433],{"class":82}," name\n",[35,435,437,439,441,443,445,448,450,453,455],{"class":37,"line":436},17,[35,438,422],{"class":82},[35,440,133],{"class":45},[35,442,428],{"class":82},[35,444,382],{"class":78},[35,446,447],{"class":82}," df.get(",[35,449,133],{"class":45},[35,451,452],{"class":82},", spec.get(",[35,454,133],{"class":45},[35,456,457],{"class":82},"))\n",[35,459,461,463,465,467,469,471,473,475,478],{"class":37,"line":460},18,[35,462,422],{"class":82},[35,464,167],{"class":45},[35,466,428],{"class":82},[35,468,382],{"class":78},[35,470,447],{"class":82},[35,472,167],{"class":45},[35,474,130],{"class":82},[35,476,477],{"class":102},"1",[35,479,480],{"class":82},")\n",[35,482,484,486,488,490,492,494,496,498,500],{"class":37,"line":483},19,[35,485,422],{"class":82},[35,487,244],{"class":45},[35,489,428],{"class":82},[35,491,382],{"class":78},[35,493,447],{"class":82},[35,495,244],{"class":45},[35,497,130],{"class":82},[35,499,477],{"class":102},[35,501,480],{"class":82},[35,503,505,507,509,511,513,515,517,519,522],{"class":37,"line":504},20,[35,506,422],{"class":82},[35,508,338],{"class":45},[35,510,428],{"class":82},[35,512,382],{"class":78},[35,514,447],{"class":82},[35,516,338],{"class":45},[35,518,130],{"class":82},[35,520,521],{"class":102},"0",[35,523,480],{"class":82},[35,525,527,530,533,535],{"class":37,"line":526},21,[35,528,529],{"class":78},"    if",[35,531,532],{"class":82}," spec.get(",[35,534,271],{"class":45},[35,536,537],{"class":82},"):\n",[35,539,541,544,546,548,550,553,555,557,560,563,566,569,572,575,578],{"class":37,"line":540},22,[35,542,543],{"class":82},"        df[",[35,545,190],{"class":45},[35,547,428],{"class":82},[35,549,382],{"class":78},[35,551,552],{"class":82}," (df[",[35,554,190],{"class":45},[35,556,428],{"class":82},[35,558,559],{"class":78},"*",[35,561,562],{"class":102}," 7",[35,564,565],{"class":78}," \u002F",[35,567,568],{"class":102}," 5",[35,570,571],{"class":82},").round().astype(",[35,573,574],{"class":102},"int",[35,576,577],{"class":82},")    ",[35,579,581],{"class":580},"s-wDw","# working → calendar days\n",[35,583,585,588],{"class":37,"line":584},23,[35,586,587],{"class":78},"    return",[35,589,590],{"class":82}," df\n",[35,592,594],{"class":37,"line":593},24,[35,595,96],{"emptyLinePlaceholder":95},[35,597,599,602,604,607,610,613,616,619,622,625,627,629],{"class":37,"line":598},25,[35,600,601],{"class":82},"quotes ",[35,603,382],{"class":78},[35,605,606],{"class":82}," pd.concat([load(n, s) ",[35,608,609],{"class":78},"for",[35,611,612],{"class":82}," n, s ",[35,614,615],{"class":78},"in",[35,617,618],{"class":102}," SUPPLIERS",[35,620,621],{"class":82},".items()], ",[35,623,624],{"class":393},"ignore_index",[35,626,382],{"class":78},[35,628,276],{"class":102},[35,630,480],{"class":82},[35,632,634,637,640,642,644,646,648,650,652,654,656,658,660,662,664,666,668,670,673,676,678,681],{"class":37,"line":633},26,[35,635,636],{"class":102},"print",[35,638,639],{"class":82},"(quotes[[",[35,641,425],{"class":45},[35,643,130],{"class":82},[35,645,157],{"class":45},[35,647,130],{"class":82},[35,649,167],{"class":45},[35,651,130],{"class":82},[35,653,244],{"class":45},[35,655,130],{"class":82},[35,657,177],{"class":45},[35,659,130],{"class":82},[35,661,133],{"class":45},[35,663,130],{"class":82},[35,665,338],{"class":45},[35,667,130],{"class":82},[35,669,190],{"class":45},[35,671,672],{"class":82},"]].to_string(",[35,674,675],{"class":393},"index",[35,677,382],{"class":78},[35,679,680],{"class":102},"False",[35,682,457],{"class":82},[10,684,685,686,688],{},"Missing concepts get neutral defaults — no price breaks means a single tier from quantity 1, no pack size means 1, no minimum order means 0 — so every supplier ends up with the same columns. Brightline's lead time is in working days and is converted to calendar days so the three are comparable. Adding a fourth supplier is one more entry in ",[32,687,103],{},", which keeps the mapping readable and reviewable.",[690,691,699,700,699,704,699,708,699,715,699,724,699,728,699,735,699,739,699,743,699,747,699,751,699,753,699,757,699,759,699,762,699,765,699,769,699,771,699,775,699,777,699,780,699,783,699,787,699,789,699,792,699,794,699,797,699,800,699,804,699,806,699,809,699,811,699,814,699,819,699,824,699,828,699,832,699,837,699,842,699,844,699,847,699,850,699,854,699,856,699,858,699,861,699,865,699,867,699,869,699,872,699,876,699,878,699,880,699,886,699,890,699,897,699,902,699,910],"svg",{"viewBox":692,"role":693,"ariaLabelledBy":694,"xmlns":697,"style":698},"12 0 736 196","img",[695,696],"sq-map-t","sq-map-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:736px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[701,702,703],"title",{"id":695},"Three quote layouts, one schema",[705,706,707],"desc",{"id":696},"Each supplier names its columns differently and expresses price differently. A per-supplier mapping renames columns to item, from quantity, pack, price, currency, minimum order and lead days, filling neutral defaults where a supplier has no such concept.",[709,710],"rect",{"x":711,"y":521,"width":712,"height":713,"fill":714},"12","736","196","#ffffff",[709,716],{"x":717,"y":718,"width":719,"height":720,"rx":521,"fill":721,"stroke":722,"style":723},"28.4","40.0","32.0","24.0","#e7ebef","var(--line,#cdd5e6)","stroke-width:1px",[709,725],{"x":726,"y":718,"width":727,"height":720,"rx":521,"fill":721,"stroke":722,"style":723},"60.4","76.1",[729,730,734],"text",{"x":731,"y":732,"style":733},"98.4","57.0","font-size:11px;font-weight:600;fill:var(--muted,#5b6780);text-anchor:middle","A",[709,736],{"x":737,"y":718,"width":738,"height":720,"rx":521,"fill":721,"stroke":722,"style":723},"136.4","233.3",[729,740,742],{"x":741,"y":732,"style":733},"253.1","B",[709,744],{"x":717,"y":745,"width":719,"height":746,"rx":521,"fill":721,"stroke":722,"style":723},"64.0","28.0",[729,748,477],{"x":749,"y":750,"style":733},"44.4","83.0",[709,752],{"x":726,"y":745,"width":727,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,754,756],{"x":731,"y":750,"style":755},"font-size:10.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Supplier",[709,758],{"x":737,"y":745,"width":738,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,760,761],{"x":741,"y":750,"style":755},"Their columns",[709,763],{"x":717,"y":764,"width":719,"height":746,"rx":521,"fill":721,"stroke":722,"style":723},"92.0",[729,766,768],{"x":749,"y":767,"style":733},"111.0","2",[709,770],{"x":726,"y":764,"width":727,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,772,774],{"x":731,"y":767,"style":773},"font-size:10.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Acme",[709,776],{"x":737,"y":764,"width":738,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,778,779],{"x":741,"y":767,"style":773},"Item code · From qty · Unit price EUR",[709,781],{"x":717,"y":782,"width":719,"height":746,"rx":521,"fill":721,"stroke":722,"style":723},"120.0",[729,784,786],{"x":749,"y":785,"style":733},"139.0","3",[709,788],{"x":726,"y":782,"width":727,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,790,791],{"x":731,"y":785,"style":773},"Brightline",[709,793],{"x":737,"y":782,"width":738,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,795,796],{"x":741,"y":785,"style":773},"Our ref · Pack size · Price per pack GBP",[709,798],{"x":717,"y":799,"width":719,"height":746,"rx":521,"fill":721,"stroke":722,"style":723},"148.0",[729,801,803],{"x":749,"y":802,"style":733},"167.0","4",[709,805],{"x":726,"y":799,"width":727,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,807,808],{"x":731,"y":802,"style":773},"Cobalt",[709,810],{"x":737,"y":799,"width":738,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,812,813],{"x":741,"y":802,"style":773},"SKU · Price · Currency · MOQ",[709,815],{"x":816,"y":718,"width":817,"height":746,"rx":521,"fill":721,"stroke":722,"style":818},"589.2","72.6","stroke-width:1.5px",[729,820,823],{"x":821,"y":822,"style":755},"625.5","59.0","column",[709,825],{"x":826,"y":718,"width":827,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},"661.8","69.9",[729,829,831],{"x":830,"y":822,"style":755},"696.7","default",[709,833],{"x":816,"y":834,"width":817,"height":746,"rx":521,"fill":835,"stroke":836,"style":818},"68.0","#d9f4f1","var(--teal,#0f9488)",[729,838,841],{"x":821,"y":839,"style":840},"87.0","font-size:10.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","item",[709,843],{"x":826,"y":834,"width":827,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,845,846],{"x":830,"y":839,"style":773},"required",[709,848],{"x":816,"y":849,"width":817,"height":746,"rx":521,"fill":835,"stroke":836,"style":818},"96.0",[729,851,853],{"x":821,"y":852,"style":840},"115.0","from_qty",[709,855],{"x":826,"y":849,"width":827,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,857,477],{"x":830,"y":852,"style":773},[709,859],{"x":816,"y":860,"width":817,"height":746,"rx":521,"fill":835,"stroke":836,"style":818},"124.0",[729,862,864],{"x":821,"y":863,"style":840},"143.0","pack",[709,866],{"x":826,"y":860,"width":827,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,868,477],{"x":830,"y":863,"style":773},[709,870],{"x":816,"y":871,"width":817,"height":746,"rx":521,"fill":835,"stroke":836,"style":818},"152.0",[729,873,875],{"x":821,"y":874,"style":840},"171.0","moq",[709,877],{"x":826,"y":871,"width":827,"height":746,"rx":521,"fill":714,"stroke":722,"style":723},[729,879,521],{"x":830,"y":874,"style":773},[729,881,885],{"x":882,"y":883,"style":884},"199.1","29.0","font-size:12.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","as received",[729,887,889],{"x":888,"y":883,"style":884},"660.4","common schema",[37,891],{"x1":892,"y1":893,"x2":894,"y2":893,"stroke":895,"style":896},"379.7","108.0","575.2","var(--brand,#5b5cf0)","stroke-width:2px",[898,899],"polygon",{"points":900,"fill":901},"579.2,108.0 570.2,113.0 570.2,103.0","#5b5cf0",[709,903],{"x":904,"y":905,"width":906,"height":907,"rx":908,"fill":714,"stroke":722,"style":909},"407.7","75.0","143.6","22.0","11","stroke-width:1.2px",[729,911,915],{"x":912,"y":913,"style":914},"479.5","90.0","font-size:11.5px;font-weight:400;fill:var(--brand-strong,#4338ca);text-anchor:middle;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace","SUPPLIERS mapping",[20,917,919],{"id":918},"step-2-normalise-to-a-price-per-unit-in-one-currency","Step 2 — Normalise to a price per unit in one currency",[10,921,922],{},"Prices now share column names but not meaning: per pack versus per unit, pounds versus euros. Convert everything to euros per single unit:",[25,924,926],{"className":69,"code":925,"language":71,"meta":30,"style":30},"fx_to_eur = {\"EUR\": 1.0, \"GBP\": 1.16}\n\nquotes[\"unit_eur\"] = (quotes[\"price\"] * quotes[\"currency\"].map(fx_to_eur) \u002F quotes[\"pack\"]).round(4)\nassert quotes[\"unit_eur\"].notna().all(), \"missing exchange rate\"\nprint(quotes.loc[quotes[\"from_qty\"] == 1, [\"supplier\", \"item\", \"price\", \"currency\", \"pack\", \"unit_eur\"]].to_string(index=False))\n",[32,927,928,956,960,1003,1018],{"__ignoreMap":30},[35,929,930,933,935,938,940,942,945,947,949,951,954],{"class":37,"line":38},[35,931,932],{"class":82},"fx_to_eur ",[35,934,382],{"class":78},[35,936,937],{"class":82}," {",[35,939,138],{"class":45},[35,941,124],{"class":82},[35,943,944],{"class":102},"1.0",[35,946,130],{"class":82},[35,948,217],{"class":45},[35,950,124],{"class":82},[35,952,953],{"class":102},"1.16",[35,955,355],{"class":82},[35,957,958],{"class":37,"line":92},[35,959,96],{"emptyLinePlaceholder":95},[35,961,962,965,968,970,972,975,977,979,981,984,986,989,992,994,996,999,1001],{"class":37,"line":99},[35,963,964],{"class":82},"quotes[",[35,966,967],{"class":45},"\"unit_eur\"",[35,969,428],{"class":82},[35,971,382],{"class":78},[35,973,974],{"class":82}," (quotes[",[35,976,177],{"class":45},[35,978,428],{"class":82},[35,980,559],{"class":78},[35,982,983],{"class":82}," quotes[",[35,985,133],{"class":45},[35,987,988],{"class":82},"].map(fx_to_eur) ",[35,990,991],{"class":78},"\u002F",[35,993,983],{"class":82},[35,995,244],{"class":45},[35,997,998],{"class":82},"]).round(",[35,1000,803],{"class":102},[35,1002,480],{"class":82},[35,1004,1005,1008,1010,1012,1015],{"class":37,"line":112},[35,1006,1007],{"class":78},"assert",[35,1009,983],{"class":82},[35,1011,967],{"class":45},[35,1013,1014],{"class":82},"].notna().all(), ",[35,1016,1017],{"class":45},"\"missing exchange rate\"\n",[35,1019,1020,1022,1025,1027,1029,1032,1035,1038,1040,1042,1044,1046,1048,1050,1052,1054,1056,1058,1060,1062,1064,1066,1068],{"class":37,"line":144},[35,1021,636],{"class":102},[35,1023,1024],{"class":82},"(quotes.loc[quotes[",[35,1026,167],{"class":45},[35,1028,428],{"class":82},[35,1030,1031],{"class":78},"==",[35,1033,1034],{"class":102}," 1",[35,1036,1037],{"class":82},", [",[35,1039,425],{"class":45},[35,1041,130],{"class":82},[35,1043,157],{"class":45},[35,1045,130],{"class":82},[35,1047,177],{"class":45},[35,1049,130],{"class":82},[35,1051,133],{"class":45},[35,1053,130],{"class":82},[35,1055,244],{"class":45},[35,1057,130],{"class":82},[35,1059,967],{"class":45},[35,1061,672],{"class":82},[35,1063,675],{"class":393},[35,1065,382],{"class":78},[35,1067,680],{"class":102},[35,1069,457],{"class":82},[10,1071,1072],{},"Brightline's £58.00 for a pack of ten boxes of gloves becomes €6.728 per box — cheaper than Acme's list price of €7.90, which would not have been obvious from the raw quotes. Note what \"unit\" means: here it is one box of gloves, one vest, one roll of tape, matching the request. When suppliers quote different base units (per metre versus per 50 m roll), convert the pack size accordingly; the formula does not change.",[20,1074,1076],{"id":1075},"step-3-apply-price-breaks-at-the-quantity-needed","Step 3 — Apply price breaks at the quantity needed",[10,1078,1079,1080,1083],{},"Price-break tables say \"from 200 boxes, €7.20\". For each item, the applicable tier is the last one whose starting quantity is at or below the order quantity — exactly what ",[32,1081,1082],{},"merge_asof"," does:",[25,1085,1087],{"className":69,"code":1086,"language":71,"meta":30,"style":30},"need = pd.DataFrame({\"item\": [\"GL-100\", \"HV-220\", \"TP-050\"], \"qty\": [300, 120, 60]})\npriced = pd.concat([\n    pd.merge_asof(need.sort_values(\"qty\"), grp.sort_values(\"from_qty\"),\n                  left_on=\"qty\", right_on=\"from_qty\", by=\"item\", direction=\"backward\")\n    for _, grp in quotes.groupby(\"supplier\")\n], ignore_index=True).dropna(subset=[\"supplier\"])\npriced[\"order_qty\"] = priced[[\"qty\", \"moq\"]].max(axis=1)\npriced[\"order_qty\"] = (-(-priced[\"order_qty\"] \u002F\u002F priced[\"pack\"]) * priced[\"pack\"]).astype(int)\npriced[\"goods_eur\"] = (priced[\"order_qty\"] * priced[\"unit_eur\"]).round(2)\nprint(priced[[\"item\", \"supplier\", \"qty\", \"order_qty\", \"unit_eur\", \"goods_eur\"]].sort_values([\"item\", \"supplier\"]).to_string(index=False))\n",[32,1088,1089,1141,1151,1166,1205,1222,1248,1281,1332,1362],{"__ignoreMap":30},[35,1090,1091,1094,1096,1099,1101,1104,1107,1109,1112,1114,1117,1120,1123,1125,1128,1130,1133,1135,1138],{"class":37,"line":38},[35,1092,1093],{"class":82},"need ",[35,1095,382],{"class":78},[35,1097,1098],{"class":82}," pd.DataFrame({",[35,1100,157],{"class":45},[35,1102,1103],{"class":82},": [",[35,1105,1106],{"class":45},"\"GL-100\"",[35,1108,130],{"class":82},[35,1110,1111],{"class":45},"\"HV-220\"",[35,1113,130],{"class":82},[35,1115,1116],{"class":45},"\"TP-050\"",[35,1118,1119],{"class":82},"], ",[35,1121,1122],{"class":45},"\"qty\"",[35,1124,1103],{"class":82},[35,1126,1127],{"class":102},"300",[35,1129,130],{"class":82},[35,1131,1132],{"class":102},"120",[35,1134,130],{"class":82},[35,1136,1137],{"class":102},"60",[35,1139,1140],{"class":82},"]})\n",[35,1142,1143,1146,1148],{"class":37,"line":92},[35,1144,1145],{"class":82},"priced ",[35,1147,382],{"class":78},[35,1149,1150],{"class":82}," pd.concat([\n",[35,1152,1153,1156,1158,1161,1163],{"class":37,"line":99},[35,1154,1155],{"class":82},"    pd.merge_asof(need.sort_values(",[35,1157,1122],{"class":45},[35,1159,1160],{"class":82},"), grp.sort_values(",[35,1162,167],{"class":45},[35,1164,1165],{"class":82},"),\n",[35,1167,1168,1171,1173,1175,1177,1180,1182,1184,1186,1189,1191,1193,1195,1198,1200,1203],{"class":37,"line":112},[35,1169,1170],{"class":393},"                  left_on",[35,1172,382],{"class":78},[35,1174,1122],{"class":45},[35,1176,130],{"class":82},[35,1178,1179],{"class":393},"right_on",[35,1181,382],{"class":78},[35,1183,167],{"class":45},[35,1185,130],{"class":82},[35,1187,1188],{"class":393},"by",[35,1190,382],{"class":78},[35,1192,157],{"class":45},[35,1194,130],{"class":82},[35,1196,1197],{"class":393},"direction",[35,1199,382],{"class":78},[35,1201,1202],{"class":45},"\"backward\"",[35,1204,480],{"class":82},[35,1206,1207,1210,1213,1215,1218,1220],{"class":37,"line":144},[35,1208,1209],{"class":78},"    for",[35,1211,1212],{"class":82}," _, grp ",[35,1214,615],{"class":78},[35,1216,1217],{"class":82}," quotes.groupby(",[35,1219,425],{"class":45},[35,1221,480],{"class":82},[35,1223,1224,1226,1228,1230,1232,1235,1238,1240,1243,1245],{"class":37,"line":182},[35,1225,1119],{"class":82},[35,1227,624],{"class":393},[35,1229,382],{"class":78},[35,1231,276],{"class":102},[35,1233,1234],{"class":82},").dropna(",[35,1236,1237],{"class":393},"subset",[35,1239,382],{"class":78},[35,1241,1242],{"class":82},"[",[35,1244,425],{"class":45},[35,1246,1247],{"class":82},"])\n",[35,1249,1250,1253,1256,1258,1260,1263,1265,1267,1269,1272,1275,1277,1279],{"class":37,"line":196},[35,1251,1252],{"class":82},"priced[",[35,1254,1255],{"class":45},"\"order_qty\"",[35,1257,428],{"class":82},[35,1259,382],{"class":78},[35,1261,1262],{"class":82}," priced[[",[35,1264,1122],{"class":45},[35,1266,130],{"class":82},[35,1268,338],{"class":45},[35,1270,1271],{"class":82},"]].max(",[35,1273,1274],{"class":393},"axis",[35,1276,382],{"class":78},[35,1278,477],{"class":102},[35,1280,480],{"class":82},[35,1282,1283,1285,1287,1289,1291,1294,1297,1300,1302,1304,1306,1308,1311,1314,1316,1319,1321,1323,1325,1328,1330],{"class":37,"line":222},[35,1284,1252],{"class":82},[35,1286,1255],{"class":45},[35,1288,428],{"class":82},[35,1290,382],{"class":78},[35,1292,1293],{"class":82}," (",[35,1295,1296],{"class":78},"-",[35,1298,1299],{"class":82},"(",[35,1301,1296],{"class":78},[35,1303,1252],{"class":82},[35,1305,1255],{"class":45},[35,1307,428],{"class":82},[35,1309,1310],{"class":78},"\u002F\u002F",[35,1312,1313],{"class":82}," priced[",[35,1315,244],{"class":45},[35,1317,1318],{"class":82},"]) ",[35,1320,559],{"class":78},[35,1322,1313],{"class":82},[35,1324,244],{"class":45},[35,1326,1327],{"class":82},"]).astype(",[35,1329,574],{"class":102},[35,1331,480],{"class":82},[35,1333,1334,1336,1339,1341,1343,1346,1348,1350,1352,1354,1356,1358,1360],{"class":37,"line":258},[35,1335,1252],{"class":82},[35,1337,1338],{"class":45},"\"goods_eur\"",[35,1340,428],{"class":82},[35,1342,382],{"class":78},[35,1344,1345],{"class":82}," (priced[",[35,1347,1255],{"class":45},[35,1349,428],{"class":82},[35,1351,559],{"class":78},[35,1353,1313],{"class":82},[35,1355,967],{"class":45},[35,1357,998],{"class":82},[35,1359,768],{"class":102},[35,1361,480],{"class":82},[35,1363,1364,1366,1369,1371,1373,1375,1377,1379,1381,1383,1385,1387,1389,1391,1394,1396,1398,1400,1403,1405,1407,1409],{"class":37,"line":282},[35,1365,636],{"class":102},[35,1367,1368],{"class":82},"(priced[[",[35,1370,157],{"class":45},[35,1372,130],{"class":82},[35,1374,425],{"class":45},[35,1376,130],{"class":82},[35,1378,1122],{"class":45},[35,1380,130],{"class":82},[35,1382,1255],{"class":45},[35,1384,130],{"class":82},[35,1386,967],{"class":45},[35,1388,130],{"class":82},[35,1390,1338],{"class":45},[35,1392,1393],{"class":82},"]].sort_values([",[35,1395,157],{"class":45},[35,1397,130],{"class":82},[35,1399,425],{"class":45},[35,1401,1402],{"class":82},"]).to_string(",[35,1404,675],{"class":393},[35,1406,382],{"class":78},[35,1408,680],{"class":102},[35,1410,457],{"class":82},[10,1412,1413,1415,1416,1419,1420,1422,1423,18],{},[32,1414,1082],{}," matches each requested quantity to the last tier whose starting quantity is at or below it, within the same item (",[32,1417,1418],{},"by=\"item\"","); running it once per supplier and concatenating the results gives one row per supplier and item — nine rows for three items and three suppliers. A single ",[32,1421,1082],{}," over all suppliers at once would pick just one supplier's tier per item, which is the classic mistake here. Acme's gloves at 300 boxes fall in the 200+ tier at €7.20. Quantities are then raised to any minimum order and rounded up to whole packs, because that is what you would actually be invoiced for: Brightline's 300 boxes are exactly 30 packs, while Cobalt's minimum of 100 rolls means 60 rolls of tape cost the price of 100, and its gloves minimum forces 400 boxes instead of 300. As-of joins in general are covered in ",[14,1424,1426],{"href":1425},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmatch-rows-by-nearest-date-with-merge-asof\u002F","Match Rows by Nearest Date with merge_asof",[20,1428,1430],{"id":1429},"step-4-add-delivery-terms-and-a-weighted-score","Step 4 — Add delivery, terms and a weighted score",[10,1432,1433],{},"Price is not the only criterion. Bring in the terms from the covering emails, compute the landed total per supplier, and score each supplier on price, lead time and payment terms with agreed weights:",[25,1435,1437],{"className":69,"code":1436,"language":71,"meta":30,"style":30},"terms = pd.DataFrame({\n    \"supplier\": [\"Acme Supplies\", \"Brightline Ltd\", \"Cobalt Trading\"],\n    \"delivery_eur\": [45.0, 0.0, 120.0],\n    \"payment_days\": [30, 60, 14],\n})\nWEIGHTS = {\"price\": 0.6, \"lead\": 0.25, \"terms\": 0.15}\n\nsummary = (priced.groupby(\"supplier\").agg(goods_eur=(\"goods_eur\", \"sum\"), lead_days=(\"lead_days\", \"max\"))\n                 .join(terms.set_index(\"supplier\")))\nsummary[\"total_eur\"] = summary[\"goods_eur\"] + summary[\"delivery_eur\"]\n\ndef score_low(s):  return (s.min() \u002F s * 100).round(1)    # cheapest \u002F fastest = 100\ndef score_high(s): return (s \u002F s.max() * 100).round(1)    # longest payment terms = 100\n\nsummary[\"score\"] = (WEIGHTS[\"price\"] * score_low(summary[\"total_eur\"]) +\n                    WEIGHTS[\"lead\"] * score_low(summary[\"lead_days\"]) +\n                    WEIGHTS[\"terms\"] * score_high(summary[\"payment_days\"])).round(1)\nprint(summary[[\"goods_eur\", \"delivery_eur\", \"total_eur\", \"lead_days\", \"payment_days\", \"score\"]].sort_values(\"score\", ascending=False).to_string())\n",[32,1438,1439,1449,1472,1493,1514,1519,1557,1561,1609,1619,1649,1653,1689,1722,1726,1759,1780,1805],{"__ignoreMap":30},[35,1440,1441,1444,1446],{"class":37,"line":38},[35,1442,1443],{"class":82},"terms ",[35,1445,382],{"class":78},[35,1447,1448],{"class":82}," pd.DataFrame({\n",[35,1450,1451,1454,1456,1459,1461,1464,1466,1469],{"class":37,"line":92},[35,1452,1453],{"class":45},"    \"supplier\"",[35,1455,1103],{"class":82},[35,1457,1458],{"class":45},"\"Acme Supplies\"",[35,1460,130],{"class":82},[35,1462,1463],{"class":45},"\"Brightline Ltd\"",[35,1465,130],{"class":82},[35,1467,1468],{"class":45},"\"Cobalt Trading\"",[35,1470,1471],{"class":82},"],\n",[35,1473,1474,1477,1479,1482,1484,1487,1489,1491],{"class":37,"line":99},[35,1475,1476],{"class":45},"    \"delivery_eur\"",[35,1478,1103],{"class":82},[35,1480,1481],{"class":102},"45.0",[35,1483,130],{"class":82},[35,1485,1486],{"class":102},"0.0",[35,1488,130],{"class":82},[35,1490,782],{"class":102},[35,1492,1471],{"class":82},[35,1494,1495,1498,1500,1503,1505,1507,1509,1512],{"class":37,"line":112},[35,1496,1497],{"class":45},"    \"payment_days\"",[35,1499,1103],{"class":82},[35,1501,1502],{"class":102},"30",[35,1504,130],{"class":82},[35,1506,1137],{"class":102},[35,1508,130],{"class":82},[35,1510,1511],{"class":102},"14",[35,1513,1471],{"class":82},[35,1515,1516],{"class":37,"line":144},[35,1517,1518],{"class":82},"})\n",[35,1520,1521,1524,1526,1528,1530,1532,1535,1537,1540,1542,1545,1547,1550,1552,1555],{"class":37,"line":182},[35,1522,1523],{"class":102},"WEIGHTS",[35,1525,106],{"class":78},[35,1527,937],{"class":82},[35,1529,177],{"class":45},[35,1531,124],{"class":82},[35,1533,1534],{"class":102},"0.6",[35,1536,130],{"class":82},[35,1538,1539],{"class":45},"\"lead\"",[35,1541,124],{"class":82},[35,1543,1544],{"class":102},"0.25",[35,1546,130],{"class":82},[35,1548,1549],{"class":45},"\"terms\"",[35,1551,124],{"class":82},[35,1553,1554],{"class":102},"0.15",[35,1556,355],{"class":82},[35,1558,1559],{"class":37,"line":196},[35,1560,96],{"emptyLinePlaceholder":95},[35,1562,1563,1566,1568,1571,1573,1576,1579,1581,1583,1585,1587,1590,1593,1596,1598,1600,1602,1604,1607],{"class":37,"line":222},[35,1564,1565],{"class":82},"summary ",[35,1567,382],{"class":78},[35,1569,1570],{"class":82}," (priced.groupby(",[35,1572,425],{"class":45},[35,1574,1575],{"class":82},").agg(",[35,1577,1578],{"class":393},"goods_eur",[35,1580,382],{"class":78},[35,1582,1299],{"class":82},[35,1584,1338],{"class":45},[35,1586,130],{"class":82},[35,1588,1589],{"class":45},"\"sum\"",[35,1591,1592],{"class":82},"), ",[35,1594,1595],{"class":393},"lead_days",[35,1597,382],{"class":78},[35,1599,1299],{"class":82},[35,1601,190],{"class":45},[35,1603,130],{"class":82},[35,1605,1606],{"class":45},"\"max\"",[35,1608,457],{"class":82},[35,1610,1611,1614,1616],{"class":37,"line":258},[35,1612,1613],{"class":82},"                 .join(terms.set_index(",[35,1615,425],{"class":45},[35,1617,1618],{"class":82},")))\n",[35,1620,1621,1624,1627,1629,1631,1634,1636,1638,1641,1643,1646],{"class":37,"line":282},[35,1622,1623],{"class":82},"summary[",[35,1625,1626],{"class":45},"\"total_eur\"",[35,1628,428],{"class":82},[35,1630,382],{"class":78},[35,1632,1633],{"class":82}," summary[",[35,1635,1338],{"class":45},[35,1637,428],{"class":82},[35,1639,1640],{"class":78},"+",[35,1642,1633],{"class":82},[35,1644,1645],{"class":45},"\"delivery_eur\"",[35,1647,1648],{"class":82},"]\n",[35,1650,1651],{"class":37,"line":299},[35,1652,96],{"emptyLinePlaceholder":95},[35,1654,1655,1657,1660,1663,1666,1669,1671,1674,1676,1679,1682,1684,1686],{"class":37,"line":352},[35,1656,366],{"class":78},[35,1658,1659],{"class":369}," score_low",[35,1661,1662],{"class":82},"(s):  ",[35,1664,1665],{"class":78},"return",[35,1667,1668],{"class":82}," (s.min() ",[35,1670,991],{"class":78},[35,1672,1673],{"class":82}," s ",[35,1675,559],{"class":78},[35,1677,1678],{"class":102}," 100",[35,1680,1681],{"class":82},").round(",[35,1683,477],{"class":102},[35,1685,577],{"class":82},[35,1687,1688],{"class":580},"# cheapest \u002F fastest = 100\n",[35,1690,1691,1693,1696,1699,1701,1704,1706,1709,1711,1713,1715,1717,1719],{"class":37,"line":358},[35,1692,366],{"class":78},[35,1694,1695],{"class":369}," score_high",[35,1697,1698],{"class":82},"(s): ",[35,1700,1665],{"class":78},[35,1702,1703],{"class":82}," (s ",[35,1705,991],{"class":78},[35,1707,1708],{"class":82}," s.max() ",[35,1710,559],{"class":78},[35,1712,1678],{"class":102},[35,1714,1681],{"class":82},[35,1716,477],{"class":102},[35,1718,577],{"class":82},[35,1720,1721],{"class":580},"# longest payment terms = 100\n",[35,1723,1724],{"class":37,"line":363},[35,1725,96],{"emptyLinePlaceholder":95},[35,1727,1728,1730,1733,1735,1737,1739,1741,1743,1745,1747,1749,1752,1754,1756],{"class":37,"line":376},[35,1729,1623],{"class":82},[35,1731,1732],{"class":45},"\"score\"",[35,1734,428],{"class":82},[35,1736,382],{"class":78},[35,1738,1293],{"class":82},[35,1740,1523],{"class":102},[35,1742,1242],{"class":82},[35,1744,177],{"class":45},[35,1746,428],{"class":82},[35,1748,559],{"class":78},[35,1750,1751],{"class":82}," score_low(summary[",[35,1753,1626],{"class":45},[35,1755,1318],{"class":82},[35,1757,1758],{"class":78},"+\n",[35,1760,1761,1764,1766,1768,1770,1772,1774,1776,1778],{"class":37,"line":419},[35,1762,1763],{"class":102},"                    WEIGHTS",[35,1765,1242],{"class":82},[35,1767,1539],{"class":45},[35,1769,428],{"class":82},[35,1771,559],{"class":78},[35,1773,1751],{"class":82},[35,1775,190],{"class":45},[35,1777,1318],{"class":82},[35,1779,1758],{"class":78},[35,1781,1782,1784,1786,1788,1790,1792,1795,1798,1801,1803],{"class":37,"line":436},[35,1783,1763],{"class":102},[35,1785,1242],{"class":82},[35,1787,1549],{"class":45},[35,1789,428],{"class":82},[35,1791,559],{"class":78},[35,1793,1794],{"class":82}," score_high(summary[",[35,1796,1797],{"class":45},"\"payment_days\"",[35,1799,1800],{"class":82},"])).round(",[35,1802,477],{"class":102},[35,1804,480],{"class":82},[35,1806,1807,1809,1812,1814,1816,1818,1820,1822,1824,1826,1828,1830,1832,1834,1837,1839,1841,1844,1846,1848],{"class":37,"line":460},[35,1808,636],{"class":102},[35,1810,1811],{"class":82},"(summary[[",[35,1813,1338],{"class":45},[35,1815,130],{"class":82},[35,1817,1645],{"class":45},[35,1819,130],{"class":82},[35,1821,1626],{"class":45},[35,1823,130],{"class":82},[35,1825,190],{"class":45},[35,1827,130],{"class":82},[35,1829,1797],{"class":45},[35,1831,130],{"class":82},[35,1833,1732],{"class":45},[35,1835,1836],{"class":82},"]].sort_values(",[35,1838,1732],{"class":45},[35,1840,130],{"class":82},[35,1842,1843],{"class":393},"ascending",[35,1845,382],{"class":78},[35,1847,680],{"class":102},[35,1849,1850],{"class":82},").to_string())\n",[10,1852,1853],{},"Each criterion is scaled so the best supplier scores 100 and the others proportionally less — lowest total and shortest lead time for \"low is good\", longest payment terms for \"high is good\" — then combined with the weights. The result is closer than the raw quotes suggest. Brightline has the lowest landed total, €2,738.76 against Acme's €2,895.00, and the best payment terms — but Acme delivers in 7 days instead of 14, and with lead time weighted at 25% that is enough for Acme to edge ahead, 89.3 to 87.5. Cobalt's minimum orders and delivery charge push its total to €3,686 and its score to 60.6. A two-point margin is a signal to look at the weights, not a verdict: if delivery speed matters less for this order, Brightline wins. Keeping the weights in one dictionary, and printing them in the report, makes the decision transparent; buyers can rerun with different weights in seconds to see whether the answer is sensitive to them.",[690,1855,699,1860,699,1863,699,1866,699,1869,699,1874,699,1880,699,1884,699,1889,699,1892,699,1896,699,1900,699,1905,699,1907,699,1911,699,1916],{"viewBox":1856,"role":693,"ariaLabelledBy":1857,"xmlns":697,"style":698},"4 0 736 140",[1858,1859],"sq-score-t","sq-score-d",[701,1861,1862],{"id":1858},"Weighted supplier scores",[705,1864,1865],{"id":1859},"Acme Supplies scores 89.3, helped by the fastest delivery. Brightline Ltd scores 87.5 with the lowest landed total and the longest payment terms but slower delivery. Cobalt Trading scores 60.6 because minimum orders and a delivery charge raise its total.",[709,1867],{"x":803,"y":521,"width":712,"height":1868,"fill":714},"140",[709,1870],{"x":1871,"y":1872,"width":860,"height":746,"rx":1873,"fill":721,"stroke":722,"style":723},"20.0","16.0","7",[729,1875,1879],{"x":1876,"y":1877,"style":1878},"82.0","34.5","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Acme Supplies",[709,1881],{"x":1882,"y":1872,"width":1883,"height":746,"rx":1873,"fill":835,"stroke":836,"style":818},"154.0","387.4",[729,1885,1888],{"x":1886,"y":1877,"style":1887},"549.4","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start","89.3 · fastest delivery",[709,1890],{"x":1871,"y":1891,"width":860,"height":746,"rx":1873,"fill":721,"stroke":722,"style":723},"56.0",[729,1893,1895],{"x":1876,"y":1894,"style":1878},"74.5","Brightline Ltd",[709,1897],{"x":1882,"y":1891,"width":1898,"height":746,"rx":1873,"fill":1899,"stroke":895,"style":818},"379.6","#f0f4ff",[729,1901,1904],{"x":1902,"y":1894,"style":1903},"541.6","font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:start","87.5 · lowest total, best terms",[709,1906],{"x":1871,"y":849,"width":860,"height":746,"rx":1873,"fill":721,"stroke":722,"style":723},[729,1908,1910],{"x":1876,"y":1909,"style":1878},"114.5","Cobalt Trading",[709,1912],{"x":1882,"y":849,"width":1913,"height":746,"rx":1873,"fill":1914,"stroke":1915,"style":818},"262.9","#fdefd8","var(--gold,#b4740a)",[729,1917,1920],{"x":1918,"y":1909,"style":1919},"424.9","font-size:12px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:start","60.6 · MOQ + delivery",[20,1922,1924],{"id":1923},"step-5-write-the-comparison-matrix","Step 5 — Write the comparison matrix",[10,1926,1927],{},"The buyer's view is a matrix: items down the side, suppliers across, the goods cost of the required quantity in each cell with the cheapest highlighted, and the summary underneath:",[25,1929,1931],{"className":69,"code":1930,"language":71,"meta":30,"style":30},"from openpyxl.formatting.rule import FormulaRule\nfrom openpyxl.styles import Font, PatternFill\n\nmatrix = priced.pivot_table(index=\"item\", columns=\"supplier\", values=\"goods_eur\", aggfunc=\"sum\")\nwith pd.ExcelWriter(\"supplier_comparison.xlsx\", engine=\"openpyxl\") as xw:\n    matrix.to_excel(xw, sheet_name=\"Comparison\", startrow=1)\n    summary.reset_index().to_excel(xw, sheet_name=\"Comparison\", startrow=len(matrix) + 5, index=False)\n    priced.to_excel(xw, sheet_name=\"Detail\", index=False)\n    pd.DataFrame(list(WEIGHTS.items()), columns=[\"criterion\", \"weight\"]).to_excel(xw, sheet_name=\"Weights\", index=False)\n    ws = xw.sheets[\"Comparison\"]\n    ws[\"A1\"] = \"Goods cost per item at required quantity (EUR)\"\n    ws[\"A1\"].font = Font(bold=True)\n    first, last = 3, 2 + len(matrix)\n    ws.conditional_formatting.add(f\"B{first}:D{last}\", FormulaRule(\n        formula=[f\"B{first}=MIN($B{first}:$D{first})\"], fill=PatternFill(\"solid\", fgColor=\"C6EFCE\")))\n    for col in \"ABCDEFGH\":\n        ws.column_dimensions[col].width = 16\nprint(matrix.round(2).to_string())\nprint(\"winner:\", summary[\"score\"].idxmax())\n",[32,1932,1933,1946,1958,1962,2006,2035,2059,2096,2118,2166,2180,2195,2218,2241,2278,2343,2358,2368,2379],{"__ignoreMap":30},[35,1934,1935,1938,1941,1943],{"class":37,"line":38},[35,1936,1937],{"class":78},"from",[35,1939,1940],{"class":82}," openpyxl.formatting.rule ",[35,1942,79],{"class":78},[35,1944,1945],{"class":82}," FormulaRule\n",[35,1947,1948,1950,1953,1955],{"class":37,"line":92},[35,1949,1937],{"class":78},[35,1951,1952],{"class":82}," openpyxl.styles ",[35,1954,79],{"class":78},[35,1956,1957],{"class":82}," Font, PatternFill\n",[35,1959,1960],{"class":37,"line":99},[35,1961,96],{"emptyLinePlaceholder":95},[35,1963,1964,1967,1969,1972,1974,1976,1978,1980,1982,1984,1986,1988,1991,1993,1995,1997,2000,2002,2004],{"class":37,"line":112},[35,1965,1966],{"class":82},"matrix ",[35,1968,382],{"class":78},[35,1970,1971],{"class":82}," priced.pivot_table(",[35,1973,675],{"class":393},[35,1975,382],{"class":78},[35,1977,157],{"class":45},[35,1979,130],{"class":82},[35,1981,394],{"class":393},[35,1983,382],{"class":78},[35,1985,425],{"class":45},[35,1987,130],{"class":82},[35,1989,1990],{"class":393},"values",[35,1992,382],{"class":78},[35,1994,1338],{"class":45},[35,1996,130],{"class":82},[35,1998,1999],{"class":393},"aggfunc",[35,2001,382],{"class":78},[35,2003,1589],{"class":45},[35,2005,480],{"class":82},[35,2007,2008,2011,2014,2017,2019,2022,2024,2027,2030,2032],{"class":37,"line":144},[35,2009,2010],{"class":78},"with",[35,2012,2013],{"class":82}," pd.ExcelWriter(",[35,2015,2016],{"class":45},"\"supplier_comparison.xlsx\"",[35,2018,130],{"class":82},[35,2020,2021],{"class":393},"engine",[35,2023,382],{"class":78},[35,2025,2026],{"class":45},"\"openpyxl\"",[35,2028,2029],{"class":82},") ",[35,2031,86],{"class":78},[35,2033,2034],{"class":82}," xw:\n",[35,2036,2037,2040,2043,2045,2048,2050,2053,2055,2057],{"class":37,"line":182},[35,2038,2039],{"class":82},"    matrix.to_excel(xw, ",[35,2041,2042],{"class":393},"sheet_name",[35,2044,382],{"class":78},[35,2046,2047],{"class":45},"\"Comparison\"",[35,2049,130],{"class":82},[35,2051,2052],{"class":393},"startrow",[35,2054,382],{"class":78},[35,2056,477],{"class":102},[35,2058,480],{"class":82},[35,2060,2061,2064,2066,2068,2070,2072,2074,2076,2079,2082,2084,2086,2088,2090,2092,2094],{"class":37,"line":196},[35,2062,2063],{"class":82},"    summary.reset_index().to_excel(xw, ",[35,2065,2042],{"class":393},[35,2067,382],{"class":78},[35,2069,2047],{"class":45},[35,2071,130],{"class":82},[35,2073,2052],{"class":393},[35,2075,382],{"class":78},[35,2077,2078],{"class":102},"len",[35,2080,2081],{"class":82},"(matrix) ",[35,2083,1640],{"class":78},[35,2085,568],{"class":102},[35,2087,130],{"class":82},[35,2089,675],{"class":393},[35,2091,382],{"class":78},[35,2093,680],{"class":102},[35,2095,480],{"class":82},[35,2097,2098,2101,2103,2105,2108,2110,2112,2114,2116],{"class":37,"line":222},[35,2099,2100],{"class":82},"    priced.to_excel(xw, ",[35,2102,2042],{"class":393},[35,2104,382],{"class":78},[35,2106,2107],{"class":45},"\"Detail\"",[35,2109,130],{"class":82},[35,2111,675],{"class":393},[35,2113,382],{"class":78},[35,2115,680],{"class":102},[35,2117,480],{"class":82},[35,2119,2120,2123,2125,2127,2129,2132,2134,2136,2138,2141,2143,2146,2149,2151,2153,2156,2158,2160,2162,2164],{"class":37,"line":258},[35,2121,2122],{"class":82},"    pd.DataFrame(",[35,2124,408],{"class":102},[35,2126,1299],{"class":82},[35,2128,1523],{"class":102},[35,2130,2131],{"class":82},".items()), ",[35,2133,394],{"class":393},[35,2135,382],{"class":78},[35,2137,1242],{"class":82},[35,2139,2140],{"class":45},"\"criterion\"",[35,2142,130],{"class":82},[35,2144,2145],{"class":45},"\"weight\"",[35,2147,2148],{"class":82},"]).to_excel(xw, ",[35,2150,2042],{"class":393},[35,2152,382],{"class":78},[35,2154,2155],{"class":45},"\"Weights\"",[35,2157,130],{"class":82},[35,2159,675],{"class":393},[35,2161,382],{"class":78},[35,2163,680],{"class":102},[35,2165,480],{"class":82},[35,2167,2168,2171,2173,2176,2178],{"class":37,"line":282},[35,2169,2170],{"class":82},"    ws ",[35,2172,382],{"class":78},[35,2174,2175],{"class":82}," xw.sheets[",[35,2177,2047],{"class":45},[35,2179,1648],{"class":82},[35,2181,2182,2185,2188,2190,2192],{"class":37,"line":299},[35,2183,2184],{"class":82},"    ws[",[35,2186,2187],{"class":45},"\"A1\"",[35,2189,428],{"class":82},[35,2191,382],{"class":78},[35,2193,2194],{"class":45}," \"Goods cost per item at required quantity (EUR)\"\n",[35,2196,2197,2199,2201,2204,2206,2209,2212,2214,2216],{"class":37,"line":352},[35,2198,2184],{"class":82},[35,2200,2187],{"class":45},[35,2202,2203],{"class":82},"].font ",[35,2205,382],{"class":78},[35,2207,2208],{"class":82}," Font(",[35,2210,2211],{"class":393},"bold",[35,2213,382],{"class":78},[35,2215,276],{"class":102},[35,2217,480],{"class":82},[35,2219,2220,2223,2225,2228,2230,2232,2235,2238],{"class":37,"line":358},[35,2221,2222],{"class":82},"    first, last ",[35,2224,382],{"class":78},[35,2226,2227],{"class":102}," 3",[35,2229,130],{"class":82},[35,2231,768],{"class":102},[35,2233,2234],{"class":78}," +",[35,2236,2237],{"class":102}," len",[35,2239,2240],{"class":82},"(matrix)\n",[35,2242,2243,2246,2249,2252,2256,2259,2262,2265,2267,2270,2272,2275],{"class":37,"line":363},[35,2244,2245],{"class":82},"    ws.conditional_formatting.add(",[35,2247,2248],{"class":78},"f",[35,2250,2251],{"class":45},"\"B",[35,2253,2255],{"class":2254},"sSjpA","{",[35,2257,2258],{"class":82},"first",[35,2260,2261],{"class":2254},"}",[35,2263,2264],{"class":45},":D",[35,2266,2255],{"class":2254},[35,2268,2269],{"class":82},"last",[35,2271,2261],{"class":2254},[35,2273,2274],{"class":45},"\"",[35,2276,2277],{"class":82},", FormulaRule(\n",[35,2279,2280,2283,2285,2287,2289,2291,2293,2295,2297,2300,2302,2304,2306,2309,2311,2313,2315,2318,2320,2323,2325,2328,2331,2333,2336,2338,2341],{"class":37,"line":376},[35,2281,2282],{"class":393},"        formula",[35,2284,382],{"class":78},[35,2286,1242],{"class":82},[35,2288,2248],{"class":78},[35,2290,2251],{"class":45},[35,2292,2255],{"class":2254},[35,2294,2258],{"class":82},[35,2296,2261],{"class":2254},[35,2298,2299],{"class":45},"=MIN($B",[35,2301,2255],{"class":2254},[35,2303,2258],{"class":82},[35,2305,2261],{"class":2254},[35,2307,2308],{"class":45},":$D",[35,2310,2255],{"class":2254},[35,2312,2258],{"class":82},[35,2314,2261],{"class":2254},[35,2316,2317],{"class":45},")\"",[35,2319,1119],{"class":82},[35,2321,2322],{"class":393},"fill",[35,2324,382],{"class":78},[35,2326,2327],{"class":82},"PatternFill(",[35,2329,2330],{"class":45},"\"solid\"",[35,2332,130],{"class":82},[35,2334,2335],{"class":393},"fgColor",[35,2337,382],{"class":78},[35,2339,2340],{"class":45},"\"C6EFCE\"",[35,2342,1618],{"class":82},[35,2344,2345,2347,2350,2352,2355],{"class":37,"line":419},[35,2346,1209],{"class":78},[35,2348,2349],{"class":82}," col ",[35,2351,615],{"class":78},[35,2353,2354],{"class":45}," \"ABCDEFGH\"",[35,2356,2357],{"class":82},":\n",[35,2359,2360,2363,2365],{"class":37,"line":436},[35,2361,2362],{"class":82},"        ws.column_dimensions[col].width ",[35,2364,382],{"class":78},[35,2366,2367],{"class":102}," 16\n",[35,2369,2370,2372,2375,2377],{"class":37,"line":460},[35,2371,636],{"class":102},[35,2373,2374],{"class":82},"(matrix.round(",[35,2376,768],{"class":102},[35,2378,1850],{"class":82},[35,2380,2381,2383,2385,2388,2391,2393],{"class":37,"line":483},[35,2382,636],{"class":102},[35,2384,1299],{"class":82},[35,2386,2387],{"class":45},"\"winner:\"",[35,2389,2390],{"class":82},", summary[",[35,2392,1732],{"class":45},[35,2394,2395],{"class":82},"].idxmax())\n",[10,2397,2398,2399,18],{},"The conditional format compares each cell with the minimum of its row, so the cheapest supplier for each item is shaded green — and it stays correct if a buyer edits a figure. The summary with totals, terms and scores sits below, and the Detail and Weights sheets document how every number was reached. Highlighting per row this way is covered in ",[14,2400,2402],{"href":2401},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-top-and-bottom-values-with-openpyxl\u002F","Highlight Top and Bottom Values with openpyxl",[690,2404,699,2410,699,2413,699,2416,699,2420,699,2424,699,2435,699,2441,699,2446,699,2451,699,2454,699,2457,699,2464,699,2469,699,2472,699,2476,699,2479,699,2482,699,2489,699,2495,699,2499,699,2503,699,2507,699,2510,699,2513,699,2520,699,2525,699,2528],{"viewBox":2405,"role":693,"ariaLabelledBy":2406,"xmlns":697,"style":2409},"4 0 752 142",[2407,2408],"sq-flow-t","sq-flow-d","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[701,2411,2412],{"id":2407},"From quotes to a decision",[705,2414,2415],{"id":2408},"Each quote is mapped to a common schema, prices are converted to euros per unit, the right price break is applied at the required quantity with minimum orders and pack rounding, delivery and terms are added, and suppliers are scored with agreed weights.",[709,2417],{"x":803,"y":521,"width":2418,"height":2419,"fill":714},"752","142",[709,2421],{"x":1871,"y":1872,"width":2422,"height":2423,"rx":711,"fill":721,"stroke":722,"style":896},"153.0","110.5",[2425,2426,2428],"g",{"transform":2427},"translate(84.5,37.8) scale(1.000)",[2429,2430],"path",{"d":2431,"fill":2432,"stroke":2433,"style":2434},"M3 5h18v14H3z M3 10h18 M3 15h18 M9 5v14","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[729,2436,2440],{"x":2437,"y":2438,"style":2439},"96.5","84.2","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Map columns",[729,2442,2445],{"x":2437,"y":2443,"style":2444},"103.8","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","one entry per supplier",[37,2447],{"x1":2448,"y1":2449,"x2":2450,"y2":2449,"stroke":895,"style":896},"178.0","71.2","200.0",[898,2452],{"points":2453,"fill":901},"204.0,71.2 195.0,76.2 195.0,66.2",[709,2455],{"x":2456,"y":1872,"width":2422,"height":2423,"rx":711,"fill":1899,"stroke":895,"style":896},"209.0",[2425,2458,2460],{"transform":2459},"translate(273.5,37.8) scale(1.000)",[2429,2461],{"d":2462,"fill":2432,"stroke":2463,"style":2434},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7","var(--brand-strong,#4338ca)",[729,2465,2468],{"x":2466,"y":2438,"style":2467},"285.5","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Normalise price",[729,2470,2471],{"x":2466,"y":2443,"style":2444},"EUR per unit",[37,2473],{"x1":2474,"y1":2449,"x2":2475,"y2":2449,"stroke":895,"style":896},"367.0","389.0",[898,2477],{"points":2478,"fill":901},"393.0,71.2 384.0,76.2 384.0,66.2",[709,2480],{"x":2481,"y":1872,"width":2422,"height":2423,"rx":711,"fill":835,"stroke":836,"style":896},"398.0",[2425,2483,2485],{"transform":2484},"translate(462.5,29.0) scale(1.000)",[2429,2486],{"d":2487,"fill":2432,"stroke":2488,"style":2434},"M12 3l9 5-9 5-9-5z M3 13l9 5 9-5 M3 17l9 5 9-5","var(--teal-ink,#0b6157)",[729,2490,2494],{"x":2491,"y":2492,"style":2493},"474.5","75.5","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Price breaks",[729,2496,2498],{"x":2491,"y":2497,"style":2444},"95.0","merge_asof, MOQ,",[729,2500,2502],{"x":2491,"y":2501,"style":2444},"112.5","packs",[37,2504],{"x1":2505,"y1":2449,"x2":2506,"y2":2449,"stroke":895,"style":896},"556.0","578.0",[898,2508],{"points":2509,"fill":901},"582.0,71.2 573.0,76.2 573.0,66.2",[709,2511],{"x":2512,"y":1872,"width":2422,"height":2423,"rx":711,"fill":1914,"stroke":1915,"style":896},"587.0",[2425,2514,2516],{"transform":2515},"translate(651.5,29.0) scale(1.000)",[2429,2517],{"d":2518,"fill":2432,"stroke":2519,"style":2434},"M4 20h16 M7 17v-5 M12 17V7 M17 17v-8","var(--gold-ink,#7a4e06)",[729,2521,2524],{"x":2522,"y":2492,"style":2523},"663.5","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Score",[729,2526,2527],{"x":2522,"y":2497,"style":2444},"price 60 · lead 25 ·",[729,2529,2530],{"x":2522,"y":2501,"style":2444},"terms 15",[20,2532,2534],{"id":2533},"common-pitfalls","Common pitfalls",[2536,2537,2538,2546,2552,2558,2564],"ul",{},[2539,2540,2541,2545],"li",{},[2542,2543,2544],"strong",{},"Comparing per-pack with per-unit prices."," Divide by pack size before comparing.",[2539,2547,2548,2551],{},[2542,2549,2550],{},"Ignoring minimum order quantities."," The price you pay is for what you must order.",[2539,2553,2554,2557],{},[2542,2555,2556],{},"One merge_asof across suppliers."," Match price breaks per supplier, or suppliers disappear.",[2539,2559,2560,2563],{},[2542,2561,2562],{},"Price-only decisions."," Weigh lead time and payment terms explicitly — here the cheapest supplier did not win.",[2539,2565,2566,2569],{},[2542,2567,2568],{},"Hidden weights."," Put the weights in the report so the choice can be reviewed.",[20,2571,2573],{"id":2572},"conclusion","Conclusion",[10,2575,2576,2577,2579],{},"Comparing supplier quotes in Python is mostly normalisation: map each supplier's columns to a common schema, convert prices to one currency per single unit, apply quantity price breaks per supplier with ",[32,2578,1082],{},", and round up to whole packs and minimum orders. Add delivery charges and payment terms, score price, lead time and terms with explicit weights, and write a comparison matrix that highlights the cheapest option per item, with the detail and weights alongside.",[20,2581,2583],{"id":2582},"frequently-asked-questions","Frequently asked questions",[10,2585,2586,2589],{},[2542,2587,2588],{},"How do I compare supplier quotes in different Excel formats?","\nDefine a column mapping per supplier, rename each quote's columns to a common schema with neutral defaults for missing fields, and concatenate them into one DataFrame.",[10,2591,2592,2595],{},[2542,2593,2594],{},"How do I apply quantity price breaks in pandas?","\nSort the tiers by starting quantity and use merge_asof with direction backward, matching each supplier separately, so each order quantity gets the last tier at or below it.",[10,2597,2598,2601],{},[2542,2599,2600],{},"How do I account for minimum order quantities?","\nTake the larger of the needed quantity and the minimum order, round it up to whole packs, and price that quantity, since that is what will be invoiced.",[10,2603,2604,2607],{},[2542,2605,2606],{},"How do I score suppliers on more than price?","\nScale each criterion so the best supplier scores 100, such as lowest total cost or longest payment terms, and combine the scores with agreed weights.",[20,2609,2611],{"id":2610},"related","Related",[2536,2613,2614,2620,2627,2634],{},[2539,2615,2616,2617,2619],{},"Up one level: ",[14,2618,17],{"href":16}," — finance and operations reports in Excel.",[2539,2621,2622,2626],{},[14,2623,2625],{"href":2624},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fconvert-multi-currency-transactions-with-an-fx-rate-table\u002F","Convert Multi-Currency Transactions with an FX Rate Table"," — currency conversion done properly.",[2539,2628,2629,2633],{},[14,2630,2632],{"href":2631},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fbuild-an-inventory-reorder-report\u002F","Build an Inventory Reorder Report"," — what to order before asking for quotes.",[2539,2635,2636,2640],{},[14,2637,2639],{"href":2638},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Freorder-columns-to-match-a-template-layout-with-pandas\u002F","Reorder Columns to Match a Template Layout with pandas"," — mapping messy column layouts.",[2642,2643,2644],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":30,"searchDepth":92,"depth":92,"links":2646},[2647,2648,2649,2650,2651,2652,2653,2654,2655,2656],{"id":22,"depth":92,"text":23},{"id":62,"depth":92,"text":63},{"id":918,"depth":92,"text":919},{"id":1075,"depth":92,"text":1076},{"id":1429,"depth":92,"text":1430},{"id":1923,"depth":92,"text":1924},{"id":2533,"depth":92,"text":2534},{"id":2572,"depth":92,"text":2573},{"id":2582,"depth":92,"text":2583},{"id":2610,"depth":92,"text":2611},"2026-10-02","Compare supplier quotes that arrive as differently laid-out Excel files: map each supplier's columns to one schema, convert currencies and pack sizes to a per-unit price, apply quantity price breaks with merge_asof, add delivery and minimum-order rules, score price, lead time and payment terms with weights, and write a comparison matrix that highlights the best offer per item.","md",[2661,2663,2665,2667],{"q":2588,"a":2662},"Define a column mapping per supplier, rename each quote's columns to a common schema with neutral defaults for missing fields, and concatenate them into one DataFrame.",{"q":2594,"a":2664},"Sort the tiers by starting quantity and use merge_asof with direction backward, matching each supplier separately, so each order quantity gets the last tier at or below it.",{"q":2600,"a":2666},"Take the larger of the needed quantity and the minimum order, round it up to whole packs, and price that quantity, since that is what will be invoiced.",{"q":2606,"a":2668},"Scale each criterion so the best supplier scores 100, such as lowest total cost or longest payment terms, and combine the scores with agreed weights.","compare supplier quotes with python",null,{"breadcrumb":2672},[2673,2675,2678],{"name":2674,"item":991},"Home",{"name":2676,"item":2677},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":2679,"item":16},"Building Finance and Operations Reports in Excel with Python","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcompare-supplier-quotes-with-python",{"title":2682,"description":2683},"Compare Supplier Quotes from Excel Files with Python","Normalise supplier quote workbooks with pandas: column mapping, currency and pack-size conversion, tiered price breaks with merge_asof, MOQ and delivery costs, weighted scoring and an Excel comparison matrix.","compare-supplier-quotes-with-python","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcompare-supplier-quotes-with-python\u002Findex",[2687,2688],"report-layouts","joins-and-lookups","how-to","7wbd2jbrQ0-xCNw3mkgthQVd_5af__R-LFrornTiItc",[2692,2696],{"title":2693,"path":2694,"stem":2695,"children":-1},"Check Supplier Invoices Against Contract Prices","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcheck-supplier-invoices-against-contract-prices","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fcheck-supplier-invoices-against-contract-prices\u002Findex",{"title":2697,"path":2698,"stem":2699,"children":-1},"Consolidate Employee Expense Claims into a Report","\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fconsolidate-employee-expense-claims-into-a-report","automating-reporting-workflows\u002Fbuilding-finance-and-operations-reports\u002Fconsolidate-employee-expense-claims-into-a-report\u002Findex",[2701,2703],{"path":2702,"title":2679},"\u002Fautomating-reporting-workflows\u002Fbuilding-finance-and-operations-reports",{"path":2704,"title":2676},"\u002Fautomating-reporting-workflows",[],[2707,2711,2714,2717,2720,2723],{"path":2708,"title":2709,"kind":2710},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards","Building Multi-Sheet Excel Dashboards","guide",{"path":2712,"title":2713,"kind":2689},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-data-dictionary-sheet-to-an-excel-report","Add a Data Dictionary Sheet to an Excel Report",{"path":2715,"title":2716,"kind":2689},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-kpi-summary-block-to-an-excel-dashboard","Add a KPI Summary Block to an Excel Dashboard",{"path":2718,"title":2719,"kind":2689},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-a-table-of-contents-sheet-with-hyperlinks-in-excel","Add a Table of Contents Sheet with Hyperlinks in Excel",{"path":2721,"title":2722,"kind":2689},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-slicer-style-filters-to-an-excel-dashboard","Add a Dropdown Filter That Drives an Excel Dashboard",{"path":2724,"title":2725,"kind":2689},"\u002Fautomating-reporting-workflows\u002Fbuilding-multi-sheet-excel-dashboards\u002Fadd-summary-sheet-to-excel-report-python","Add a Summary Sheet to an Excel Report with Python",1790972127918]