diff --git a/westech_r2/__pycache__/__init__.cpython-312.pyc b/westech_r2/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..0b2fbe3 Binary files /dev/null and b/westech_r2/__pycache__/__init__.cpython-312.pyc differ diff --git a/westech_r2/__pycache__/hooks.cpython-312.pyc b/westech_r2/__pycache__/hooks.cpython-312.pyc new file mode 100644 index 0000000..c15a8e4 Binary files /dev/null and b/westech_r2/__pycache__/hooks.cpython-312.pyc differ diff --git a/westech_r2/api/scoring.py b/westech_r2/api/scoring.py new file mode 100644 index 0000000..68fa015 --- /dev/null +++ b/westech_r2/api/scoring.py @@ -0,0 +1,244 @@ +import frappe +import re +from frappe.utils import now, now_datetime + +def get_pricing_config(): + if not frappe.db.exists("Pricing Score Config", "Pricing Score Config"): + return None + return frappe.get_doc("Pricing Score Config", "Pricing Score Config") + +def parse_ram(ram_str): + if not ram_str: + return 0 + m = re.search(r'(\d+(?:\.\d+)?)', str(ram_str)) + if m: + return int(float(m.group(1))) + return 0 + +def parse_cpu(cpu_str): + if not cpu_str: + return {"brand": "", "series": "", "gen": 0} + cpu = str(cpu_str).lower() + result = {"brand": "", "series": "", "gen": 0} + if "intel" in cpu or "core" in cpu or "i3" in cpu or "i5" in cpu or "i7" in cpu or "i9" in cpu or "xeon" in cpu: + result["brand"] = "intel" + elif "ryzen" in cpu or "athlon" in cpu or "amd" in cpu: + result["brand"] = "amd" + elif "apple" in cpu or "m1" in cpu or "m2" in cpu or "m3" in cpu: + result["brand"] = "apple" + series_match = re.search(r'(i[3579])', cpu) + if series_match: + result["series"] = series_match.group(1) + elif "xeon" in cpu: + result["series"] = "xeon" + elif "ryzen" in cpu: + ryz_match = re.search(r'ryzen\s+(\d+)', cpu) + if ryz_match: + result["series"] = f"r{ryz_match.group(1)}" + elif "m1" in cpu: + result["series"] = "m1" + elif "m2" in cpu: + result["series"] = "m2" + elif "m3" in cpu: + result["series"] = "m3" + gen_match = re.search(r'(?:i[3579]-|core\s+i[3579]\s+)(\d)', cpu) + if gen_match: + first_digit = int(gen_match.group(1)) + if first_digit == 1: + gen_match2 = re.search(r'(?:i[3579]-|core\s+i[3579]\s+)1(\d)', cpu) + if gen_match2: + result["gen"] = 10 + int(gen_match2.group(1)) + else: + result["gen"] = 1 + else: + result["gen"] = first_digit + gen_match3 = re.search(r'(?:i[3579]-)(\d{4})', cpu) + if gen_match3: + model = gen_match3.group(1) + first_digit = int(model[0]) + if first_digit == 1: + result["gen"] = 10 + int(model[1]) + else: + result["gen"] = first_digit + return result + +@frappe.whitelist() +def calculate_serial_score(serial_no): + serial = frappe.get_doc("Serial No", serial_no) + config = get_pricing_config() + if not config: + return {"error": "No Pricing Score Config found"} + + score = 0.0 + details = [] + + cos_grade = serial.get("cosmetic_grade", "") + if cos_grade: + grade_match = re.search(r'[Cc](\d+)', str(cos_grade)) + if grade_match: + g = int(grade_match.group(1)) + if g <= 2: + return {"status": "scrap", "reason": f"Cosmetic C{g} - below minimum"} + base = getattr(config, f"c{g}_base", 0) + score += base + details.append(f"Cosmetic C{g} = +{base}") + + cpu_info = parse_cpu(serial.get("processor", "")) + if cpu_info["series"] in ("i7", "r7"): + score += config.i7_bonus + details.append(f"CPU {cpu_info['series'].upper()} = +{config.i7_bonus}") + elif cpu_info["series"] in ("i9", "r9"): + score += config.i9_bonus + details.append(f"CPU {cpu_info['series'].upper()} = +{config.i9_bonus}") + + gen = cpu_info["gen"] + gen_bonus = 0 + if gen == 10: gen_bonus = config.gen_10_bonus + elif gen == 11: gen_bonus = config.gen_11_bonus + elif gen == 12: gen_bonus = config.gen_12_bonus + elif gen == 13: gen_bonus = config.gen_13_bonus + elif gen == 14: gen_bonus = config.gen_14_bonus + if gen_bonus > 0: + score += gen_bonus + details.append(f"Gen {gen} = +{gen_bonus}") + + ram_gb = parse_ram(serial.get("ram", "")) + if ram_gb >= 16: + score += config.ram_16_bonus + details.append(f"RAM {ram_gb}GB = +{config.ram_16_bonus}") + if ram_gb >= 32: + score += config.ram_32_bonus + details.append(f"RAM {ram_gb}GB = +{config.ram_32_bonus}") + if ram_gb > 32: + details.append(f"WARNING: {ram_gb}GB exceeds laptop/desktop norm") + + if score >= config.medium_threshold: + tier = "High" + elif score >= config.low_threshold: + tier = "Medium" + else: + tier = "Low" + + market = {"low": 0, "median": 0, "high": 0} + age_days = 0 + age_status = "unknown" + + if serial.item_code: + item = frappe.get_doc("Item", serial.item_code) + market = { + "low": item.market_low or item.base_market_price or 0, + "median": item.market_median or item.base_market_price or 0, + "high": item.market_high or item.base_market_price or 0, + } + if item.market_last_priced: + age_days = (now_datetime() - item.market_last_priced).days + if age_days <= 30: age_status = "current" + elif age_days <= 60: age_status = "stale" + elif age_days <= 90: age_status = "aging" + else: age_status = "expired" + + suggested_price = market["high"] if tier == "High" else market["median"] if tier == "Medium" else market["low"] + + serial.desirability_score = round(score, 1) + serial.suggested_tier = tier + serial.suggested_price = suggested_price + serial.save() + frappe.db.commit() + + return { + "status": "ok", + "serial_no": serial_no, + "score": round(score, 1), + "tier": tier, + "details": details, + "market_prices": market, + "suggested_price": suggested_price, + "age_days": age_days, + "age_status": age_status, + } + +@frappe.whitelist() +def batch_calculate_scores(batch_size=100): + serials = frappe.get_all("Serial No", + filters={"cosmetic_grade": ["is", "set"]}, + fields=["name"], + limit=int(batch_size) + ) + results = {"updated": 0, "scrap": 0, "errors": 0} + for s in serials: + try: + result = calculate_serial_score(s.name) + if result.get("status") == "scrap": + results["scrap"] += 1 + else: + results["updated"] += 1 + except Exception as e: + results["errors"] += 1 + frappe.log_error(f"Score calc error for {s.name}: {e}") + return results + +@frappe.whitelist() +def get_sales_pricing_data(limit=50): + """Get pricing data for Sales Manager page.""" + config = get_pricing_config() + if not config: + return {"error": "No Pricing Score Config"} + + serials = frappe.get_all("Serial No", + filters={"cosmetic_grade": ["is", "set"]}, + fields=["name", "serial_no", "item_code", "item_name", "cosmetic_grade", "processor", "ram", "desirability_score", "suggested_tier", "suggested_price", "assigned_price", "pricing_status"], + limit=int(limit), + order_by="modified desc" + ) + + results = [] + for s in serials: + age = {"days": 0, "status": "unknown", "color": "gray"} + market = {"low": 0, "median": 0, "high": 0} + + if s.item_code: + item = frappe.get_doc("Item", s.item_code) + market = { + "low": item.market_low or 0, + "median": item.market_median or 0, + "high": item.market_high or 0, + } + if item.market_last_priced: + age_days = (now_datetime() - item.market_last_priced).days + age["days"] = age_days + if age_days <= 30: + age["status"] = "current" + age["color"] = "green" + elif age_days <= 60: + age["status"] = "stale" + age["color"] = "yellow" + elif age_days <= 90: + age["status"] = "aging" + age["color"] = "orange" + else: + age["status"] = "expired" + age["color"] = "red" + + results.append({ + "serial_no": s.name, + "item_code": s.item_code, + "item_name": s.item_name, + "cosmetic_grade": s.cosmetic_grade, + "processor": s.processor, + "ram": s.ram, + "score": s.desirability_score, + "tier": s.suggested_tier, + "market": market, + "suggested_price": s.suggested_price, + "assigned_price": s.assigned_price, + "pricing_status": s.pricing_status, + "age": age, + }) + + return { + "serials": results, + "config": { + "low_threshold": config.low_threshold, + "medium_threshold": config.medium_threshold, + } + } diff --git a/westech_r2/page/sales-manager/sales-manager.html b/westech_r2/page/sales-manager/sales-manager.html new file mode 100644 index 0000000..e37f258 --- /dev/null +++ b/westech_r2/page/sales-manager/sales-manager.html @@ -0,0 +1,83 @@ +
Review suggested prices, adjust tiers, and set final prices
+0-30 days
+30-60 days
+60-90 days
+90+ days
+| Serial | +Item | +Grade | +Specs | +Score | +Tier | +Market Range | +Suggested | +Age | +Final Price | +Actions | +
|---|