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 @@ +
+ + +
+
+
+
+
Current
+

0-30 days

+
+
+
+
+
+
+
Stale
+

30-60 days

+
+
+
+
+
+
+
Aging
+

60-90 days

+
+
+
+
+
+
+
Expired
+

90+ days

+
+
+
+
+ +
+
+
+ + + + + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + +
SerialItemGradeSpecsScoreTierMarket RangeSuggestedAgeFinal PriceActions
+
+ + +
diff --git a/westech_r2/page/sales-manager/sales-manager.js b/westech_r2/page/sales-manager/sales-manager.js new file mode 100644 index 0000000..ec9cd1e --- /dev/null +++ b/westech_r2/page/sales-manager/sales-manager.js @@ -0,0 +1,125 @@ +frappe.pages['sales-manager'].on_page_load = function(wrapper) { + var page = frappe.ui.make_app_page({ + parent: wrapper, + title: 'Sales Manager', + single_column: true + }); + + var $content = $(wrapper).find('.page-content'); + $content.html(frappe.render_template('sales_manager')); + + loadPricingData(); + + $('#btn-refresh').on('click', function() { + loadPricingData(); + }); + + $('#btn-batch-score').on('click', function() { + frappe.confirm('Calculate scores for all serials?', function() { + frappe.call({ + method: 'westech_r2.api.scoring.batch_calculate_scores', + args: {batch_size: 500}, + callback: function(r) { + if (r.message) { + frappe.msgprint('Updated: ' + r.message.updated + + ', Scrap: ' + r.message.scrap + + ', Errors: ' + r.message.errors); + loadPricingData(); + } + } + }); + }); + }); + + $('#btn-all, #btn-current, #btn-stale, #btn-aging, #btn-expired').on('click', function() { + var filter = $(this).attr('id').replace('btn-', ''); + filterTable(filter); + }); +}; + +function loadPricingData() { + $('#loading').show(); + $('#pricing-tbody').empty(); + + frappe.call({ + method: 'westech_r2.api.scoring.get_sales_pricing_data', + args: {limit: 100}, + callback: function(r) { + $('#loading').hide(); + if (r.message && r.message.serials) { + renderPricingTable(r.message.serials); + } + } + }); +} + +function renderPricingTable(serials) { + var tbody = $('#pricing-tbody'); + tbody.empty(); + + serials.forEach(function(s) { + var ageBadge = '' + + (s.age.status || 'unknown') + ''; + + var tierBadge = '' + (s.tier || 'N/A') + ''; + + var specs = []; + if (s.processor) specs.push(s.processor); + if (s.ram) specs.push(s.ram); + + var marketRange = '$' + (s.market.low || 0).toFixed(0) + ' - $' + (s.market.high || 0).toFixed(0); + + var row = '' + + '' + s.serial_no + '' + + '' + (s.item_name || s.item_code) + '' + + '' + (s.cosmetic_grade || '') + '' + + '' + specs.join(' / ') + '' + + '' + (s.score || 0) + '' + + '' + tierBadge + '' + + '' + marketRange + '' + + '$' + (s.suggested_price || 0).toFixed(2) + '' + + '' + ageBadge + '
' + s.age.days + 'd' + + '' + + '' + + '' + + '' + + ''; + + tbody.append(row); + }); + + $('.btn-save-price').on('click', function() { + var serial = $(this).data('serial'); + var price = $(this).closest('tr').find('.final-price').val(); + saveFinalPrice(serial, price); + }); +} + +function filterTable(filter) { + if (filter === 'all') { + $('#pricing-tbody tr').show(); + } else { + $('#pricing-tbody tr').hide(); + $('#pricing-tbody tr[data-age="' + filter + '"]').show(); + } +} + +function saveFinalPrice(serial_no, price) { + frappe.call({ + method: 'frappe.client.set_value', + args: { + doctype: 'Serial No', + name: serial_no, + fieldname: { + 'assigned_price': price, + 'pricing_status': 'Manual Override' + } + }, + callback: function(r) { + if (!r.exc) { + frappe.show_alert({message: 'Price saved for ' + serial_no, indicator: 'green'}); + } + } + }); +} diff --git a/westech_r2/page/sales-manager/sales-manager.json b/westech_r2/page/sales-manager/sales-manager.json new file mode 100644 index 0000000..1dc08a6 --- /dev/null +++ b/westech_r2/page/sales-manager/sales-manager.json @@ -0,0 +1,12 @@ +{ + "title": "Sales Manager", + "route": "sales-manager", + "icon": "fa fa-dollar-sign", + "roles": [ + {"role": "System Manager"}, + {"role": "Sales User"} + ], + "standard": "Yes", + "type": "page", + "module": "Westech R2" +} diff --git a/westech_r2/page/sales-manager/sales_manager.py b/westech_r2/page/sales-manager/sales_manager.py new file mode 100644 index 0000000..d2aad02 --- /dev/null +++ b/westech_r2/page/sales-manager/sales_manager.py @@ -0,0 +1,6 @@ +import frappe +from frappe import _ + +def get_context(context): + context.no_cache = 1 + return context diff --git a/westech_r2/westech_r2/__pycache__/__init__.cpython-312.pyc b/westech_r2/westech_r2/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..a909cf6 Binary files /dev/null and b/westech_r2/westech_r2/__pycache__/__init__.cpython-312.pyc differ diff --git a/westech_r2/westech_r2/page/__init__.py b/westech_r2/westech_r2/page/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/westech_r2/westech_r2/page/sales_manager/__init__.py b/westech_r2/westech_r2/page/sales_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/westech_r2/westech_r2/page/sales_manager/sales_manager.js b/westech_r2/westech_r2/page/sales_manager/sales_manager.js new file mode 100644 index 0000000..98a8f27 --- /dev/null +++ b/westech_r2/westech_r2/page/sales_manager/sales_manager.js @@ -0,0 +1,7 @@ +frappe.pages['sales-manager'].on_page_load = function(wrapper) { + var page = frappe.ui.make_app_page({ + parent: wrapper, + title: 'Sales Manager', + single_column: true + }); +} \ No newline at end of file diff --git a/westech_r2/westech_r2/page/sales_manager/sales_manager.json b/westech_r2/westech_r2/page/sales_manager/sales_manager.json new file mode 100644 index 0000000..28b5072 --- /dev/null +++ b/westech_r2/westech_r2/page/sales_manager/sales_manager.json @@ -0,0 +1,19 @@ +{ + "content": null, + "creation": "2026-05-17 08:50:06.927009", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-05-17 08:50:06.927009", + "modified_by": "Administrator", + "module": "Westech R2", + "name": "sales-manager", + "owner": "Administrator", + "page_name": "sales-manager", + "roles": [], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0, + "title": "Sales Manager" +} \ No newline at end of file diff --git a/westech_r2/westech_r2/workspace/westech/westech.json b/westech_r2/westech_r2/workspace/westech/westech.json index fd21793..c6d7023 100644 --- a/westech_r2/westech_r2/workspace/westech/westech.json +++ b/westech_r2/westech_r2/workspace/westech/westech.json @@ -100,7 +100,7 @@ "type": "Link" } ], - "modified": "2026-05-17 06:07:36.785015", + "modified": "2026-05-17 08:50:07.192685", "modified_by": "Administrator", "module": "Westech R2", "name": "Westech", @@ -180,6 +180,12 @@ "label": "eBay Pricing", "link_to": "ebay-pricing", "type": "Page" + }, + { + "doc_view": "", + "label": "Sales Manager", + "link_to": "sales-manager", + "type": "Page" } ], "title": "Westech"