Spaces:
Sleeping
Sleeping
| """ | |
| 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}" | |