giva-discovery / src /quote.py
Gautham98's picture
GIVA Discovery: full app + vectors
73d02ec verified
Raw
History Blame Contribute Delete
8.93 kB
"""
quote.py — rate card + quote math (ported from sketch-to-quote src/lib/pricing.js).
Every cost-side number is measured off real gold POs (guts.vendors_pos_poskus,
trailing 120-180 days), not assumed. Customer side: gold at the live rate,
diamonds at a flat Rs.55,000/ct, making Rs.900/g (calibrated: solving
listed_price/1.03 = gold + making*gm + 55000*ct across live-priced BOM SKUs fit
best at 900/g, median abs error 8.5%).
The quote is built on the DESIGNED piece's spec (goldGm + stone lines), not on
the matched catalogue SKU.
"""
from sieve import SIEVE_TABLE, sieve_by_name, sieve_for_ct
DEFAULT_RATES = {
"gold24": 14450, # fine 24K Rs/g; replaced at runtime by live rate
"ktPurity": {9: 0.375, 14: 0.585, 18: 0.75},
"labourBase": {9: 718, 14: 820, 18: 872},
"labourMult": [
{"maxPcs": 0, "m": 1.9},
{"maxPcs": 3, "m": 1.31},
{"maxPcs": 10, "m": 1.0},
{"maxPcs": 20, "m": 1.36},
{"maxPcs": 40, "m": 1.82},
{"maxPcs": float("inf"), "m": 3.1},
],
"diaCostBands": [
{"maxCps": 0.004, "rate": 12900},
{"maxCps": 0.007, "rate": 11850},
{"maxCps": 0.012, "rate": 8250},
{"maxCps": 0.02, "rate": 6720},
{"maxCps": 0.05, "rate": 6320},
{"maxCps": 0.15, "rate": 7510},
{"maxCps": 0.40, "rate": 8560},
{"maxCps": float("inf"), "rate": 9990},
],
"hallmarkPerPc": 45,
"wastagePct": 2,
"making": 900,
"diaRetailPerCt": 55000,
"gstPct": 3,
}
PLACEHOLDER_PRICES = {9769}
def real_price(p):
try:
v = float(p)
except (TypeError, ValueError):
return None
return v if v > 0 and round(v) not in PLACEHOLDER_PRICES else None
def purity_for(kt, rates=DEFAULT_RATES):
return rates["ktPurity"].get(kt, 0.585)
def gold_rate_per_g(kt, rates=DEFAULT_RATES):
return rates["gold24"] * purity_for(kt, rates)
def dia_cost_rate(ct_per_stone, rates=DEFAULT_RATES):
cps = float(ct_per_stone or 0)
for b in rates["diaCostBands"]:
if cps <= b["maxCps"]:
return b["rate"]
return rates["diaCostBands"][-1]["rate"]
def labour_per_g(kt, pcs, rates=DEFAULT_RATES):
base = rates["labourBase"].get(kt, rates["labourBase"][14])
n = float(pcs or 0)
band = next((b for b in rates["labourMult"] if n <= b["maxPcs"]), {"m": 1})
return base * band["m"]
def ct_for_sieve(sieve):
hit = sieve_by_name(sieve)
return hit["ct"] if hit else 0.01
def normalize_lines(lines):
"""Rebuild line carats from pcs x sieve, so an edited stone count flows through."""
out = []
for l in (lines or []):
if float(l.get("pcs") or 0) <= 0:
continue
cps = float(l.get("ctPerStone") or ct_for_sieve(l.get("sieve")))
pcs = max(0, round(float(l.get("pcs") or 0)))
out.append({**l, "pcs": pcs, "ctPerStone": cps,
"ct": round(pcs * cps, 3)})
return out
def total_pcs(lines):
return sum(l["pcs"] for l in normalize_lines(lines))
def total_ct(lines):
return round(sum(l["ct"] for l in normalize_lines(lines)), 3)
def compute_quote(spec, kt, rates=DEFAULT_RATES):
"""spec: {goldGm, lines, units, category} — the DESIGNED piece."""
if not spec or not (float(spec.get("goldGm") or 0) > 0):
return None
gm = float(spec["goldGm"])
lines = normalize_lines(spec.get("lines"))
pcs = sum(l["pcs"] for l in lines)
ct = sum(l["ct"] for l in lines)
units = max(1, int(spec.get("units") or 1))
gold_rate_g = gold_rate_per_g(kt, rates)
gold_cost = gm * gold_rate_g
wastage = gold_cost * (rates["wastagePct"] / 100)
lab_rate = labour_per_g(kt, pcs, rates)
labour = gm * lab_rate
dia_lines = [{**l, "rate": dia_cost_rate(l["ctPerStone"], rates),
"cost": l["ct"] * dia_cost_rate(l["ctPerStone"], rates)} for l in lines]
dia_cost = sum(l["cost"] for l in dia_lines)
hallmark = rates["hallmarkPerPc"] * units
cogs = gold_cost + wastage + labour + dia_cost + hallmark
making = gm * rates["making"]
dia_retail = ct * rates["diaRetailPerCt"]
price_ex_gst = gold_cost + making + dia_retail
price = price_ex_gst * (1 + rates["gstPct"] / 100)
margin = price_ex_gst - cogs
return {
"kt": kt, "gm": gm, "pcs": pcs, "ct": ct, "units": units, "lines": lines,
"goldRateG": gold_rate_g, "goldCost": gold_cost, "wastage": wastage,
"labRate": lab_rate, "labour": labour, "diaLines": dia_lines,
"diaCost": dia_cost, "hallmark": hallmark, "cogs": cogs,
"making": making, "diaRetail": dia_retail, "priceExGst": price_ex_gst,
"price": price, "margin": margin,
"marginPct": (margin / price_ex_gst * 100) if price_ex_gst else 0,
}
def _gm_for_price(target, spec, kt, rates):
"""Gold grams that land exactly on a target all-in price, stack fixed."""
ct = total_ct(spec.get("lines"))
per_g = gold_rate_per_g(kt, rates) + rates["making"]
fixed = ct * rates["diaRetailPerCt"]
gm = (target / (1 + rates["gstPct"] / 100) - fixed) / per_g
return round(gm, 2) if gm > 0.05 else None
def _shrink_stones(lines):
out = []
for l in normalize_lines(lines):
i = next((k for k, s in enumerate(SIEVE_TABLE) if s["sieve"] == l["sieve"]), 0)
nxt = SIEVE_TABLE[i - 1] if i > 0 else SIEVE_TABLE[0]
out.append({**l, "sieve": nxt["sieve"], "mm": nxt["mm"], "ctPerStone": nxt["ct"]})
return out
def budget_levers(spec, kt, rates, budget):
"""Concrete, fully-costed ways to hit a customer's number."""
if not spec or not budget or not (budget > 0):
return []
now = compute_quote(spec, kt, rates)
if not now:
return []
gap = now["price"] - budget
out = []
def add(label, s, k, note):
q = compute_quote(s, k, rates)
if not q:
return
out.append({"label": label, "note": note, "kt": k, "spec": s,
"price": q["price"], "quote": q,
"fits": q["price"] <= budget * 1.02,
"delta": q["price"] - now["price"]})
if gap > 0:
for k in (14, 9):
if k < kt:
add(f"{k}KT instead of {kt}KT", spec, k, "same design, lighter alloy value")
gm = _gm_for_price(budget, spec, kt, rates)
if gm and gm < spec["goldGm"] * 0.98:
add(f"Slimmer build — {gm} g of gold", {**spec, "goldGm": gm}, kt,
f"{round((1 - gm / spec['goldGm']) * 100)}% less metal: thinner band or hollow section")
shrunk = _shrink_stones(spec.get("lines"))
if total_ct(shrunk) < total_ct(spec.get("lines")):
add("One sieve smaller on every stone", {**spec, "lines": shrunk}, kt,
f"{total_ct(spec.get('lines')):.3f} to {total_ct(shrunk):.3f} ct, same stone count")
combo = {**spec, "lines": shrunk}
combo_kt = 14 if kt > 14 else 9
combo_gm = _gm_for_price(budget, combo, combo_kt, rates)
if combo_gm:
add(f"{combo_kt}KT + smaller stones + {combo_gm} g",
{**combo, "goldGm": combo_gm}, combo_kt, "stacked changes to land on budget exactly")
else:
gm = _gm_for_price(budget, spec, kt, rates)
if gm and gm > spec["goldGm"] * 1.02:
add(f"Heavier build — {gm} g of gold", {**spec, "goldGm": gm}, kt,
"more substantial piece within the same budget")
if kt < 18:
add("Upgrade to 18KT", spec, 18, "richer colour, higher resale value")
bigger = []
for l in normalize_lines(spec.get("lines")):
i = next((k for k, s in enumerate(SIEVE_TABLE) if s["sieve"] == l["sieve"]), 0)
nxt = SIEVE_TABLE[min(len(SIEVE_TABLE) - 1, i + 1)]
bigger.append({**l, "sieve": nxt["sieve"], "mm": nxt["mm"], "ctPerStone": nxt["ct"]})
add("One sieve larger on every stone", {**spec, "lines": bigger}, kt,
f"{total_ct(spec.get('lines')):.3f} to {total_ct(bigger):.3f} ct")
seen, uniq = set(), []
for o in out:
key = round(o["price"])
if key not in seen:
seen.add(key)
uniq.append(o)
uniq.sort(key=lambda o: abs(o["price"] - budget))
return uniq[:4]
def estimate_timeline(spec):
pcs = total_pcs((spec or {}).get("lines")) or 0
extra = 1 if pcs > 15 else 0
stages = [
{"name": "Casting & goldwork", "lo": 5, "hi": 7},
{"name": "Stone setting & polish", "lo": 4 + extra, "hi": 6 + extra},
{"name": "QC & IGI certification", "lo": 2, "hi": 3},
{"name": "Dispatch & delivery", "lo": 4, "hi": 7},
]
return {"stages": stages,
"lo": sum(s["lo"] for s in stages),
"hi": sum(s["hi"] for s in stages)}
def inr(v, digits=0):
if v is None:
return "—"
return "₹" + f"{float(v):,.{digits}f}"