feat: add eBay pricing integration with DocTypes, Frappe Page, and API methods
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,474 @@
|
|||||||
|
"""
|
||||||
|
Westech R2 — eBay Pricing API
|
||||||
|
Whitelisted Frappe methods for searching eBay sold listings and batch pricing.
|
||||||
|
|
||||||
|
Mirrors /opt/eim/app/ebay_pricing.py logic, adapted for Frappe/ERPNext.
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
from frappe.utils import now, now_datetime
|
||||||
|
from frappe import _
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
OXYLABS_API = "https://realtime.oxylabs.io/v1/queries"
|
||||||
|
ACTOR_ID = "caffein.dev~ebay-sold-listings"
|
||||||
|
API_BASE = "https://api.apify.com/v2"
|
||||||
|
|
||||||
|
MFR_CLEANUP = {
|
||||||
|
"Dell Inc": "Dell",
|
||||||
|
"HP HP": "HP",
|
||||||
|
"HP": "HP",
|
||||||
|
"LENOVO": "Lenovo",
|
||||||
|
"Lenovo": "Lenovo",
|
||||||
|
"Microsoft Corporation": "Microsoft",
|
||||||
|
"Apple Inc": "Apple",
|
||||||
|
"ASUSTeK COMPUTER INC.": "ASUS",
|
||||||
|
"Acer": "Acer",
|
||||||
|
"Panasonic": "Panasonic",
|
||||||
|
"Samsung": "Samsung",
|
||||||
|
"Toshiba": "Toshiba",
|
||||||
|
"Fujitsu": "Fujitsu",
|
||||||
|
"Hewlett-Packard": "HP",
|
||||||
|
"HUAWEI": "Huawei",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_settings():
|
||||||
|
"""Load eBay Pricing Settings singleton."""
|
||||||
|
if not frappe.db.exists("eBay Pricing Settings", "eBay Pricing Settings"):
|
||||||
|
return None
|
||||||
|
return frappe.get_doc("eBay Pricing Settings", "eBay Pricing Settings")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_oxylabs_creds():
|
||||||
|
"""Return (user, password) tuple from settings or env."""
|
||||||
|
settings = _get_settings()
|
||||||
|
user = password = ""
|
||||||
|
if settings:
|
||||||
|
user = settings.get("oxylabs_user") or ""
|
||||||
|
password = settings.get_password("oxylabs_password") or ""
|
||||||
|
# Env fallback
|
||||||
|
if not user:
|
||||||
|
user = frappe.conf.get("oxylabs_user", "")
|
||||||
|
if not password:
|
||||||
|
password = frappe.conf.get("oxylabs_password", "")
|
||||||
|
return (user, password)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_apify_token():
|
||||||
|
"""Return Apify token from settings or env."""
|
||||||
|
settings = _get_settings()
|
||||||
|
token = ""
|
||||||
|
if settings:
|
||||||
|
token = settings.get_password("apify_token") or ""
|
||||||
|
if not token:
|
||||||
|
token = frappe.conf.get("apify_token", "")
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def clean_manufacturer(mfr):
|
||||||
|
return MFR_CLEANUP.get(mfr, mfr)
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def search_model(query=None, manufacturer=None, model=None, source="auto"):
|
||||||
|
"""
|
||||||
|
Search eBay sold listings for a specific model.
|
||||||
|
Returns {results: [...], pricing: {...}} or {error}.
|
||||||
|
"""
|
||||||
|
if not query and not (manufacturer and model):
|
||||||
|
return {"error": "Provide query or manufacturer + model"}
|
||||||
|
|
||||||
|
if not manufacturer or not model:
|
||||||
|
# Try to split query into manufacturer + model
|
||||||
|
parts = (query or "").split(None, 1)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
manufacturer, model = parts[0], parts[1]
|
||||||
|
else:
|
||||||
|
manufacturer, model = query, ""
|
||||||
|
|
||||||
|
items, used_source = _search_ebay_sold(model, manufacturer, source=source)
|
||||||
|
pricing = _parse_prices(items, manufacturer, model, source=used_source or "unknown")
|
||||||
|
|
||||||
|
if pricing:
|
||||||
|
# Write or update System Pricing record
|
||||||
|
_upsert_system_pricing(manufacturer, model, pricing)
|
||||||
|
# Log the API call
|
||||||
|
_log_api_call(manufacturer, model, query, used_source, len(items) if items else 0,
|
||||||
|
"Success" if pricing else "Failed")
|
||||||
|
return {"results": items or [], "pricing": pricing}
|
||||||
|
else:
|
||||||
|
_log_api_call(manufacturer, model, query, used_source, 0, "Failed")
|
||||||
|
return {"results": items or [], "pricing": None, "message": "No pricing data found"}
|
||||||
|
|
||||||
|
|
||||||
|
def _search_ebay_sold_oxylabs(model, manufacturer):
|
||||||
|
user, password = _get_oxylabs_creds()
|
||||||
|
if not user or not password:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import requests as req_module
|
||||||
|
|
||||||
|
clean_mfr = clean_manufacturer(manufacturer)
|
||||||
|
query = f"{clean_mfr} {model}"
|
||||||
|
|
||||||
|
payloads = [
|
||||||
|
{
|
||||||
|
"source": "universal",
|
||||||
|
"url": f"https://www.ebay.com/sch/i.html?_nkw={urllib.parse.quote(query)}&LH_Sold=1&_ipg=240",
|
||||||
|
"render": "html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "ebay_search",
|
||||||
|
"query": query,
|
||||||
|
"domain": "com",
|
||||||
|
"render": "html",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for payload in payloads:
|
||||||
|
try:
|
||||||
|
resp = req_module.post(OXYLABS_API, auth=(user, password), json=payload, timeout=120)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
frappe.log_error(f"Oxylabs HTTP {resp.status_code}", "eBay Pricing")
|
||||||
|
continue
|
||||||
|
data = resp.json()
|
||||||
|
if "results" not in data or not data["results"]:
|
||||||
|
continue
|
||||||
|
content = data["results"][0].get("content", "")
|
||||||
|
if not isinstance(content, str) or len(content) < 100000:
|
||||||
|
continue
|
||||||
|
listings = _parse_ebay_html(content)
|
||||||
|
if listings and len(listings) >= 3:
|
||||||
|
return listings
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(f"Oxylabs error: {e}", "eBay Pricing")
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ebay_html(content):
|
||||||
|
listings = []
|
||||||
|
card_blocks = re.split(r'class="s-card\s', content)
|
||||||
|
for block in card_blocks[1:]:
|
||||||
|
listing = {}
|
||||||
|
price_m = re.search(
|
||||||
|
r's-card__price[^>]*>\$([\d,.]+(?:\.\d{2})?)(?:\s*to\s*\$[\d,.]+(?:\.\d{2})?)?</span>',
|
||||||
|
block,
|
||||||
|
)
|
||||||
|
if price_m:
|
||||||
|
listing["price"] = float(price_m.group(1).replace(",", ""))
|
||||||
|
|
||||||
|
title_m = re.search(r's-card__title[^>]*><span[^>]*>([^<]+)</span>', block)
|
||||||
|
if title_m:
|
||||||
|
listing["title"] = title_m.group(1).strip()
|
||||||
|
if listing["title"].lower() in ("shop on ebay", ""):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
heading_m = re.search(r'role=heading[^>]*>(.*?)</(?:div|span|h\d)>', block, re.DOTALL)
|
||||||
|
if heading_m:
|
||||||
|
title_text = re.sub(r'<[^>]+>', '', heading_m.group(1)).strip()
|
||||||
|
if title_text.lower() != "shop on ebay":
|
||||||
|
listing["title"] = title_text
|
||||||
|
|
||||||
|
sold_m = re.search(r'(\d[\d,]*)\s+sold', block, re.IGNORECASE)
|
||||||
|
if sold_m:
|
||||||
|
listing["sold"] = int(sold_m.group(1).replace(",", ""))
|
||||||
|
|
||||||
|
if re.search(r'Free (?:shipping|delivery|Standard Shipping)', block, re.IGNORECASE):
|
||||||
|
listing["shipping"] = "Free"
|
||||||
|
else:
|
||||||
|
ship_m = re.search(
|
||||||
|
r'\+\$?([\d,.]+)\s+(?:shipping|delivery|Standard Shipping)', block, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if ship_m:
|
||||||
|
listing["shipping"] = float(ship_m.group(1).replace(",", ""))
|
||||||
|
else:
|
||||||
|
ship_alt = re.search(r'\$([\d,.]+)\s+(?:shipping|delivery)', block, re.IGNORECASE)
|
||||||
|
if ship_alt:
|
||||||
|
listing["shipping"] = float(ship_alt.group(1).replace(",", ""))
|
||||||
|
|
||||||
|
cond_m = re.search(
|
||||||
|
r'(Pre-Owned|Used|Brand New|New \(Other\)|Refurbished|Open Box|For parts or not working|Seller refurbished|New with defects|New with box|New without box|New with tags)',
|
||||||
|
block, re.IGNORECASE,
|
||||||
|
)
|
||||||
|
if cond_m:
|
||||||
|
listing["condition"] = cond_m.group(1)
|
||||||
|
|
||||||
|
if listing.get("price") or listing.get("title"):
|
||||||
|
listings.append(listing)
|
||||||
|
return listings if listings else None
|
||||||
|
|
||||||
|
|
||||||
|
def _search_ebay_sold_apify(model, manufacturer):
|
||||||
|
token = _get_apify_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
import requests as req_module
|
||||||
|
|
||||||
|
clean_mfr = clean_manufacturer(manufacturer)
|
||||||
|
query = f"{clean_mfr} {model}"
|
||||||
|
|
||||||
|
run_input = {
|
||||||
|
"keywords": [query],
|
||||||
|
"daysToScrape": 60,
|
||||||
|
"count": 30,
|
||||||
|
"categoryId": "0",
|
||||||
|
"ebaySite": "ebay.com",
|
||||||
|
"sortOrder": "endedRecently",
|
||||||
|
"itemCondition": "any",
|
||||||
|
"itemLocation": "domestic",
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{API_BASE}/acts/{ACTOR_ID}/runs?token={token}"
|
||||||
|
try:
|
||||||
|
resp = req_module.post(url, json=run_input, timeout=30)
|
||||||
|
result = resp.json()
|
||||||
|
run_id = result["data"]["id"]
|
||||||
|
dataset_id = result["data"].get("defaultDatasetId")
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(f"Apify start error: {e}", "eBay Pricing")
|
||||||
|
return None
|
||||||
|
|
||||||
|
max_wait = 120
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < max_wait:
|
||||||
|
time.sleep(8)
|
||||||
|
try:
|
||||||
|
status_url = f"{API_BASE}/actor-runs/{run_id}?token={token}"
|
||||||
|
status_resp = req_module.get(status_url, timeout=10)
|
||||||
|
status = status_resp.json()
|
||||||
|
run_status = status["data"]["status"]
|
||||||
|
if run_status == "SUCCEEDED":
|
||||||
|
break
|
||||||
|
elif run_status in ("FAILED", "ABORTED", "TIMED-OUT"):
|
||||||
|
frappe.log_error(f"Apify run {run_status}", "eBay Pricing")
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
results_url = f"{API_BASE}/datasets/{dataset_id}/items?token={token}&limit=30&clean=true"
|
||||||
|
results_resp = req_module.get(results_url, timeout=15)
|
||||||
|
return results_resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
frappe.log_error(f"Apify fetch error: {e}", "eBay Pricing")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _search_ebay_sold(model, manufacturer, source="auto"):
|
||||||
|
if source in ("auto", "oxylabs"):
|
||||||
|
result = _search_ebay_sold_oxylabs(model, manufacturer)
|
||||||
|
if result is not None:
|
||||||
|
return result, "oxylabs"
|
||||||
|
frappe.logger().info("Oxylabs failed, trying Apify...")
|
||||||
|
if source in ("auto", "apify"):
|
||||||
|
result = _search_ebay_sold_apify(model, manufacturer)
|
||||||
|
if result is not None:
|
||||||
|
return result, "apify"
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_prices(items, manufacturer, model, source="oxylabs"):
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
prices = []
|
||||||
|
clean_mfr = clean_manufacturer(manufacturer)
|
||||||
|
search_terms = {clean_mfr.lower(), model.lower()}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if item.get("error"):
|
||||||
|
continue
|
||||||
|
if source == "oxylabs":
|
||||||
|
price_val = item.get("price")
|
||||||
|
if isinstance(price_val, str):
|
||||||
|
price_str = price_val.replace("$", "").replace(",", "").strip()
|
||||||
|
if " to " in price_str:
|
||||||
|
price_str = price_str.split(" to ")[0]
|
||||||
|
try:
|
||||||
|
price_val = float(price_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if not isinstance(price_val, (int, float)):
|
||||||
|
continue
|
||||||
|
p = float(price_val)
|
||||||
|
if 5 < p < 10000:
|
||||||
|
title = item.get("title", "").upper()
|
||||||
|
model_upper = model.upper()
|
||||||
|
model_words = model_upper.split()
|
||||||
|
if len(items) > 5:
|
||||||
|
short_model_words = [w for w in model_words if len(w) > 2]
|
||||||
|
if short_model_words and not any(w in title for w in short_model_words):
|
||||||
|
continue
|
||||||
|
prices.append(p)
|
||||||
|
else:
|
||||||
|
price_str = item.get("totalPrice") or item.get("soldPrice")
|
||||||
|
if not price_str:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
p = float(str(price_str).replace(",", "").replace("$", "").strip())
|
||||||
|
if 5 < p < 10000:
|
||||||
|
prices.append(p)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not prices:
|
||||||
|
return None
|
||||||
|
prices.sort()
|
||||||
|
if len(prices) >= 5:
|
||||||
|
trim = max(1, int(len(prices) * 0.1))
|
||||||
|
trimmed = prices[trim : len(prices) - trim]
|
||||||
|
if trimmed:
|
||||||
|
prices = trimmed
|
||||||
|
if not prices:
|
||||||
|
return None
|
||||||
|
|
||||||
|
avg = sum(prices) / len(prices)
|
||||||
|
median = prices[len(prices) // 2]
|
||||||
|
return {
|
||||||
|
"price_high": round(max(prices), 2),
|
||||||
|
"price_low": round(min(prices), 2),
|
||||||
|
"price_average": round(avg, 2),
|
||||||
|
"price_auction": round(median, 2),
|
||||||
|
"sample_count": len(prices),
|
||||||
|
"source": f"ebay_{source}",
|
||||||
|
"scraped_at": now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_system_pricing(manufacturer, model, pricing):
|
||||||
|
"""Create or update System Pricing record."""
|
||||||
|
# Check if record exists by model/manufacturer
|
||||||
|
existing = frappe.db.get_value(
|
||||||
|
"System Pricing",
|
||||||
|
{"manufacturer": manufacturer, "model": model},
|
||||||
|
"name",
|
||||||
|
)
|
||||||
|
doc = None
|
||||||
|
if existing:
|
||||||
|
doc = frappe.get_doc("System Pricing", existing)
|
||||||
|
else:
|
||||||
|
doc = frappe.new_doc("System Pricing")
|
||||||
|
doc.manufacturer = manufacturer
|
||||||
|
doc.model = model
|
||||||
|
|
||||||
|
for key in ("price_high", "price_low", "price_average", "price_auction",
|
||||||
|
"sample_count", "source", "scraped_at"):
|
||||||
|
if key in pricing:
|
||||||
|
setattr(doc, key, pricing[key])
|
||||||
|
|
||||||
|
# Compute days_since_pricing
|
||||||
|
if doc.scraped_at:
|
||||||
|
scraped = frappe.utils.get_datetime(doc.scraped_at)
|
||||||
|
now = now_datetime()
|
||||||
|
doc.days_since_pricing = (now - scraped).days
|
||||||
|
else:
|
||||||
|
doc.days_since_pricing = 0
|
||||||
|
|
||||||
|
doc.pricing_status = "Priced"
|
||||||
|
doc.save()
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _log_api_call(manufacturer, model, search_query, source, results_count, status):
|
||||||
|
"""Log API usage for budget tracking."""
|
||||||
|
try:
|
||||||
|
log = frappe.new_doc("eBay Pricing Log")
|
||||||
|
log.manufacturer = manufacturer
|
||||||
|
log.model = model
|
||||||
|
log.search_query = search_query
|
||||||
|
log.source = source or "unknown"
|
||||||
|
log.timestamp = now()
|
||||||
|
log.results_count = results_count or 0
|
||||||
|
log.status = status
|
||||||
|
log.save()
|
||||||
|
frappe.db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def run_batch(batch_size=10, source="auto", force=False):
|
||||||
|
"""
|
||||||
|
Run batch pricing on the next N unique models that need pricing.
|
||||||
|
Returns {priced, failed, skipped, total}.
|
||||||
|
"""
|
||||||
|
batch_size = int(batch_size) if batch_size != "all" else 999999
|
||||||
|
force = bool(force)
|
||||||
|
|
||||||
|
# Get unique models from Serial No / Item records that have manufacturer + model
|
||||||
|
models = frappe.db.sql(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT manufacturer, model
|
||||||
|
FROM `tabSerial No`
|
||||||
|
WHERE manufacturer IS NOT NULL AND manufacturer != ''
|
||||||
|
AND model IS NOT NULL AND model != ''
|
||||||
|
ORDER BY creation DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(batch_size,),
|
||||||
|
as_dict=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
priced = failed = skipped = 0
|
||||||
|
for row in models:
|
||||||
|
mfr = row.manufacturer
|
||||||
|
mdl = row.model
|
||||||
|
|
||||||
|
# Skip if already priced (unless force)
|
||||||
|
if not force:
|
||||||
|
exists = frappe.db.exists("System Pricing", {"manufacturer": mfr, "model": mdl})
|
||||||
|
if exists:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
items, used_source = _search_ebay_sold(mdl, mfr, source=source)
|
||||||
|
pricing = _parse_prices(items, mfr, mdl, source=used_source or "unknown")
|
||||||
|
if pricing:
|
||||||
|
_upsert_system_pricing(mfr, mdl, pricing)
|
||||||
|
_log_api_call(mfr, mdl, f"{mfr} {mdl}", used_source, len(items) if items else 0, "Success")
|
||||||
|
priced += 1
|
||||||
|
else:
|
||||||
|
_log_api_call(mfr, mdl, f"{mfr} {mdl}", used_source, 0, "Failed")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
# Rate limit
|
||||||
|
if used_source == "oxylabs":
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
return {"priced": priced, "failed": failed, "skipped": skipped, "total": len(models)}
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_recent_pricing(limit=50, status_filter=None):
|
||||||
|
"""Return recent System Pricing records as list of dicts."""
|
||||||
|
filters = {}
|
||||||
|
if status_filter:
|
||||||
|
filters["pricing_status"] = status_filter
|
||||||
|
|
||||||
|
records = frappe.get_all(
|
||||||
|
"System Pricing",
|
||||||
|
filters=filters,
|
||||||
|
fields=[
|
||||||
|
"name", "manufacturer", "model", "pricing_status",
|
||||||
|
"scraped_at", "days_since_pricing",
|
||||||
|
"price_high", "price_low", "price_average",
|
||||||
|
"sample_count", "source",
|
||||||
|
],
|
||||||
|
order_by="scraped_at desc",
|
||||||
|
limit=int(limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
for r in records:
|
||||||
|
r["days_since_pricing"] = r.get("days_since_pricing") or 0
|
||||||
|
for key in ("price_high", "price_low", "price_average"):
|
||||||
|
if r.get(key) is not None:
|
||||||
|
r[key] = round(r[key], 2)
|
||||||
|
return records
|
||||||
@@ -1,29 +1,57 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "Scheduled Pickup - Auto Fill",
|
"docstatus": 0,
|
||||||
"dt": "Scheduled Pickup",
|
"doctype": "Client Script",
|
||||||
"view": "Form",
|
"dt": "Pallet",
|
||||||
"module": null,
|
"enabled": 1,
|
||||||
"enabled": 1,
|
"modified": "2026-05-17 05:39:15.497479",
|
||||||
"script": "frappe.ui.form.on('Scheduled Pickup', {\n customer_number: function(frm) {\n var customer = frm.doc.customer_number;\n if (!customer) {\n clear_supplier_fields(frm);\n return;\n }\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Supplier', name: customer},\n callback: function(r) {\n if (!r.message) return;\n var s = r.message;\n if (!frm.doc.company_name && s.supplier_name) {\n frm.set_value('company_name', s.supplier_name);\n }\n if (s.supplier_primary_contact) {\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Contact', name: s.supplier_primary_contact},\n callback: function(cr) {\n if (!cr.message) return;\n var ct = cr.message;\n var full_name = [ct.first_name, ct.last_name].filter(Boolean).join(' ');\n if (!frm.doc.contact_name) frm.set_value('contact_name', full_name);\n if (!frm.doc.contact_phone) frm.set_value('contact_phone', ct.phone || '');\n if (!frm.doc.contact_email) frm.set_value('contact_email', ct.email_id || '');\n }\n });\n }\n if (s.supplier_primary_address) {\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Address', name: s.supplier_primary_address},\n callback: function(ar) {\n if (!ar.message) return;\n var a = ar.message;\n if (!frm.doc.address_line) frm.set_value('address_line', a.address_line1 || '');\n if (!frm.doc.city) frm.set_value('city', a.city || '');\n if (!frm.doc.state) frm.set_value('state', a.state || '');\n if (!frm.doc.zip_code) frm.set_value('zip_code', a.pincode || '');\n }\n });\n }\n if (s.geocoded && s.latitude && s.longitude) {\n frm.set_value('latitude', s.latitude);\n frm.set_value('longitude', s.longitude);\n frm.set_value('geocoded', 1);\n }\n }\n });\n }\n});\n\nfunction clear_supplier_fields(frm) {\n frm.set_value('company_name', '');\n frm.set_value('contact_name', '');\n frm.set_value('contact_phone', '');\n frm.set_value('contact_email', '');\n frm.set_value('address_line', '');\n frm.set_value('city', '');\n frm.set_value('state', '');\n frm.set_value('zip_code', '');\n frm.set_value('latitude', '');\n frm.set_value('longitude', '');\n frm.set_value('geocoded', 0);\n}\n",
|
"module": null,
|
||||||
"doctype": "Client Script"
|
"name": "Pallet - View Serials",
|
||||||
},
|
"script": "frappe.ui.form.on('Pallet', {\n refresh: function(frm) {\n frm.add_custom_button(__('View Serials'), function() {\n frappe.set_route('List', 'Serial No', {\n 'pallet': frm.doc.pallet_number || frm.doc.name\n });\n }, __('View'));\n \n frm.add_custom_button(__('Serials Spreadsheet'), function() {\n frappe.set_route('query-report', 'Serial Nos by Pallet', {\n 'pallet': frm.doc.pallet_number || frm.doc.name\n });\n }, __('View'));\n }\n});\n",
|
||||||
{
|
"view": "Form"
|
||||||
"name": "Load - View Pallets Button",
|
},
|
||||||
"dt": "Load",
|
{
|
||||||
"view": "Form",
|
"docstatus": 0,
|
||||||
"module": null,
|
"doctype": "Client Script",
|
||||||
"enabled": 1,
|
"dt": "Load",
|
||||||
"script": "frappe.ui.form.on('Load', {\n refresh: function(frm) {\n frm.add_custom_button(__('View Pallets'), function() {\n frappe.set_route('List', 'Pallet', {'load': frm.doc.name});\n }, __('Actions'));\n }\n});\n",
|
"enabled": 1,
|
||||||
"doctype": "Client Script"
|
"modified": "2026-05-17 05:39:15.577871",
|
||||||
},
|
"module": null,
|
||||||
{
|
"name": "Load - View Pallets Button",
|
||||||
"name": "Pallet - View Serials",
|
"script": "frappe.ui.form.on('Load', {\n refresh: function(frm) {\n frm.add_custom_button(__('View Pallets'), function() {\n frappe.set_route('List', 'Pallet', {'load': frm.doc.name});\n }, __('Actions'));\n }\n});\n",
|
||||||
"dt": "Pallet",
|
"view": "Form"
|
||||||
"view": "Form",
|
},
|
||||||
"module": null,
|
{
|
||||||
"enabled": 1,
|
"docstatus": 0,
|
||||||
"script": "frappe.ui.form.on('Pallet', {\n refresh: function(frm) {\n frm.add_custom_button(__('View Serials'), function() {\n frappe.set_route('List', 'Serial No', {\n 'pallet': frm.doc.pallet_number || frm.doc.name\n });\n }, __('View'));\n \n frm.add_custom_button(__('Serials Spreadsheet'), function() {\n frappe.set_route('query-report', 'Serial Nos by Pallet', {\n 'pallet': frm.doc.pallet_number || frm.doc.name\n });\n }, __('View'));\n }\n});\n",
|
"doctype": "Client Script",
|
||||||
"doctype": "Client Script"
|
"dt": "Scheduled Pickup",
|
||||||
}
|
"enabled": 1,
|
||||||
|
"modified": "2026-05-17 05:39:15.588395",
|
||||||
|
"module": null,
|
||||||
|
"name": "Scheduled Pickup - Auto Fill",
|
||||||
|
"script": "frappe.ui.form.on('Scheduled Pickup', {\n customer_number: function(frm) {\n var customer = frm.doc.customer_number;\n if (!customer) {\n clear_supplier_fields(frm);\n return;\n }\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Supplier', name: customer},\n callback: function(r) {\n if (!r.message) return;\n var s = r.message;\n if (!frm.doc.company_name && s.supplier_name) {\n frm.set_value('company_name', s.supplier_name);\n }\n if (s.supplier_primary_contact) {\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Contact', name: s.supplier_primary_contact},\n callback: function(cr) {\n if (!cr.message) return;\n var ct = cr.message;\n var full_name = [ct.first_name, ct.last_name].filter(Boolean).join(' ');\n if (!frm.doc.contact_name) frm.set_value('contact_name', full_name);\n if (!frm.doc.contact_phone) frm.set_value('contact_phone', ct.phone || '');\n if (!frm.doc.contact_email) frm.set_value('contact_email', ct.email_id || '');\n }\n });\n }\n if (s.supplier_primary_address) {\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Address', name: s.supplier_primary_address},\n callback: function(ar) {\n if (!ar.message) return;\n var a = ar.message;\n if (!frm.doc.address_line) frm.set_value('address_line', a.address_line1 || '');\n if (!frm.doc.city) frm.set_value('city', a.city || '');\n if (!frm.doc.state) frm.set_value('state', a.state || '');\n if (!frm.doc.zip_code) frm.set_value('zip_code', a.pincode || '');\n }\n });\n }\n if (s.geocoded && s.latitude && s.longitude) {\n frm.set_value('latitude', s.latitude);\n frm.set_value('longitude', s.longitude);\n frm.set_value('geocoded', 1);\n }\n }\n });\n }\n});\n\nfunction clear_supplier_fields(frm) {\n frm.set_value('company_name', '');\n frm.set_value('contact_name', '');\n frm.set_value('contact_phone', '');\n frm.set_value('contact_email', '');\n frm.set_value('address_line', '');\n frm.set_value('city', '');\n frm.set_value('state', '');\n frm.set_value('zip_code', '');\n frm.set_value('latitude', '');\n frm.set_value('longitude', '');\n frm.set_value('geocoded', 0);\n}\n",
|
||||||
|
"view": "Form"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Client Script",
|
||||||
|
"dt": "System Pricing",
|
||||||
|
"enabled": 1,
|
||||||
|
"modified": "2026-05-17 05:41:31.790905",
|
||||||
|
"module": null,
|
||||||
|
"name": "System Pricing - Age Indicators",
|
||||||
|
"script": "frappe.listview_settings['System Pricing'] = {\n add_fields: [\"scraped_at\", \"days_since_pricing\", \"pricing_status\"],\n get_indicator: function(doc) {\n if (doc.pricing_status === \"Needs Pricing\" || doc.pricing_status === \"Error\") {\n return [__(\"Needs Pricing\"), \"orange\", \"pricing_status,=,Needs Pricing\"];\n }\n if (!doc.scraped_at) {\n return [__(\"No Date\"), \"gray\", \"scraped_at,is,not set\"];\n }\n var days = doc.days_since_pricing || 0;\n if (days < 90) {\n return [__(\"Fresh\"), \"green\", \"days_since_pricing,<,90\"];\n } else if (days < 120) {\n return [__(\"Aging\"), \"yellow\", \"days_since_pricing,<,120\"];\n } else {\n return [__(\"Expired\"), \"red\", \"days_since_pricing,>=,120\"];\n }\n }\n};",
|
||||||
|
"view": "List"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Client Script",
|
||||||
|
"dt": "System Pricing",
|
||||||
|
"enabled": 1,
|
||||||
|
"modified": "2026-05-17 05:41:31.954814",
|
||||||
|
"module": null,
|
||||||
|
"name": "System Pricing - Auto Calculate Days",
|
||||||
|
"script": "frappe.ui.form.on('System Pricing', {\n refresh: function(frm) {\n calculate_days(frm);\n },\n scraped_at: function(frm) {\n calculate_days(frm);\n }\n});\n\nfunction calculate_days(frm) {\n if (frm.doc.scraped_at) {\n var scraped = new Date(frm.doc.scraped_at);\n var now = new Date();\n var diffTime = Math.abs(now - scraped);\n var diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n frm.set_value('days_since_pricing', diffDays);\n } else {\n frm.set_value('days_since_pricing', 0);\n }\n}",
|
||||||
|
"view": "Form"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
+3192
-3080
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+13999
-339
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends "templates/web.html" %}
|
||||||
|
{% block style %}
|
||||||
|
<style>
|
||||||
|
.badge.status-fresh { background-color: #28a745; }
|
||||||
|
.badge.status-aging { background-color: #ffc107; color: #212529; }
|
||||||
|
.badge.status-expired { background-color: #dc3545; }
|
||||||
|
.badge.status-needs { background-color: #fd7e14; }
|
||||||
|
.badge.status-error { background-color: #6c757d; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
{% block page_content %}
|
||||||
|
<div id="ebay-pricing-page"></div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
frappe.pages['ebay_pricing'].on_page_load = function(wrapper) {
|
||||||
|
var page = frappe.ui.make_app_page({
|
||||||
|
parent: wrapper,
|
||||||
|
title: __('eBay Pricing'),
|
||||||
|
single_column: true
|
||||||
|
});
|
||||||
|
|
||||||
|
let $container = $(`<div class="ebay-pricing-container" style="padding: 1rem;">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Search Model</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="ebay-search-input"
|
||||||
|
placeholder="Dell Latitude 5410..." autocomplete="off">
|
||||||
|
<span class="input-group-btn">
|
||||||
|
<button class="btn btn-primary" id="ebay-search-btn">
|
||||||
|
<i class="fa fa-search"></i> Search
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 text-right">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Batch Size</label>
|
||||||
|
<select class="form-control" id="ebay-batch-size" style="display:inline-block; width:auto;">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-warning" id="ebay-batch-btn">
|
||||||
|
<i class="fa fa-play"></i> Price Batch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div id="ebay-results-area">
|
||||||
|
<div class="text-muted text-center" style="padding: 4rem;">
|
||||||
|
<i class="fa fa-search" style="font-size: 3rem; opacity: 0.3;"></i>
|
||||||
|
<p>Search for a model or run batch pricing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`).appendTo(page.main);
|
||||||
|
|
||||||
|
$container.find('#ebay-search-btn').on('click', function() {
|
||||||
|
let query = $container.find('#ebay-search-input').val().trim();
|
||||||
|
if (!query) {
|
||||||
|
frappe.msgprint(__('Enter a model to search'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
search_ebay(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.find('#ebay-search-input').on('keypress', function(e) {
|
||||||
|
if (e.which === 13) {
|
||||||
|
$container.find('#ebay-search-btn').trigger('click');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.find('#ebay-batch-btn').on('click', function() {
|
||||||
|
let size = $container.find('#ebay-batch-size').val();
|
||||||
|
run_batch(size);
|
||||||
|
});
|
||||||
|
|
||||||
|
function search_ebay(query) {
|
||||||
|
frappe.call({
|
||||||
|
method: 'westech_r2.api.ebay_pricing.search_model',
|
||||||
|
args: { query: query },
|
||||||
|
freeze: true,
|
||||||
|
freeze_message: __('Searching eBay sold listings...'),
|
||||||
|
callback: function(r) {
|
||||||
|
if (r.message && r.message.results) {
|
||||||
|
render_results(r.message);
|
||||||
|
} else {
|
||||||
|
let msg = (r.message && r.message.message) || __('No results found');
|
||||||
|
frappe.msgprint(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function run_batch(size) {
|
||||||
|
frappe.call({
|
||||||
|
method: 'westech_r2.api.ebay_pricing.run_batch',
|
||||||
|
args: { batch_size: size },
|
||||||
|
freeze: true,
|
||||||
|
freeze_message: __('Running batch pricing...'),
|
||||||
|
callback: function(r) {
|
||||||
|
if (r.message) {
|
||||||
|
frappe.msgprint(__('Batch complete: {0} priced, {1} failed, {2} skipped',
|
||||||
|
[r.message.priced, r.message.failed, r.message.skipped]));
|
||||||
|
load_recent_pricing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_results(data) {
|
||||||
|
let $area = $container.find('#ebay-results-area').empty();
|
||||||
|
if (!data.results || !data.results.length) {
|
||||||
|
$area.html(`<div class="text-muted text-center" style="padding: 2rem;">No results</div>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `<table class="table table-bordered">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Condition</th>
|
||||||
|
<th>Sold</th>
|
||||||
|
<th>Shipping</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>`;
|
||||||
|
data.results.forEach(item => {
|
||||||
|
html += `<tr>
|
||||||
|
<td>${frappe.utils.escape_html(item.title || '')}</td>
|
||||||
|
<td>$${(item.price || 0).toFixed(2)}</td>
|
||||||
|
<td>${frappe.utils.escape_html(item.condition || '')}</td>
|
||||||
|
<td>${item.sold || ''}</td>
|
||||||
|
<td>${item.shipping || ''}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
|
||||||
|
if (data.pricing) {
|
||||||
|
html += `<div class="well">
|
||||||
|
<h4>Pricing Summary</h4>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3"><strong>Low:</strong> $${data.pricing.price_low}</div>
|
||||||
|
<div class="col-md-3"><strong>High:</strong> $${data.pricing.price_high}</div>
|
||||||
|
<div class="col-md-3"><strong>Average:</strong> $${data.pricing.price_average}</div>
|
||||||
|
<div class="col-md-3"><strong>Median:</strong> $${data.pricing.price_auction}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top: 1rem;">
|
||||||
|
<div class="col-md-6"><strong>Source:</strong> ${data.pricing.source}</div>
|
||||||
|
<div class="col-md-6"><strong>Samples:</strong> ${data.pricing.sample_count}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
$area.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function load_recent_pricing() {
|
||||||
|
frappe.call({
|
||||||
|
method: 'westech_r2.api.ebay_pricing.get_recent_pricing',
|
||||||
|
args: { limit: 50 },
|
||||||
|
callback: function(r) {
|
||||||
|
if (r.message) {
|
||||||
|
render_pricing_grid(r.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_pricing_grid(items) {
|
||||||
|
let $area = $container.find('#ebay-results-area');
|
||||||
|
if (!items || !items.length) {
|
||||||
|
$area.html(`<div class="text-muted text-center" style="padding: 2rem;">No pricing data yet</div>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `<h4>Recent Pricing Results</h4>
|
||||||
|
<table class="table table-bordered table-hover">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Manufacturer</th>
|
||||||
|
<th>Model</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Age</th>
|
||||||
|
<th>Low</th>
|
||||||
|
<th>High</th>
|
||||||
|
<th>Avg</th>
|
||||||
|
<th>Samples</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Last Priced</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
items.forEach(row => {
|
||||||
|
let status_class = 'status-needs';
|
||||||
|
if (row.pricing_status === 'Priced') status_class = 'status-fresh';
|
||||||
|
else if (row.pricing_status === 'Manual Override') status_class = 'status-fresh';
|
||||||
|
else if (row.pricing_status === 'Expired') status_class = 'status-expired';
|
||||||
|
else if (row.pricing_status === 'Error') status_class = 'status-error';
|
||||||
|
|
||||||
|
let age = row.days_since_pricing || 0;
|
||||||
|
let age_badge = age < 90 ? 'status-fresh' : (age < 120 ? 'status-aging' : 'status-expired');
|
||||||
|
|
||||||
|
html += `<tr>
|
||||||
|
<td>${frappe.utils.escape_html(row.manufacturer || '')}</td>
|
||||||
|
<td>${frappe.utils.escape_html(row.model || '')}</td>
|
||||||
|
<td><span class="badge ${status_class}">${row.pricing_status}</span></td>
|
||||||
|
<td><span class="badge ${age_badge}">${age} days</span></td>
|
||||||
|
<td>$${row.price_low || ''}</td>
|
||||||
|
<td>$${row.price_high || ''}</td>
|
||||||
|
<td>$${row.price_average || ''}</td>
|
||||||
|
<td>${row.sample_count || ''}</td>
|
||||||
|
<td>${row.source || ''}</td>
|
||||||
|
<td>${frappe.datetime.str_to_user(row.scraped_at) || ''}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
$area.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
load_recent_pricing();
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"creation": "2026-05-17 05:30:00.000000",
|
||||||
|
"docstatus": 0,
|
||||||
|
"doctype": "Page",
|
||||||
|
"icon": "fa fa-tags",
|
||||||
|
"modified": "2026-05-17 05:30:00.000000",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "Westech R2",
|
||||||
|
"name": "ebay_pricing",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"page_name": "eBay Pricing",
|
||||||
|
"roles": [
|
||||||
|
{"role": "System Manager"},
|
||||||
|
{"role": "Stock User"},
|
||||||
|
{"role": "Sales User"}
|
||||||
|
],
|
||||||
|
"standard": "Yes",
|
||||||
|
"system_page": 0,
|
||||||
|
"title": "eBay Pricing"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe import _
|
||||||
|
|
||||||
|
no_cache = 1
|
||||||
|
|
||||||
|
def get_context(context):
|
||||||
|
context.no_cache = 1
|
||||||
|
context.title = _("eBay Pricing")
|
||||||
|
return context
|
||||||
Binary file not shown.
Reference in New Issue
Block a user