wip: receiving dashboard, customer records, archive old 2-level paths, various fixes

This commit is contained in:
vagrant
2026-05-28 03:30:45 +00:00
parent d4ed4b1d89
commit 6fe6d61779
141 changed files with 4247 additions and 1175 deletions
@@ -0,0 +1,3 @@
from westech_r2.api import sales
from westech_r2.api import receiving_api
@@ -0,0 +1,166 @@
import frappe
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.lib.colors import HexColor, black, white, grey
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
from reportlab.lib import colors
import io
import os
DARK_BLUE = HexColor('#2F5496')
LIGHT_BLUE = HexColor('#D6E4F0')
GRAY = HexColor('#666666')
@frappe.whitelist()
def generate_cor(company_name=None, weights=None, received_date=None, red_r2=None, contact_name=None, contact_number=None, address_line=None, pallet_name=None):
"""Generate Certificate of Recycling PDF from form data."""
# Format date
date_str = ''
if received_date:
from frappe.utils import formatdate
date_str = formatdate(received_date, 'MMMM d, Y')
items_recycled = 'e-waste'
if red_r2:
items_recycled += ' (' + red_r2 + ')'
output = io.BytesIO()
doc = SimpleDocTemplate(
output,
pagesize=letter,
topMargin=0.5 * inch,
bottomMargin=0.5 * inch,
leftMargin=0.75 * inch,
rightMargin=0.75 * inch
)
styles = getSampleStyleSheet()
# Custom styles matching the Electron app
date_style = ParagraphStyle('DateBlock', parent=styles['Normal'], fontSize=14, fontName='Times-Bold', alignment=TA_LEFT)
title_style = ParagraphStyle('CertTitle', parent=styles['Title'], fontSize=16, fontName='Times-Bold', textColor=black, spaceAfter=6, alignment=TA_CENTER, letterSpacing=0.05)
cert_style = ParagraphStyle('CertBody', parent=styles['Normal'], fontName='Times-Roman', fontSize=12, spaceAfter=12, alignment=TA_JUSTIFY)
body_style = ParagraphStyle('BodyText2', parent=styles['Normal'], fontName='Times-Roman', fontSize=12, spaceAfter=10, alignment=TA_JUSTIFY)
bullet_style = ParagraphStyle('BulletText', parent=styles['Normal'], fontName='Times-Roman', fontSize=10, spaceAfter=4, leftIndent=24, bulletIndent=12, alignment=TA_JUSTIFY)
optin_style = ParagraphStyle('OptIn', parent=styles['Normal'], fontName='Times-Roman', fontSize=12, spaceAfter=10, alignment=TA_JUSTIFY)
sig_style = ParagraphStyle('Signature', parent=styles['Normal'], fontName='Times-Bold', fontSize=18, spaceBefore=18)
footer_style = ParagraphStyle('Footer', parent=styles['Normal'], fontName='Times-Roman', fontSize=10, textColor=GRAY)
elements = []
# Header row: Date | Logo | Title
logo_path = os.path.join(frappe.get_app_path('westech_r2'), 'public', 'images', 'cor_logo.png')
logo_img = None
if os.path.exists(logo_path):
logo_img = Image(logo_path, width=2.45 * inch, height=0.8 * inch)
header_data = [
[Paragraph(date_str, date_style), logo_img or Paragraph('', styles['Normal']), Paragraph('CERTIFICATE OF RECYCLING', title_style)]
]
header_table = Table(header_data, colWidths=[1.8 * inch, 2.45 * inch, 2.75 * inch])
header_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, 0), 'LEFT'),
('ALIGN', (1, 0), (1, 0), 'CENTER'),
('ALIGN', (2, 0), (2, 0), 'CENTER'),
]))
elements.append(header_table)
elements.append(Spacer(1, 18))
# Certification paragraph
elements.append(Paragraph(
'Full Circle Electronics AZ, LLC (dba Westech Recyclers) certifies that the '
'materials submitted for recycling are received and will be properly recycled '
'in accordance with all state and federal recycling regulations and in '
'accordance with the R2 Standard.',
cert_style
))
# Data table
data_rows = [
['Company:', company_name or 'N/A'],
['Weight:', weights or 'N/A'],
['Items Recycled:', items_recycled],
]
if contact_name:
data_rows.append(['Contact:', contact_name])
if contact_number:
data_rows.append(['Phone:', contact_number])
if address_line:
data_rows.append(['Address:', address_line])
data_table = Table(data_rows, colWidths=[3.36 * inch, 3.64 * inch])
data_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Times-Roman'),
('FONTSIZE', (0, 0), (-1, -1), 12),
('ALIGN', (0, 0), (0, -1), 'RIGHT'),
('ALIGN', (1, 0), (1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bfbfbf')),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
]))
elements.append(data_table)
elements.append(Spacer(1, 12))
# Body paragraphs
elements.append(Paragraph(
'Full Circle Electronics AZ, LLC further acknowledges the acceptance and '
'recycling of any material potentially containing data. Data containing '
'materials are stored in our secured facility ensuring the security of the '
'unit(s) prior to data sanitization.',
body_style
))
elements.append(Paragraph(
'Data containing materials are sanitized in compliance with NIST 800-88 '
'guidelines which is set forth by the U.S. government for a robust methodology '
'for erasing data from storage media. Depending upon the media received, the '
'data destruction methods used are as follows:',
body_style
))
# Bullet list
bullets = [
'Hard disk and solid-state drives will either be logically sanitized using professional software or physically destroyed via shredding or degaussing.',
'Media cards and small storage devices will either be degaussed / shredded at our facility or sent straight to a smelter.',
'Data tapes or reels will either be degaussed or shredded at a vetted and approved downstream service provider.',
'Electronics with embedded storage chips will either be destroyed by physical destruction at our facility or at a vetted and approved downstream service provider.',
'Small electronics containing data will either be logically sanitized using the manufacturer\'s application for destroying data or sent to a vetted and approved downstream service provider.',
]
for b in bullets:
elements.append(Paragraph('\u2022 ' + b, bullet_style))
elements.append(Spacer(1, 6))
# Opt-in
elements.append(Paragraph(
'Opt-in option. If you desire to be informed of our data destruction process '
'changes or be notified of any unlikely security breaches, please let us know.',
optin_style
))
# Signature
elements.append(Paragraph('Westech Recyclers', sig_style))
# Footer
elements.append(Spacer(1, 10))
elements.append(Paragraph(
'220 S 9th St Phoenix, AZ 85034    '
'<link href="http://www.westechrecyclers.com" color="#1155cc">www.westechrecyclers.com</link> &nbsp;&nbsp; '
'602.256.7626',
footer_style
))
doc.build(elements)
output.seek(0)
frappe.response.filename = 'COR_' + (company_name or 'document').replace(' ', '_') + '.pdf'
frappe.response.filecontent = output.getvalue()
frappe.response.type = 'download'
frappe.response.display_content_as = 'attachment'
@@ -0,0 +1,546 @@
import sys
sys.path.insert(0, '/home/frappe/erpnext-bench/apps/frappe')
import frappe
from frappe.utils import now, now_datetime, flt
from frappe import _
import json
import re
import time
import urllib.parse
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():
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():
settings = _get_settings()
user = password = ""
if settings:
user = settings.get("oxylabs_user") or ""
try:
password = settings.get_password("oxylabs_password") or ""
except Exception:
pass
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():
settings = _get_settings()
token = ""
if settings:
try:
token = settings.get_password("apify_token") or ""
except Exception:
pass
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"):
if not query and not (manufacturer and model):
return {"error": "Provide query or manufacturer + model"}
if not manufacturer or not 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:
_upsert_system_pricing(manufacturer, model, pricing)
_update_item_market_data(manufacturer, model, pricing)
_log_api_call(manufacturer, model, query, used_source,
len(items) if items else 0, "Success")
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:
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:
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|'
r'For parts or not working|Seller refurbished|New with defects|'
r'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:
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"):
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:
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"
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 = []
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": source or "unknown",
"scraped_at": now(),
}
def _upsert_system_pricing(manufacturer, model, pricing):
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", "scraped_at"):
if key in pricing:
setattr(doc, key, pricing[key])
# Fix source to match allowed values
if "source" in pricing:
raw_source = pricing["source"]
if raw_source.startswith("ebay_"):
raw_source = raw_source.replace("ebay_", "")
if raw_source not in ("oxylabs", "apify"):
raw_source = "unknown"
setattr(doc, "source", raw_source)
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 _update_item_market_data(manufacturer, model, pricing):
clean_mfr = clean_manufacturer(manufacturer)
model_lower = model.lower()
# Match by brand (exact) + item_name (contains model)
items = frappe.get_all(
"Item",
filters={
"item_group": ["in", ["Laptop", "Desktop", "Tablet", "Phone", "Workstation"]],
"disabled": 0,
"brand": clean_mfr,
},
fields=["name", "item_name"],
)
matched = None
for item in items:
item_name_lower = (item.item_name or "").lower()
if model_lower in item_name_lower:
matched = item.name
break
if not matched:
return
item_doc = frappe.get_doc("Item", matched)
item_doc.base_market_price = pricing.get("price_average", 0)
item_doc.market_high = pricing.get("price_high", 0)
item_doc.market_low = pricing.get("price_low", 0)
item_doc.market_median = pricing.get("price_auction", 0)
item_doc.market_average = pricing.get("price_average", 0)
item_doc.market_samples = pricing.get("sample_count", 0)
item_doc.market_last_priced = pricing.get("scraped_at")
item_doc.save()
frappe.db.commit()
return {"item": matched, "updated": True}
def _log_api_call(manufacturer, model, search_query, source, results_count, status):
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):
batch_size = int(batch_size) if batch_size != "all" else 999999
force = bool(force)
# Get unique models from Items that have serials
models = frappe.db.sql(
"""
SELECT DISTINCT i.brand as manufacturer, i.item_name as model
FROM `tabItem` i
INNER JOIN `tabSerial No` sn ON sn.item_code = i.name
WHERE i.brand IS NOT NULL AND i.brand != ''
AND i.disabled = 0
ORDER BY i.modified DESC
LIMIT %s
""",
(batch_size,),
as_dict=True,
)
priced = failed = skipped = 0
for row in models:
mfr = row.manufacturer
mdl = row.model
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)
_update_item_market_data(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
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):
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
@frappe.whitelist()
def apply_serial_pricing(serial_no):
serial = frappe.get_doc("Serial No", serial_no)
if not serial.item_code:
return {"error": "No item linked"}
item = frappe.get_doc("Item", serial.item_code)
grade = serial.grade
price_point = serial.price_point
if not grade:
serial.pricing_status = "Needs Grading"
serial.save()
return {"status": "needs_grading"}
if grade in ("C", "D", "F"):
serial.assigned_price = item.commodity_flat_price or 0
serial.commodity_value = item.commodity_flat_price or 0
serial.pricing_status = "Commodity"
serial.pricing_source = item.name
serial.save()
frappe.db.commit()
return {"status": "commodity", "price": serial.assigned_price}
if not price_point:
serial.pricing_status = "Needs Pricing"
serial.save()
return {"status": "needs_price_point"}
base_price = 0
if price_point == "Low":
base_price = item.market_low or item.base_market_price or 0
elif price_point == "Median":
base_price = item.market_median or item.base_market_price or 0
elif price_point == "Average":
base_price = item.market_average or item.base_market_price or 0
elif price_point == "High":
base_price = item.market_high or item.base_market_price or 0
elif price_point == "Manual":
base_price = serial.manual_price or 0
multiplier = 1.0
if grade == "A":
multiplier = item.grade_a_multiplier or 1.0
elif grade == "B":
multiplier = item.grade_b_multiplier or 0.8
final_price = flt(base_price) * flt(multiplier)
serial.assigned_price = round(final_price, 2)
serial.pricing_status = "Priced" if price_point != "Manual" else "Manual Override"
serial.pricing_source = item.name
serial.save()
frappe.db.commit()
return {
"status": serial.pricing_status,
"price": serial.assigned_price,
"base": base_price,
"multiplier": multiplier,
}
@frappe.whitelist()
def batch_apply_pricing(item_code=None, batch_size=50):
filters = {"pricing_status": ["in", ["Needs Grading", "Needs Pricing", "Priced"]]}
if item_code:
filters["item_code"] = item_code
serials = frappe.get_all(
"Serial No",
filters=filters,
fields=["name", "item_code", "grade", "price_point"],
limit=int(batch_size),
)
results = {"priced": 0, "commodity": 0, "needs_grading": 0, "needs_price_point": 0, "errors": 0}
for s in serials:
try:
result = apply_serial_pricing(s.name)
status = result.get("status", "")
if status == "commodity":
results["commodity"] += 1
elif status == "needs_grading":
results["needs_grading"] += 1
elif status == "needs_price_point":
results["needs_price_point"] += 1
elif status in ("Priced", "Manual Override"):
results["priced"] += 1
else:
results["errors"] += 1
except Exception as e:
frappe.log_error(f"Pricing error for {s.name}: {e}", "eBay Pricing")
results["errors"] += 1
return results
@@ -0,0 +1,6 @@
import frappe
@frappe.whitelist()
def install_ssh():
"""Placeholder for SSH install functionality."""
return {"success": True, "message": "SSH install endpoint ready"}
@@ -0,0 +1,127 @@
import json
import re
import frappe
@frappe.whitelist()
def optimize_routes(pickup_date=None):
"""Optimize routes for all trucks on a given date."""
if not pickup_date:
pickup_date = frappe.utils.today()
trucks = frappe.get_list("Truck Profile",
filters={"active": 1},
fields=["name", "truck_name", "total_slots", "weight_capacity"]
)
pickups = frappe.get_list("Scheduled Pickup",
filters={
"pickup_date": pickup_date,
"shipment_type": "Truck",
"truck_profile": ["is", "not set"]
},
fields=["name", "customer_number", "company_name", "estimated_items",
"estimated_weight", "gaylord_count", "gaylord_sizes", "slots_needed",
"latitude", "longitude"],
order_by="stop_order asc"
)
for pickup in pickups:
if not pickup.slots_needed and pickup.gaylord_sizes:
pickup.slots_needed = _calculate_slots(pickup.gaylord_sizes)
elif not pickup.slots_needed:
pickup.slots_needed = pickup.gaylord_count or 1
pickups.sort(key=lambda x: x.slots_needed or 0, reverse=True)
routes = {}
for truck in trucks:
routes[truck.name] = {
"truck": truck,
"pickups": [],
"used_slots": 0,
"used_weight": 0
}
unassigned = []
for pickup in pickups:
assigned = False
for truck_name, route in routes.items():
truck = route["truck"]
slots = pickup.slots_needed or 0
weight = float(pickup.estimated_weight or 0)
if route["used_slots"] + slots <= truck.total_slots:
if truck.weight_capacity and route["used_weight"] + weight <= truck.weight_capacity:
route["pickups"].append(pickup)
route["used_slots"] += slots
route["used_weight"] += weight
assigned = True
frappe.db.set_value("Scheduled Pickup", pickup.name, "truck_profile", truck_name)
break
elif not truck.weight_capacity:
route["pickups"].append(pickup)
route["used_slots"] += slots
route["used_weight"] += weight
assigned = True
frappe.db.set_value("Scheduled Pickup", pickup.name, "truck_profile", truck_name)
break
if not assigned:
unassigned.append(pickup)
for truck_name, route in routes.items():
if route["pickups"]:
route["pickups"].sort(key=lambda p: (float(p.latitude or 0), float(p.longitude or 0)))
for i, pickup in enumerate(route["pickups"], 1):
frappe.db.set_value("Scheduled Pickup", pickup.name, "stop_order", i)
frappe.db.commit()
return {
"success": True,
"date": pickup_date,
"trucks_assigned": len([r for r in routes.values() if r["pickups"]]),
"total_pickups": len(pickups),
"unassigned": len(unassigned),
"routes": {
truck_name: {
"truck_name": route["truck"].truck_name,
"slots_used": route["used_slots"],
"slots_total": route["truck"].total_slots,
"weight_used": route["used_weight"],
"weight_capacity": route["truck"].weight_capacity,
"stops": len(route["pickups"]),
"pickups": [{"name": p.name, "company": p.company_name, "slots": p.slots_needed} for p in route["pickups"]]
}
for truck_name, route in routes.items() if route["pickups"]
}
}
def _calculate_slots(gaylord_sizes_text):
if not gaylord_sizes_text:
return 0
size_map = {"small": 1, "medium": 2, "large": 3}
total = 0
matches = re.findall(r'(\d+)\s*(\w+)', gaylord_sizes_text.lower())
for count, size in matches:
slots = size_map.get(size, 1)
total += int(count) * slots
return total or 1
@frappe.whitelist()
def get_scheduled_pickups(pickup_date=None):
"""Get scheduled pickups for a given date."""
if not pickup_date:
pickup_date = frappe.utils.today()
pickups = frappe.get_list("Scheduled Pickup",
filters={"pickup_date": pickup_date},
fields=["name", "customer_number", "company_name", "estimated_items",
"estimated_weight", "gaylord_count", "gaylord_sizes", "slots_needed",
"latitude", "longitude", "stop_order", "truck_profile", "status",
"contact_name", "contact_phone", "address_line", "city", "state", "zip_code"],
order_by="stop_order asc"
)
return pickups
+68
View File
@@ -0,0 +1,68 @@
import frappe
from frappe import _
@frappe.whitelist()
def get_qa_ready_serials(limit=50):
"""Get Serial Nos ready for QA."""
return frappe.get_all('Serial No',
filters={'r2_status': 'Needs QA'},
fields=['name', 'item_code', 'item_name', 'pallet', 'cosmetic_grade'],
limit=limit,
order_by='creation asc'
)
@frappe.whitelist()
def create_qa_from_serial(serial_no):
"""Create QA inspection for a Serial No."""
if not frappe.db.exists('Serial No', serial_no):
return {'error': 'Serial No not found'}
serial = frappe.get_doc('Serial No', serial_no)
existing = frappe.db.get_value('R2 Device Inspection',
{'serial_no': serial_no, 'docstatus': ['!=', 2]}, 'name')
if existing:
return {'error': f'Inspection {existing} already exists'}
insp = frappe.get_doc({
'doctype': 'R2 Device Inspection',
'serial_no': serial_no,
'item_code': serial.item_code,
'inspection_date': frappe.utils.today(),
'inspector': frappe.session.user,
'cosmetic_grade': serial.cosmetic_grade,
'screen_condition': serial.screen_condition,
'keyboard_condition': serial.keyboard_condition,
'case_condition': serial.case_condition
})
insp.insert(ignore_permissions=True)
return {'success': True, 'inspection': insp.name}
@frappe.whitelist()
def auto_grade(serial_no, grade='C5'):
"""Auto-grade a device and move to Priced state."""
serial = frappe.get_doc('Serial No', serial_no)
serial.cosmetic_grade = grade
serial.r2_status = 'Processed'
serial.save(ignore_permissions=True)
# Apply flat pricing
item = frappe.db.get_value('Item', serial.item_code, 'item_group')
flat_prices = {
'Laptops': {'c3': 250, 'c4': 200, 'c5': 150, 'c6': 100, 'c7': 60, 'c8': 30, 'c9': 15},
'Desktops': {'c3': 180, 'c4': 150, 'c5': 120, 'c6': 80, 'c7': 50, 'c8': 25, 'c9': 10},
'Tablets': {'c3': 120, 'c4': 100, 'c5': 80, 'c6': 50, 'c7': 30, 'c8': 15, 'c9': 8},
'Phones': {'c3': 100, 'c4': 80, 'c5': 60, 'c6': 40, 'c7': 25, 'c8': 12, 'c9': 5}
}
prices = flat_prices.get(item, flat_prices['Laptops'])
price = prices.get(grade.lower(), 100)
serial.suggested_price = price
serial.assigned_price = price
serial.pricing_status = 'Priced'
serial.price_point = grade
serial.r2_status = 'Priced'
serial.save(ignore_permissions=True)
return {'success': True, 'price': price}
@@ -0,0 +1,499 @@
import json
import frappe
from frappe.utils import today, getdate, add_days
from datetime import timedelta
@frappe.whitelist()
def get_pickups(date=None):
"""Fetch scheduled pickups with optional date filter.
Returns pickups, calendar (next 30 days), and weekly chart data."""
filters = []
if date:
filters.append(["Scheduled Pickup", "pickup_date", "=", date])
fields = [
"name", "pickup_date", "pickup_type", "status", "truck", "stop_order",
"customer_number", "company_name",
"contact_name", "contact_phone", "contact_email",
"address_line", "city", "state", "zip_code",
"latitude", "longitude",
"estimated_items", "estimated_weight", "load_contents",
"num_labels", "data_status", "red_r2",
"notes", "legacy_notes", "needs_aor", "needs_cod",
]
pickups = frappe.get_list("Scheduled Pickup",
fields=fields,
filters=filters if filters else None,
order_by="pickup_date asc, stop_order asc",
limit_page_length=500,
)
# Build calendar data (next 30 days)
from_date = getdate(today())
to_date = add_days(from_date, 30)
all_pickups = frappe.get_list("Scheduled Pickup",
fields=["pickup_date"],
filters=[["Scheduled Pickup", "pickup_date", ">=", str(from_date)],
["Scheduled Pickup", "pickup_date", "<=", str(to_date)]],
limit_page_length=500,
)
pickup_counts = {}
for p in all_pickups:
d = p.get("pickup_date", "")
if d:
pickup_counts[d] = pickup_counts.get(d, 0) + 1
calendar = []
for i in range(30):
d = add_days(from_date, i)
ds = str(d)
calendar.append({"date": ds, "count": pickup_counts.get(ds, 0)})
# Build weekly chart data (last 12 weeks)
weekly = []
for i in range(11, -1, -1):
week_start = add_days(from_date, -(from_date.weekday() + 7 * i))
week_end = add_days(week_start, 6)
count = 0
for d_str, c in pickup_counts.items():
try:
d = getdate(d_str)
if week_start <= d <= week_end:
count += c
except (ValueError, TypeError):
pass
weekly.append({"label": week_start.strftime("%m/%d"), "count": count})
return {
"pickups": pickups,
"calendar": calendar,
"weekly": weekly,
}
@frappe.whitelist()
def auto_route(date=None):
"""Auto-assign pickups to trucks based on capacity and proximity."""
if not date:
date = today()
pickups = frappe.get_list("Scheduled Pickup",
filters={"pickup_date": date},
fields=["name", "company_name", "estimated_items", "estimated_weight",
"latitude", "longitude", "pickup_type"],
limit_page_length=200,
)
if not pickups:
return {"success": True, "assigned": 0}
trucks = ["Truck 1", "Truck 2", "Truck 3"]
sorted_p = sorted(pickups, key=lambda p: (float(p.get("latitude") or 0), float(p.get("longitude") or 0)))
n = len(sorted_p)
assigned = 0
for i, p in enumerate(sorted_p):
if p.get("pickup_type") == "Drop-off":
truck = ""
else:
truck = trucks[i % 3] if n <= 3 else trucks[min(i * 3 // n, 2)]
doc = frappe.get_doc("Scheduled Pickup", p["name"])
doc.truck = truck
doc.status = "Routed" if truck else "Scheduled"
doc.stop_order = i + 1
doc.save()
assigned += 1
frappe.db.commit()
return {"success": True, "assigned": assigned}
@frappe.whitelist()
def get_checkins():
"""Fetch completed check-ins — returns Loads with their Pallets."""
loads = frappe.get_list("Load",
fields=["name", "load_number", "incoming_date", "customer", "customer_name",
"total_devices", "total_weight", "data_status", "red_r2"],
order_by="incoming_date desc",
limit_page_length=100,
)
for load in loads:
pallets = frappe.get_list("Pallet",
filters={"load": load.name},
fields=["name", "pallet_number", "received_date", "inbound_weight",
"total_items", "data_status", "red_r2", "description", "status"],
limit_page_length=50,
)
load["pallets"] = pallets
load["pallet_count"] = len(pallets)
return {"checkins": loads}
@frappe.whitelist()
def checkin_load(pickup_name, received_date, actual_pallets, total_weight, load_contents, data_status=None, red_r2=None):
"""Check in a load: create Load + Pallets, mark pickup Complete."""
# Get the pickup
pickup = frappe.get_doc("Scheduled Pickup", pickup_name)
if not actual_pallets or int(actual_pallets) < 1:
frappe.throw("Actual pallet count must be at least 1")
actual_pallets = int(actual_pallets)
# Resolve customer - customer_number on pickup is a Link to Customer
customer_id = pickup.customer_number
if not customer_id or not frappe.db.exists("Customer", customer_id):
frappe.throw("Customer {} not found. Please verify the customer on the pickup.".format(customer_id))
# Generate Load name: MMDDYYYY-CustomerNumber format
from datetime import datetime
try:
dt = datetime.strptime(received_date, "%Y-%m-%d")
date_part = dt.strftime("%m%d%Y")
except (ValueError, TypeError):
date_part = received_date.replace("-", "")
cust_num = customer_id
load_name = "{}-{}".format(date_part, cust_num)
# Make unique if name already exists
base_name = load_name
counter = 1
while frappe.db.exists("Load", load_name):
load_name = "{}-{}".format(base_name, counter)
counter += 1
# Create Load
load = frappe.get_doc({
"doctype": "Load",
"name": load_name,
"load_number": load_name,
"incoming_date": received_date,
"customer": customer_id,
"customer_name": pickup.company_name or "",
"customer_number": customer_id,
"data_status": data_status or pickup.data_status or "",
"red_r2": red_r2 or pickup.red_r2 or "",
"total_weight": float(total_weight) if total_weight else 0,
"total_devices": actual_pallets,
"material_items": [{
"material_type": "# Of Pallets",
"total_count": actual_pallets,
"weight": float(total_weight) if total_weight else 0,
"initial_data_status": data_status or pickup.data_status or "D0",
}],
})
load.insert()
frappe.db.commit()
# Create Pallets — autoname=pallet_number, so set name=pallet_number
for i in range(actual_pallets):
pallet_num = "{}-P{}".format(load_name, i + 1)
pallet = frappe.get_doc({
"doctype": "Pallet",
"name": pallet_num,
"pallet_number": pallet_num,
"received_date": received_date,
"load": load.name,
"company_name": pickup.company_name or "",
"inbound_weight": str(round(float(total_weight) / actual_pallets, 1)) if total_weight and actual_pallets else "",
"description": load_contents or "",
"data_status": data_status or pickup.data_status or "",
"red_r2": red_r2 or pickup.red_r2 or "",
"contact_name": pickup.contact_name or "",
"contact_number": pickup.contact_phone or "",
"contact_email": pickup.contact_email or "",
"address_line": (pickup.address_line or "") + ((", " + pickup.city) if pickup.city else "") + ((", " + pickup.state) if pickup.state else ""),
"needs_aor": pickup.needs_aor or 0,
"needs_cod": pickup.needs_cod or 0,
"notes": pickup.notes or "",
"pickup": pickup.pickup_type or "",
"status": "Received",
})
pallet.insert()
frappe.db.commit()
# Update pickup status
pickup.status = "Complete"
pickup.save()
frappe.db.commit()
return {
"success": True,
"load": load.name,
"pallets_created": actual_pallets,
}
@frappe.whitelist()
def get_pickup_details(pickup_name):
"""Get full details of a Scheduled Pickup for the check-in form."""
pickup = frappe.get_doc("Scheduled Pickup", pickup_name)
return {
"name": pickup.name,
"pickup_date": pickup.pickup_date,
"pickup_type": pickup.pickup_type,
"customer_number": pickup.customer_number,
"company_name": pickup.company_name,
"contact_name": pickup.contact_name,
"contact_phone": pickup.contact_phone,
"contact_email": pickup.contact_email,
"address_line": pickup.address_line,
"city": pickup.city,
"state": pickup.state,
"zip_code": pickup.zip_code,
"estimated_items": pickup.estimated_items,
"estimated_weight": pickup.estimated_weight,
"data_status": pickup.data_status,
"red_r2": pickup.red_r2,
"needs_aor": pickup.needs_aor,
"needs_cod": pickup.needs_cod,
"notes": pickup.notes,
}
@frappe.whitelist()
def cor_report():
"""Generate Certificate of Recycling report."""
pickups = frappe.get_list("Scheduled Pickup",
filters={"status": "Complete"},
fields=["name", "pickup_date", "company_name", "customer_number",
"estimated_items", "estimated_weight", "load_contents",
"data_status", "red_r2", "needs_aor", "needs_cod"],
order_by="pickup_date desc",
limit_page_length=200,
)
html = "<!DOCTYPE html><html><head><title>CoR Report</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:40px;}"
html += "table{border-collapse:collapse;width:100%;}"
html += "th,td{border:1px solid #ddd;padding:8px;text-align:left;font-size:13px;}"
html += "th{background:#2F5496;color:#fff;}h1{color:#2F5496;}</style></head><body>"
html += "<h1>Certificate of Recycling (CoR) Report</h1>"
html += "<p>Generated: " + frappe.utils.now() + "</p>"
html += "<p>Total completed loads: " + str(len(pickups)) + "</p>"
if pickups:
html += "<table><tr><th>Date</th><th>Customer</th><th>Items</th><th>Weight</th>"
html += "<th>Contents</th><th>Data Status</th><th>RED/R2</th><th>AoR</th><th>CoD</th></tr>"
for p in pickups:
html += "<tr><td>" + str(p.get("pickup_date", "")) + "</td>"
html += "<td>" + str(p.get("company_name", "")) + "</td>"
html += "<td>" + str(p.get("estimated_items", "")) + "</td>"
html += "<td>" + str(p.get("estimated_weight", "")) + "</td>"
html += "<td>" + str(p.get("load_contents", "")) + "</td>"
html += "<td>" + str(p.get("data_status", "")) + "</td>"
html += "<td>" + str(p.get("red_r2", "")) + "</td>"
html += "<td>" + ("" if p.get("needs_aor") else "") + "</td>"
html += "<td>" + ("" if p.get("needs_cod") else "") + "</td></tr>"
html += "</table>"
else:
html += "<p>No completed loads found.</p>"
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
@frappe.whitelist()
def print_route_sheet(date=None):
"""Generate printable route sheet."""
if not date:
date = today()
filters = {"pickup_date": date} if date else {}
pickups = frappe.get_list("Scheduled Pickup",
filters=filters,
fields=["name", "pickup_date", "pickup_type", "status", "truck", "stop_order",
"company_name", "contact_name", "contact_phone", "contact_email",
"address_line", "city", "state", "zip_code",
"estimated_items", "estimated_weight", "load_contents",
"data_status", "red_r2", "needs_aor", "needs_cod", "notes"],
order_by="truck asc, stop_order asc",
limit_page_length=200,
)
trucks = {}
unassigned = []
for p in pickups:
t = p.get("truck", "")
if t and t != "Unassigned":
trucks.setdefault(t, []).append(p)
else:
unassigned.append(p)
html = "<!DOCTYPE html><html><head><title>Route Sheet</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:30px;}"
html += "table{border-collapse:collapse;width:100%;margin-bottom:12px;}"
html += "th,td{border:1px solid #999;padding:6px 10px;text-align:left;font-size:12px;}"
html += "th{background:#2F5496;color:#fff;}h1{color:#2F5496;font-size:20px;}"
html += ".truck-header{background:#f0f0f0;padding:8px;font-weight:700;font-size:14px;border:1px solid #ccc;}"
html += "@media print{body{margin:10px;}}</style></head><body>"
html += "<h1>Route Sheet — " + str(date or "Today") + "</h1>"
for truck_name, stops in sorted(trucks.items()):
html += '<div class="truck-header">🚛 ' + truck_name + "" + str(len(stops)) + " stops</div>"
html += "<table><tr><th>#</th><th>Customer</th><th>Address</th><th>Contact</th>"
html += "<th>Items</th><th>Weight</th><th>Data</th><th>RED/R2</th><th>AoR</th><th>CoD</th><th>Notes</th></tr>"
for i, s in enumerate(stops, 1):
addr = str(s.get("address_line", "")) + ", " + str(s.get("city", "")) + ", " + str(s.get("state", "")) + " " + str(s.get("zip_code", ""))
html += "<tr><td>" + str(i) + "</td><td>" + str(s.get("company_name", "")) + "</td>"
html += "<td>" + addr + "</td><td>" + str(s.get("contact_name", "")) + "<br>" + str(s.get("contact_phone", "")) + "</td>"
html += "<td>" + str(s.get("estimated_items", "")) + "</td><td>" + str(s.get("estimated_weight", "")) + "</td>"
html += "<td>" + str(s.get("data_status", "")) + "</td><td>" + str(s.get("red_r2", "")) + "</td>"
html += "<td>" + ("" if s.get("needs_aor") else "") + "</td>"
html += "<td>" + ("" if s.get("needs_cod") else "") + "</td>"
html += "<td>" + str(s.get("notes", "")) + "</td></tr>"
html += "</table>"
if unassigned:
html += "<h2>Unassigned</h2><table><tr><th>Customer</th><th>Address</th><th>Notes</th></tr>"
for s in unassigned:
html += "<tr><td>" + str(s.get("company_name", "")) + "</td>"
html += "<td>" + str(s.get("address_line", "")) + "</td>"
html += "<td>" + str(s.get("notes", "")) + "</td></tr>"
html += "</table>"
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
@frappe.whitelist()
def print_green_sheet(date=None):
"""Generate Green Sheet printout for each pallet.
Shows customer info, service level banner, driver instructions, RED LINE instructions."""
if not date:
date = today()
# Get completed loads for this date
loads = frappe.get_list("Load",
filters={"incoming_date": date},
fields=["name", "load_number", "customer", "customer_name",
"incoming_date", "total_weight", "data_status", "red_r2"],
order_by="name asc",
limit_page_length=200,
)
html = "<!DOCTYPE html><html><head><title>Green Sheets</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:30px;}"
html += ".green-sheet{border:2px solid #2E7D32;border-radius:6px;padding:16px;margin:20px 0;page-break-after:always;}"
html += ".gs-title{color:#2E7D32;font-size:18px;font-weight:700;border-bottom:2px solid #2E7D32;padding-bottom:4px;}"
html += ".gs-customer{background:#E8F5E9;border:1px solid #66BB6A;border-radius:4px;padding:8px;margin:8px 0;}"
html += ".gs-service-banner{background:#C62828;color:#fff;font-size:16px;font-weight:700;text-align:center;padding:8px;border-radius:4px;margin:8px 0;}"
html += ".gs-driver{background:#F5F5F5;border:1px solid #999;border-radius:4px;padding:8px;margin:8px 0;}"
html += ".gs-redline{background:#FFCDD2;border:2px solid #C62828;border-radius:4px;padding:8px;margin:8px 0;}"
html += ".gs-r2warning{background:#FFF9C4;border:1px solid #F9A825;border-radius:4px;padding:8px;margin:8px 0;font-size:12px;}"
html += "table{border-collapse:collapse;width:100%;margin:8px 0;}"
html += "th,td{border:1px solid #999;padding:6px;text-align:left;font-size:12px;}"
html += "th{background:#2E7D32;color:#fff;}"
html += ".gs-footer{margin-top:12px;font-size:11px;color:#666;border-top:1px solid #ccc;padding-top:8px;}"
html += "@media print{body{margin:10px;}.green-sheet{page-break-after:always;}}</style></head><body>"
for load in loads:
pallets = frappe.get_list("Pallet",
filters={"load": load.name},
fields=["name", "pallet_number", "inbound_weight", "total_items",
"data_status", "red_r2", "description", "needs_aor", "needs_cod", "notes", "status"],
limit_page_length=50,
)
for pallet in pallets:
html += '<div class="green-sheet">'
html += '<div class="gs-title">🟢 GREEN SHEET — Data-Bearing Equipment Tracking</div>'
html += '<div style="text-align:right;font-size:12px;">Pallet # ' + str(pallet.get("pallet_number", "")) + ' | Load # ' + str(load.get("name", "")) + ' | ' + str(load.get("incoming_date", "")) + '</div>'
# Customer block
html += '<div class="gs-customer">'
service_level = ""
if pallet.get("red_r2"):
service_level = pallet.get("red_r2", "")
html += '<strong>(' + str(load.get("customer", "")) + ') — ' + str(service_level) + '' + str(load.get("customer_name", "")) + '</strong>'
html += '</div>'
# Service level banner (only for RED/NIST)
rr = pallet.get("red_r2", "")
if rr and rr != "Neither":
html += '<div class="gs-service-banner">SERVICE LEVEL: ' + str(rr).upper() + '</div>'
# Driver instructions (notes)
if pallet.get("notes"):
html += '<div class="gs-driver"><strong>Driver Instructions:</strong><br>' + str(pallet.get("notes", "")) + '</div>'
# RED LINE instructions (for RED/NIST)
if rr and rr not in ("", "Neither", "R2"):
html += '<div class="gs-redline"><strong>⚠ RED LINE INSTRUCTIONS</strong><br>All data-bearing equipment must be tracked. Destruction method per customer specification.</div>'
# Pallet details table
html += '<table><tr><th>Pallet Designation</th><th>Data Status</th></tr>'
html += '<tr><td>' + str(pallet.get("status", "Received")) + '</td><td>' + str(pallet.get("data_status", "")) + '</td></tr>'
html += '<tr><th>Inbound Weight</th><th>Total Items</th></tr>'
html += '<tr><td>' + str(pallet.get("inbound_weight", "")) + '</td><td>' + str(pallet.get("total_items", "")) + '</td></tr>'
html += '<tr><th>AoR/CoR</th><th>Contents</th></tr>'
aor_cor = ""
if pallet.get("needs_aor"): aor_cor += "✓ AoR "
if pallet.get("needs_cod"): aor_cor += "✓ CoD"
html += '<tr><td>' + (aor_cor or "None") + '</td><td>' + str(pallet.get("description", "")) + '</td></tr>'
html += '</table>'
# Material tracking (hand-write on paper)
html += '<table><tr><th>Material</th><th>%</th><th>Weight</th><th>Sign Off</th><th>Date</th></tr>'
for _ in range(4):
html += '<tr><td>&nbsp;</td><td></td><td></td><td></td><td></td></tr>'
html += '</table>'
# R2 warning
html += '<div class="gs-r2warning">⚠ R2 REQUIREMENT: This pallet contains data-bearing equipment. All devices must be tracked through erasure with 5% verification audit.</div>'
# Signatures
html += '<table><tr><th>Received By</th><th>Inspected By</th><th>Verified By</th></tr>'
html += '<tr><td>&nbsp;</td><td>&nbsp;</td><td>&nbsp;</td></tr></table>'
# Footer
html += '<div class="gs-footer">Westech Electronics • Green Sheet — Pallet # ' + str(pallet.get("pallet_number", "")) + ' • Printed ' + str(frappe.utils.nowdate()) + ' • KEEP WITH PALLET AT ALL TIMES</div>'
html += '</div>'
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
@frappe.whitelist()
def print_labels(date=None):
"""Generate printable labels."""
if not date:
date = today()
filters = {"pickup_date": date} if date else {}
pickups = frappe.get_list("Scheduled Pickup",
filters=filters,
fields=["name", "company_name", "pickup_date", "num_labels", "data_status", "red_r2"],
limit_page_length=200,
)
html = "<!DOCTYPE html><html><head><title>Labels</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:10px;}"
html += ".label{border:2px solid #000;width:3in;height:1.5in;display:inline-block;margin:4px;padding:6px;font-size:11px;page-break-inside:avoid;}"
html += ".label-co{font-weight:700;font-size:14px;}.label-date{font-size:10px;color:#666;}"
html += ".label-status{font-size:10px;margin-top:2px;}"
html += "@media print{body{margin:0;}}</style></head><body>"
for p in pickups:
n = p.get("num_labels") or 1
for _ in range(n):
html += '<div class="label">'
html += '<div class="label-co">' + str(p.get("company_name", "")) + '</div>'
html += '<div class="label-date">' + str(p.get("pickup_date", "")) + '</div>'
html += '<div class="label-status">' + str(p.get("data_status", "")) + " | " + str(p.get("red_r2", "")) + '</div>'
html += '</div>'
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
+92
View File
@@ -0,0 +1,92 @@
import frappe
from frappe import _
@frappe.whitelist()
def quick_sell(serial_no, customer=None, payment_method='Cash'):
"""Create Sales Invoice for quick cash sale."""
try:
serial = frappe.get_doc('Serial No', serial_no)
if serial.r2_status != 'Ready for Sale':
return {'error': 'Device must be Ready for Sale'}
# Use default customer if none provided
if not customer:
customer = frappe.db.get_value('Customer', {}, 'name', order_by='creation asc')
if not customer:
# Create walk-in customer
customer = frappe.get_doc({
'doctype': 'Customer',
'customer_name': 'Walk-in Customer',
'customer_type': 'Individual'
}).insert(ignore_permissions=True).name
# Create Sales Invoice
price = serial.assigned_price or serial.suggested_price or 0
invoice = frappe.get_doc({
'doctype': 'Sales Invoice',
'customer': customer,
'serial_no': serial_no,
'device_condition': f"Cosmetic: {serial.cosmetic_grade or 'N/A'}",
'posting_date': frappe.utils.today(),
'due_date': frappe.utils.today(),
'items': [{
'item_code': serial.item_code,
'qty': 1,
'rate': price,
'amount': price,
'serial_no': serial_no
}],
'payments': [{
'mode_of_payment': payment_method,
'amount': price
}]
})
invoice.insert(ignore_permissions=True)
invoice.submit()
# Update Serial No
serial.r2_status = 'Sold'
serial.status = 'Delivered'
serial.customer = customer
serial.save(ignore_permissions=True)
return {
'success': True,
'invoice': invoice.name,
'customer': customer,
'amount': price
}
except Exception as e:
frappe.log_error(f"Quick sell failed: {str(e)}", "Sales")
return {'error': str(e)}
@frappe.whitelist()
def create_sales_order(quotation_name):
"""Create Sales Order from Quotation."""
try:
from erpnext.selling.doctype.quotation.quotation import make_sales_order
so = make_sales_order(quotation_name)
so.insert(ignore_permissions=True)
so.submit()
return {'success': True, 'sales_order': so.name}
except Exception as e:
return {'error': str(e)}
@frappe.whitelist()
def create_delivery_note(sales_order_name):
"""Create Delivery Note from Sales Order."""
try:
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
dn = make_delivery_note(sales_order_name)
dn.insert(ignore_permissions=True)
dn.submit()
return {'success': True, 'delivery_note': dn.name}
except Exception as e:
return {'error': str(e)}
+244
View File
@@ -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,
}
}
@@ -0,0 +1,37 @@
import frappe
from frappe import _
def validate_hardware_tests(doc, method):
"""Before save: if CPU or RAM test failed, route to Dismantle."""
# Check if this is a device-type serial (has item_code)
if not doc.item_code:
return
# Check CPU and RAM test results
cpu_fail = doc.get("cpu_test") == "Fail"
ram_fail = doc.get("ram_test") == "Fail"
if cpu_fail or ram_fail:
# Set grade to Flagged
doc.grade = "Flagged"
doc.pricing_status = "Dismantle"
doc.assigned_price = None
# Route to Dismantle warehouse if it exists
dismantle_wh = frappe.db.exists("Warehouse", "Dismantle - WR")
if dismantle_wh:
doc.warehouse = "Dismantle - WR"
# Log the failure reason
reasons = []
if cpu_fail:
reasons.append("CPU test failed")
if ram_fail:
reasons.append("RAM test failed")
frappe.msgprint(
_("Hardware failure detected: {0}. Device routed to Dismantle.").format(", ".join(reasons)),
indicator="red",
alert=True
)
@@ -0,0 +1,191 @@
import frappe
from frappe import _
# Mapping of material types to service categories
HDD_MATERIALS = {
"Loose Hard Drive", "External Hard Drive", "Printers/Copiers Hard Drives",
"Loose SSD or mSATA Drive", "Server", "Desktop", "Laptop", "Chromebook / Notebook",
"All In One", "HPStream", "Thin Clients", "Tablet", "Cell Phone / Smart Phone",
"Gaming Systems", "Smart TV", "POS", "POS Terminals", "DVR", "Switch",
"Network / Modems / Routers", "Office/ IP Phone", "Personal Electronics / PDA",
"CRT TV", "Printers/Copiers", "USB Drive", "SD Cards", "GPS"
}
TAPE_MATERIALS = {"CD / Floppy / DVD / Tapes"}
def _get_service_item(destruction_method, has_hardware=True):
"""Map destruction method to service item code."""
if destruction_method == "Wipe":
if has_hardware:
return "SVR-HDD-WIPE-1PASS" # Default to 1-pass; user can override
return "SVR-HDD-WIPE-3PASS-NOHW"
elif destruction_method in ("Shred", "Degauss"):
if has_hardware:
return "SVR-HDD-SERIAL-WIPE-HW"
return "SVR-HDD-SERIAL-WIPE-NOHW"
elif destruction_method == "None":
return None
return "SVR-HDD-WIPE-1PASS"
def _get_tape_item(destruction_method, has_hardware=True):
if has_hardware:
return "SVR-TAPE-SHRED"
return "SVR-TAPE-SHRED-NOHW"
def _get_onsite_item(has_hardware=True):
return "SVR-HDD-ONSITE-HW" if has_hardware else "SVR-HDD-ONSITE-NOHW"
def _calculate_tier_price(item_code, qty):
"""Return unit rate for given qty based on tier pricing."""
tiers = {
"SVR-HDD-WIPE-1PASS": [(1,10,7.00), (11,30,6.00), (31,50,4.50), (51,99,3.50), (100,999999,3.00)],
"SVR-HDD-WIPE-3PASS-HW": [(1,10,8.50), (11,30,7.00), (31,50,5.25), (51,99,4.25), (100,999999,3.50)],
"SVR-HDD-WIPE-3PASS-NOHW": [(1,10,14.00), (11,30,11.50), (31,50,8.40), (51,99,7.25), (100,999999,6.00)],
"SVR-HDD-SERIAL-WIPE-HW": [(1,10,9.00), (11,30,7.50), (31,50,6.00), (51,99,5.00), (100,999999,4.25)],
"SVR-HDD-SERIAL-WIPE-NOHW": [(1,10,14.50), (11,30,12.00), (31,50,10.00), (51,99,8.00), (100,999999,6.50)],
"SVR-HDD-ONSITE-HW": [(1,100,500.00), (101,999999,3.50)],
"SVR-HDD-ONSITE-NOHW": [(1,100,850.00), (101,999999,6.00)],
"SVR-TAPE-SHRED": [(1,10,4.00), (11,30,3.30), (31,50,2.70), (51,99,2.00), (100,999999,1.50)],
"SVR-TAPE-SHRED-NOHW": [(1,10,6.65), (11,30,5.50), (31,50,4.50), (51,99,3.35), (100,999999,2.50)],
"SVR-VIDEO-RECORD": [(1,999999,3.50)],
}
for item, tlist in tiers.items():
if item == item_code:
for min_qty, max_qty, rate in tlist:
if min_qty <= qty <= max_qty:
return rate
# Fallback to Item Price
rate = frappe.db.get_value("Item Price", {"item_code": item_code, "price_list": "2025 Service Rates", "selling": 1}, "price_list_rate")
return rate or 0
@frappe.whitelist()
def generate_service_invoice(load_name):
"""Generate a Sales Invoice from a Load document."""
load = frappe.get_doc("Load", load_name)
if load.invoice_generated:
frappe.throw(_("Invoice already generated for this load."))
if not load.customer:
frappe.throw(_("Load must have a Customer linked."))
# Gather quantities per service item
service_qty = {}
hdd_count = 0
tape_count = 0
total_items = 0
for item in load.material_items or []:
mt = item.material_type or ""
qty = item.total_count or 0
if qty <= 0:
continue
total_items += qty
if mt in HDD_MATERIALS:
hdd_count += qty
elif mt in TAPE_MATERIALS:
tape_count += qty
# Determine if on-site
is_onsite = (load.service_type or "") == "On-site"
destruction = load.destruction_method or "Wipe"
# For simplicity, assume "has hardware" = True unless explicitly set otherwise.
# TODO: Add custom field `has_hardware` on Load if needed.
has_hardware = True
invoice_items = []
if is_onsite and hdd_count > 0:
onsite_item = _get_onsite_item(has_hardware)
base_rate = _calculate_tier_price(onsite_item, hdd_count)
# Onsite: base fee + per-drive for extras above 100
if hdd_count <= 100:
invoice_items.append({
"item_code": onsite_item,
"qty": 1,
"rate": base_rate,
"description": f"On-site shredding for {hdd_count} drives"
})
else:
# One base fee + per-drive extras
invoice_items.append({
"item_code": onsite_item,
"qty": 1,
"rate": base_rate,
"description": f"On-site base fee (1-100 drives)"
})
extra = hdd_count - 100
per_drive_rate = _calculate_tier_price(onsite_item, hdd_count)
invoice_items.append({
"item_code": onsite_item,
"qty": extra,
"rate": per_drive_rate,
"description": f"Additional on-site drives ({extra})"
})
else:
# Standard pickup/mail-in pricing
if hdd_count > 0:
hdd_item = _get_service_item(destruction, has_hardware)
if hdd_item:
rate = _calculate_tier_price(hdd_item, hdd_count)
invoice_items.append({
"item_code": hdd_item,
"qty": hdd_count,
"rate": rate,
"description": f"{hdd_item} for {hdd_count} drives"
})
if tape_count > 0:
tape_item = _get_tape_item(destruction, has_hardware)
rate = _calculate_tier_price(tape_item, tape_count)
invoice_items.append({
"item_code": tape_item,
"qty": tape_count,
"rate": rate,
"description": f"{tape_item} for {tape_count} tapes"
})
# Video recording surcharge
if load.video_recording and total_items > 0:
vid_rate = _calculate_tier_price("SVR-VIDEO-RECORD", total_items)
invoice_items.append({
"item_code": "SVR-VIDEO-RECORD",
"qty": total_items,
"rate": vid_rate,
"description": f"Video recording surcharge for {total_items} items"
})
if not invoice_items:
frappe.throw(_("No billable items found in this load."))
# Create Sales Invoice
si = frappe.new_doc("Sales Invoice")
si.customer = load.customer
si.posting_date = frappe.utils.today()
si.due_date = frappe.utils.today()
si.price_list = "2025 Service Rates"
si.selling_price_list = "2025 Service Rates"
si.currency = "USD"
si.set_warehouse = None
si.update_stock = 0
for it in invoice_items:
si.append("items", {
"item_code": it["item_code"],
"qty": it["qty"],
"rate": it["rate"],
"description": it.get("description", ""),
"uom": "Unit"
})
si.save()
# Do NOT submit automatically; let user review
# Update Load
load.invoice_generated = 1
load.sales_invoice = si.name
load.save()
frappe.db.commit()
return {"status": "ok", "sales_invoice": si.name}
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Westech and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Customer Interaction", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,147 @@
{
"actions": [],
"autoname": "autoincrement",
"creation": "2026-05-22 11:58:31.649154",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer",
"customer_number",
"contact_name",
"phone_1",
"phone_2",
"email_1",
"email_2",
"address",
"city",
"zip",
"hours",
"notes",
"red_r2",
"dnc",
"raw_name",
"raw_phone1",
"raw_phone2",
"raw_email"
],
"fields": [
{
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Customer",
"options": "Customer",
"reqd": 1
},
{
"fieldname": "customer_number",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Customer Number"
},
{
"fieldname": "contact_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Contact Name"
},
{
"fieldname": "phone_1",
"fieldtype": "Data",
"label": "Phone 1"
},
{
"fieldname": "phone_2",
"fieldtype": "Data",
"label": "Phone 2"
},
{
"fieldname": "email_1",
"fieldtype": "Data",
"label": "Email 1"
},
{
"fieldname": "email_2",
"fieldtype": "Data",
"label": "Email 2"
},
{
"fieldname": "address",
"fieldtype": "Text",
"label": "Address"
},
{
"fieldname": "city",
"fieldtype": "Data",
"label": "City"
},
{
"fieldname": "zip",
"fieldtype": "Data",
"label": "Zip"
},
{
"fieldname": "hours",
"fieldtype": "Data",
"label": "Hours"
},
{
"fieldname": "notes",
"fieldtype": "Text",
"label": "Notes"
},
{
"fieldname": "red_r2",
"fieldtype": "Data",
"in_standard_filter": 1,
"label": "Red R2"
},
{
"default": "0",
"fieldname": "dnc",
"fieldtype": "Check",
"label": "DNC"
},
{
"fieldname": "raw_name",
"fieldtype": "Text",
"label": "Raw Name"
},
{
"fieldname": "raw_phone1",
"fieldtype": "Data",
"label": "Raw Phone 1"
},
{
"fieldname": "raw_phone2",
"fieldtype": "Data",
"label": "Raw Phone 2"
},
{
"fieldname": "raw_email",
"fieldtype": "Data",
"label": "Raw Email"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-05-22 11:58:31.649154",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "Customer Interaction",
"naming_rule": "Autoincrement",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "contact_name",
"track_changes": 1,
"track_seen": 1
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Westech and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CustomerInteraction(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Westech and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCustomerInteraction(FrappeTestCase):
pass
@@ -0,0 +1,7 @@
frappe.ui.form.on('Load', {
refresh: function(frm) {
frm.add_custom_button(__('View Pallets'), function() {
frappe.set_route('List', 'Pallet', {'load': frm.doc.name});
}, __('Actions'));
}
});
@@ -0,0 +1,25 @@
import frappe
def calculate_totals(doc, method):
"""Auto-calculate Load totals from child tables."""
total_devices = 0
total_weight = 0.0
for item in doc.get("material_items", []):
total_devices += item.total_count or 0
total_weight += item.weight or 0.0
doc.total_devices = total_devices
doc.total_weight = total_weight
total_hdd_wiped = 0
total_hdd_degaussed = 0
for hdd in doc.get("hdd_serials", []):
if hdd.wiped:
total_hdd_wiped += 1
if hdd.degaussed or hdd.shredded:
total_hdd_degaussed += 1
doc.total_hdd_wiped = total_hdd_wiped
doc.total_hdd_degaussed = total_hdd_degaussed
@@ -0,0 +1,80 @@
frappe.ui.form.on('Pallet', {
refresh: function(frm) {
frm.add_custom_button(__('View Serials'), function() {
frappe.set_route('List', 'Serial No', {
'pallet': frm.doc.pallet_number || frm.doc.name
});
}, __('View'));
frm.add_custom_button(__('Serials Spreadsheet'), function() {
frappe.set_route('query-report', 'Serial Nos by Pallet', {
'pallet': frm.doc.pallet_number || frm.doc.name
});
}, __('View'));
frm.add_custom_button(__('Generate COR'), function() {
if (!frm.doc.pallet_number) {
frappe.msgprint('Please save the Pallet first');
return;
}
var url = '/api/method/westech_r2.api.cor_generator.generate_cor?pallet_number=' + encodeURIComponent(frm.doc.pallet_number);
window.open(url, '_blank');
}, __('Actions'));
},
customer_number: function(frm) {
var customer = frm.doc.customer_number;
if (!customer) {
clear_customer_fields(frm);
return;
}
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Supplier', name: customer},
callback: function(r) {
if (!r.message) return;
var s = r.message;
if (!frm.doc.supplier) {
frm.set_value('supplier', s.name);
}
if (!frm.doc.company_name && s.supplier_name) {
frm.set_value('company_name', s.supplier_name);
}
if (s.supplier_primary_contact) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Contact', name: s.supplier_primary_contact},
callback: function(cr) {
if (!cr.message) return;
var ct = cr.message;
var full_name = [ct.first_name, ct.last_name].filter(Boolean).join(' ');
if (!frm.doc.contact_name) frm.set_value('contact_name', full_name);
if (!frm.doc.contact_number) frm.set_value('contact_number', ct.phone || ct.mobile_no || '');
if (!frm.doc.contact_email) frm.set_value('contact_email', ct.email_id || '');
}
});
}
if (s.supplier_primary_address) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Address', name: s.supplier_primary_address},
callback: function(ar) {
if (!ar.message) return;
var a = ar.message;
if (!frm.doc.address_line) frm.set_value('address_line', a.address_line1 || '');
}
});
}
}
});
}
});
function clear_customer_fields(frm) {
frm.set_value('supplier', '');
frm.set_value('company_name', '');
frm.set_value('contact_name', '');
frm.set_value('contact_number', '');
frm.set_value('contact_email', '');
frm.set_value('address_line', '');
}
@@ -0,0 +1,7 @@
import frappe
def update_serial_nos(doc, method):
"""Update serial nos linked to this pallet."""
if doc.pallet_number:
serials = frappe.get_all("Serial No", filters={"pallet": doc.pallet_number}, fields=["name"])
doc.db_set("serial_count", len(serials))
@@ -0,0 +1,67 @@
frappe.ui.form.on('Scheduled Pickup', {
customer_number: function(frm) {
var customer = frm.doc.customer_number;
if (!customer) {
clear_customer_fields(frm);
return;
}
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Supplier', name: customer},
callback: function(r) {
if (!r.message) return;
var s = r.message;
if (!frm.doc.company_name && s.supplier_name) {
frm.set_value('company_name', s.supplier_name);
}
if (s.supplier_primary_contact) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Contact', name: s.supplier_primary_contact},
callback: function(cr) {
if (!cr.message) return;
var ct = cr.message;
var full_name = [ct.first_name, ct.last_name].filter(Boolean).join(' ');
if (!frm.doc.contact_name) frm.set_value('contact_name', full_name);
if (!frm.doc.contact_phone) frm.set_value('contact_phone', ct.phone || '');
if (!frm.doc.contact_email) frm.set_value('contact_email', ct.email_id || '');
}
});
}
if (s.supplier_primary_address) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Address', name: s.supplier_primary_address},
callback: function(ar) {
if (!ar.message) return;
var a = ar.message;
if (!frm.doc.address_line) frm.set_value('address_line', a.address_line1 || '');
if (!frm.doc.city) frm.set_value('city', a.city || '');
if (!frm.doc.state) frm.set_value('state', a.state || '');
if (!frm.doc.zip_code) frm.set_value('zip_code', a.pincode || '');
}
});
}
if (s.geocoded && s.latitude && s.longitude) {
frm.set_value('latitude', s.latitude);
frm.set_value('longitude', s.longitude);
frm.set_value('geocoded', 1);
}
}
});
}
});
function clear_customer_fields(frm) {
frm.set_value('company_name', '');
frm.set_value('contact_name', '');
frm.set_value('contact_phone', '');
frm.set_value('contact_email', '');
frm.set_value('address_line', '');
frm.set_value('city', '');
frm.set_value('state', '');
frm.set_value('zip_code', '');
frm.set_value('latitude', '');
frm.set_value('longitude', '');
frm.set_value('geocoded', 0);
}
@@ -0,0 +1,8 @@
import frappe
def set_title(doc, method):
"""Set title from company name and pickup date."""
if doc.company_name and doc.pickup_date:
doc.title = f"{doc.company_name} - {doc.pickup_date}"
elif doc.company_name:
doc.title = doc.company_name
@@ -0,0 +1,36 @@
app_name = "westech_r2"
app_title = "Westech R2"
app_publisher = "Westech"
app_description = "R2 Tracking for Westech Recyclers"
app_email = ""
app_license = "MIT"
# Fixtures - these will be exported/imported
fixtures = [
"DocType",
"Custom Field",
"Client Script",
"Workspace",
]
# Required apps
required_apps = ["erpnext"]
# DocType event hooks
doc_events = {
"Pallet": {
"before_save": "westech_r2.doctype.pallet.pallet.update_serial_nos",
},
"Scheduled Pickup": {
"before_save": "westech_r2.doctype.scheduled_pickup.scheduled_pickup.set_title",
},
"Serial No": {
"validate": "westech_r2.westech_r2.api.serial_hooks.validate_hardware_tests",
},
"Load": {
"before_save": "westech_r2.doctype.load.load.calculate_totals",
},
}
app_include_css = "/assets/westech_r2/css/westech_theme.css"
@@ -0,0 +1 @@
Westech R2
@@ -0,0 +1 @@
{"creation":"2026-05-20 15:00:00.000000","docstatus":0,"doctype":"Page","idx":0,"module":"westech_r2","name":"customer-intake","page_name":"customer-intake","roles":[],"script":null,"standard":"Yes","style":null,"system_page":0,"title":"Customer Intake"}
@@ -0,0 +1,96 @@
<div class="container" style="padding:20px;">
<div class="row">
<div class="col-md-12">
<h3>Customer Intake</h3>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label>Search Customer</label>
<input type="text" id="intake-search" class="form-control" placeholder="Type company name, number, or phone...">
<div id="search-results" class="list-group" style="margin-top:8px; max-height:250px; overflow-y:auto;"></div>
</div>
<div class="col-md-6">
<div id="no-match" style="display:none;">
<button id="btn-add-new" class="btn btn-primary">Add New Customer</button>
</div>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-4">
<label>Customer Name</label>
<input type="text" id="cust-name" class="form-control">
</div>
<div class="col-md-4">
<label>Customer Number</label>
<input type="text" id="cust-number" class="form-control">
</div>
<div class="col-md-4">
<label>Phone</label>
<input type="text" id="cust-phone" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-6">
<label>Address Line 1</label>
<input type="text" id="cust-address" class="form-control">
</div>
<div class="col-md-2">
<label>City</label>
<input type="text" id="cust-city" class="form-control">
</div>
<div class="col-md-2">
<label>State</label>
<input type="text" id="cust-state" class="form-control">
</div>
<div class="col-md-2">
<label>Zip</label>
<input type="text" id="cust-zip" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-4">
<label>Contact Persons</label>
<input type="text" id="cust-contacts" class="form-control">
</div>
<div class="col-md-4">
<label>Email</label>
<input type="text" id="cust-email" class="form-control">
</div>
<div class="col-md-4">
<label>Hours of Operation</label>
<input type="text" id="cust-hours" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-4">
<label>Data Status</label>
<select id="pallet-data-status" class="form-control">
<option value="D0">D0 - Unknown</option>
<option value="D1">D1 - Contains Data</option>
</select>
</div>
<div class="col-md-4">
<label>Status</label>
<select id="pallet-status" class="form-control">
<option value="Received">Received</option>
<option value="Sorting">Sorting</option>
<option value="Processing">Processing</option>
<option value="Complete">Complete</option>
<option value="Shipped">Shipped</option>
</select>
</div>
<div class="col-md-4">
<label>Inbound Weight (lbs)</label>
<input type="text" id="pallet-weight" class="form-control">
</div>
</div>
<div class="row" style="margin-top:15px;">
<div class="col-md-12">
<button id="btn-save-cust" class="btn btn-success">Save Customer</button>
<button id="btn-create-pallet" class="btn btn-warning" style="margin-left:10px;">Create Pallet</button>
<span id="cust-status" style="margin-left:15px;"></span>
</div>
</div>
</div>
@@ -0,0 +1,125 @@
frappe.pages["customer-intake"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({parent: wrapper, title: __("Customer Intake"), single_column: true});
var content = frappe.render_template("customer-intake", {});
$(page.body).append(content);
var currentCustomer = null;
var searchTimer = null;
function clearForm() {
currentCustomer = null;
$("#cust-name, #cust-number, #cust-phone, #cust-address, #cust-city, #cust-state, #cust-zip, #cust-contacts, #cust-email, #cust-hours").val("");
$("#search-results").empty();
$("#no-match").hide();
$("#cust-status").text("");
}
function fillForm(c) {
currentCustomer = c;
$("#cust-name").val(c.customer_name || "");
$("#cust-number").val(c.customer_number || "");
$("#cust-phone").val(c.phone || c.address_phone || "");
$("#cust-address").val(c.address_line1 || "");
$("#cust-city").val(c.city || "");
$("#cust-state").val(c.state || "");
$("#cust-zip").val(c.pincode || "");
$("#cust-contacts").val(c.contact_persons || "");
$("#cust-email").val(c.email_id || "");
$("#cust-hours").val(c.hours_of_operation || "");
$("#cust-status").text("Selected: " + (c.customer_name || c.name));
$("#no-match").hide();
}
function doSearch() {
var q = $("#intake-search").val().trim();
if (q.length < 2) { $("#search-results").empty(); return; }
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.search_customers",
args: {q: q},
callback: function(r) {
var list = $("#search-results").empty();
if (r.message && r.message.length) {
r.message.forEach(function(c) {
var item = $('<div class="list-group-item" style="cursor:pointer;">')
.html('<b>' + frappe.utils.escape_html(c.customer_name || c.name) + '</b> ' +
(c.address_line1 ? '<br>' + frappe.utils.escape_html(c.address_line1) : '') +
(c.city ? ', ' + c.city : '') +
(c.phone ? ' <br><small>' + c.phone + '</small>' : ''));
item.on("click", function() { fillForm(c); });
list.append(item);
});
$("#no-match").hide();
} else {
$("#no-match").show();
}
}
});
}
$("#intake-search").on("input", function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(doSearch, 300);
});
$("#btn-add-new").click(function() {
clearForm();
$("#cust-name").val($("#intake-search").val());
$("#cust-status").text("Adding new customer...");
});
$("#btn-save-cust").click(function() {
var data = {
customer_name: $("#cust-name").val(),
customer_number: $("#cust-number").val(),
phone: $("#cust-phone").val(),
address_line1: $("#cust-address").val(),
city: $("#cust-city").val(),
state: $("#cust-state").val(),
pincode: $("#cust-zip").val(),
contact_persons: $("#cust-contacts").val(),
email_id: $("#cust-email").val(),
hours_of_operation: $("#cust-hours").val()
};
if (currentCustomer && currentCustomer.name) {
data.name = currentCustomer.name;
}
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.create_customer_from_intake",
args: {data: JSON.stringify(data)},
callback: function(r) {
if (r.message && r.message.status === "ok") {
currentCustomer = {name: r.message.customer, customer_name: data.customer_name};
$("#cust-status").text("Saved: " + r.message.customer);
frappe.show_alert("Customer saved", 3);
} else {
frappe.show_alert("Error saving customer", 5);
}
}
});
});
$("#btn-create-pallet").click(function() {
if (!currentCustomer || !currentCustomer.name) {
frappe.msgprint("Please select or save a customer first.");
return;
}
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.create_pallet",
args: {
data: JSON.stringify({
customer: currentCustomer.name,
customer_number: $("#cust-number").val(),
data_status: $("#pallet-data-status").val(),
status: $("#pallet-status").val(),
inbound_weight: $("#pallet-weight").val()
})
},
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.msgprint("Pallet created: " + r.message.pallet);
$("#cust-status").text("Pallet: " + r.message.pallet);
}
}
});
});
};
@@ -0,0 +1,84 @@
import frappe
from frappe import _
@frappe.whitelist()
def search_customers(q=""):
if not q or len(q) < 2:
return []
q = q.strip().lower()
customers = frappe.db.sql("""
SELECT c.name, c.customer_name, c.customer_number, c.mobile_no,
a.address_line1, a.city, a.state, a.pincode
FROM tabCustomer c
LEFT JOIN `tabDynamic Link` dl ON dl.link_doctype = 'Customer' AND dl.link_name = c.name AND dl.parenttype = 'Address'
LEFT JOIN tabAddress a ON a.name = dl.parent
WHERE LOWER(c.customer_name) LIKE %s OR LOWER(c.customer_number) LIKE %s OR LOWER(c.mobile_no) LIKE %s
ORDER BY c.customer_name
LIMIT 20
""", ("%" + q + "%", "%" + q + "%", "%" + q + "%"), as_dict=True)
return customers
@frappe.whitelist()
def get_customer(name):
if not name:
return {}
cust = frappe.get_doc("Customer", name)
result = cust.as_dict()
addr = frappe.db.sql("""
SELECT a.address_line1, a.city, a.state, a.pincode, a.phone
FROM tabAddress a
JOIN `tabDynamic Link` dl ON dl.parent = a.name AND dl.link_doctype = 'Customer' AND dl.link_name = %s
LIMIT 1
""", (name,), as_dict=True)
if addr:
result.update({
"address_line1": addr[0].address_line1,
"city": addr[0].city,
"state": addr[0].state,
"pincode": addr[0].pincode,
"address_phone": addr[0].phone
})
return result
@frappe.whitelist()
def create_customer_from_intake(data):
data = frappe.parse_json(data)
if not data.get("customer_name"):
frappe.throw(_("Customer name required"))
customer = frappe.new_doc("Customer")
customer.customer_name = data.get("customer_name")
customer.customer_group = data.get("customer_group", "IT Recycling")
customer.customer_type = "Company"
customer.customer_number = data.get("customer_number")
customer.mobile_no = data.get("phone")
customer.email_id = data.get("email_id")
customer.legacy_notes = data.get("legacy_notes")
customer.hours_of_operation = data.get("hours_of_operation")
customer.contact_persons = data.get("contact_persons")
customer.save()
if data.get("address_line1") or data.get("city"):
addr = frappe.new_doc("Address")
addr.address_title = customer.customer_name
addr.address_type = "Billing"
addr.address_line1 = data.get("address_line1", "Unknown")
addr.city = data.get("city", "Unknown")
addr.state = data.get("state", "")
addr.pincode = data.get("pincode", "")
addr.country = "United States"
addr.append("links", {"link_doctype": "Customer", "link_name": customer.name})
addr.save()
return {"status": "ok", "customer": customer.name}
@frappe.whitelist()
def create_pallet(data):
data = frappe.parse_json(data)
if not data.get("customer"):
frappe.throw(_("Customer required"))
pallet = frappe.new_doc("Pallet")
pallet.customer = data.get("customer")
pallet.customer_number = data.get("customer_number")
pallet.data_status = data.get("data_status", "D0")
pallet.status = data.get("status", "Received")
pallet.inbound_weight = data.get("inbound_weight", "")
pallet.save()
return {"status": "ok", "pallet": pallet.name}
@@ -0,0 +1 @@
# Customer Records page
@@ -0,0 +1 @@
{"content": null,"creation": "2026-05-20 22:00:00.000000","docstatus": 0,"doctype": "Page","idx": 0,"modified": "2026-05-20 22:00:00.000000","modified_by": "Administrator","module": "Westech R2","name": "customer-records","owner": "Administrator","page_name": "customer-records","roles": [{"doctype": "Has Role","idx": 1,"name": "a80mopj93i","parent": "customer-records","parentfield": "roles","parenttype": "Page","role": "All"}],"script": null,"standard": "Yes","style": null,"system_page": 0,"title": "Customer Records"}
@@ -0,0 +1,227 @@
<style>
.customer-records-page {
font-family: Segoe UI, Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: white;
}
.customer-records-page h1 {
font-size: 36px;
font-weight: 900;
text-align: center;
margin-bottom: 20px;
letter-spacing: 2px;
text-transform: uppercase;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.toolbar button {
padding: 8px 20px;
border: 2px solid #333;
background: #f0f0f0;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
border-radius: 3px;
}
.toolbar button:hover { background: #e0e0e0; }
.toolbar .record-counter {
font-size: 18px;
font-weight: 600;
margin: 0 10px;
}
.toolbar .nav-btn {
padding: 6px 14px;
font-size: 18px;
min-width: 40px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 40px;
margin-bottom: 20px;
}
.form-group {
display: flex;
align-items: center;
gap: 10px;
}
.form-group label {
font-weight: 600;
font-size: 13px;
min-width: 140px;
text-align: right;
white-space: nowrap;
}
.form-group input,
.form-group textarea {
flex: 1;
padding: 6px 10px;
border: 1px solid #ccc;
font-size: 13px;
background: #f8f8f8;
}
.form-group textarea {
min-height: 60px;
resize: vertical;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid #333;
}
.search-bar label {
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
}
.search-bar select,
.search-bar input {
padding: 6px 10px;
border: 1px solid #ccc;
font-size: 13px;
}
.search-bar .search-label {
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
margin: 0 10px;
}
.search-bar button {
padding: 6px 20px;
border: 2px solid #333;
background: #f0f0f0;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
}
.search-bar button:hover { background: #e0e0e0; }
</style>
<div class="customer-records-page">
<h1>Modify Records</h1>
<div class="toolbar">
<button id="btn-save">Save</button>
<span class="record-counter">
<span id="current-index">0</span> of <span id="total-count">0</span>
</span>
<button class="nav-btn" id="btn-prev">&lt;</button>
<button class="nav-btn" id="btn-next">&gt;</button>
<button class="nav-btn" id="btn-first">&lt;&lt;</button>
<button class="nav-btn" id="btn-last">&gt;&gt;</button>
<button id="btn-delete">Delete</button>
<button id="btn-print">Print</button>
</div>
<div class="form-grid">
<div class="form-group">
<label>Record #</label>
<input type="text" id="record-number" readonly>
</div>
<div class="form-group">
<label>Additional Numbers:</label>
<input type="text" id="additional-numbers">
</div>
<div class="form-group">
<label>*</label>
<input type="text" id="field-star">
</div>
<div class="form-group">
<label>Customer Address:</label>
<input type="text" id="customer-address">
</div>
<div class="form-group">
<label>Any Letter:</label>
<input type="text" id="any-letter">
</div>
<div class="form-group">
<label>City:</label>
<input type="text" id="city">
</div>
<div class="form-group">
<label>E</label>
<input type="text" id="field-e">
</div>
<div class="form-group">
<label>Zip:</label>
<input type="text" id="zip">
</div>
<div class="form-group">
<label>Company Name:</label>
<input type="text" id="company-name">
</div>
<div class="form-group">
<label>Contacted date:</label>
<input type="date" id="contacted-date">
</div>
<div class="form-group full-width">
<label>Contact Person(s):</label>
<textarea id="contact-persons"></textarea>
</div>
<div class="form-group">
<label>Follow up date:</label>
<input type="text" id="follow-up-date">
</div>
<div class="form-group">
<label>E-Mail Address:</label>
<input type="email" id="email-address">
</div>
<div class="form-group">
<label>Last P/U date:</label>
<input type="text" id="last-pu-date">
</div>
<div class="form-group full-width">
<label>Contact Numbers:</label>
<textarea id="contact-numbers"></textarea>
</div>
<div class="form-group full-width">
<label>Hours of Operation:</label>
<input type="text" id="hours-operation">
</div>
<div class="form-group full-width">
<label>Comments:</label>
<textarea id="comments"></textarea>
</div>
</div>
<div class="search-bar">
<label>SEARCH:</label>
<select id="search-field">
<option value="address">Address</option>
<option value="company_name">Company Name</option>
<option value="contact_person">Contact Person</option>
<option value="phone">Phone</option>
<option value="email">Email</option>
<option value="city">City</option>
<option value="zip">Zip</option>
<option value="record_number">Record #</option>
</select>
<span class="search-label">FOR</span>
<input type="text" id="search-value" style="min-width:200px;">
<button id="btn-search">Search!</button>
<button id="btn-reset">Reset</button>
</div>
</div>
@@ -0,0 +1,148 @@
frappe.pages["customer-records"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Customer Records"),
single_column: true
});
var content = frappe.render_template("customer-records", {});
$(page.body).append(content);
var records = [];
var currentIdx = -1;
var searchResults = [];
function loadRecord(idx) {
if (idx < 0 || idx >= records.length) return;
currentIdx = idx;
var r = records[idx];
$("#current-index").text(idx + 1);
$("#total-count").text(records.length);
$("#record-number").val(r.name || "");
$("#additional-numbers").val(r.additional_numbers || "");
$("#field-star").val(r.field_star || "");
$("#customer-address").val(r.customer_address || "");
$("#any-letter").val(r.any_letter || "");
$("#city").val(r.city || "");
$("#field-e").val(r.field_e || "");
$("#zip").val(r.zip || "");
$("#company-name").val(r.company_name || "");
$("#contacted-date").val(r.contacted_date || "");
$("#contact-persons").val(r.contact_persons || "");
$("#follow-up-date").val(r.follow_up_date || "");
$("#email-address").val(r.email_address || "");
$("#last-pu-date").val(r.last_pu_date || "");
$("#contact-numbers").val(r.contact_numbers || "");
$("#hours-operation").val(r.hours_operation || "");
$("#comments").val(r.comments || "");
}
function fetchRecords() {
frappe.call({
method: "westech_r2.page.customer-records.customer-records.get_records",
callback: function(r) {
if (r.message) {
records = r.message;
if (records.length > 0) loadRecord(0);
else $("#current-index").text(0);
$("#total-count").text(records.length);
}
}
});
}
function saveRecord() {
if (currentIdx < 0) return;
var data = {
name: $("#record-number").val(),
additional_numbers: $("#additional-numbers").val(),
field_star: $("#field-star").val(),
customer_address: $("#customer-address").val(),
any_letter: $("#any-letter").val(),
city: $("#city").val(),
field_e: $("#field-e").val(),
zip: $("#zip").val(),
company_name: $("#company-name").val(),
contacted_date: $("#contacted-date").val(),
contact_persons: $("#contact-persons").val(),
follow_up_date: $("#follow-up-date").val(),
email_address: $("#email-address").val(),
last_pu_date: $("#last-pu-date").val(),
contact_numbers: $("#contact-numbers").val(),
hours_operation: $("#hours-operation").val(),
comments: $("#comments").val()
};
frappe.call({
method: "westech_r2.page.customer-records.customer-records.save_record",
args: { data: JSON.stringify(data) },
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.show_alert("Saved successfully");
records[currentIdx] = data;
}
}
});
}
function deleteRecord() {
if (currentIdx < 0) return;
frappe.confirm("Delete record #" + $("#record-number").val() + "?", function() {
frappe.call({
method: "westech_r2.page.customer-records.customer-records.delete_record",
args: { name: $("#record-number").val() },
callback: function(r) {
if (r.message && r.message.status === "ok") {
records.splice(currentIdx, 1);
if (records.length > 0) {
currentIdx = Math.min(currentIdx, records.length - 1);
loadRecord(currentIdx);
} else {
$("#current-index").text(0);
$("#total-count").text(0);
$("input, textarea").val("");
}
frappe.show_alert("Deleted");
}
}
});
});
}
function searchRecords() {
var field = $("#search-field").val();
var value = $("#search-value").val().toLowerCase();
if (!value) {
searchResults = [];
fetchRecords();
return;
}
frappe.call({
method: "westech_r2.page.customer-records.customer-records.search_records",
args: { field: field, value: value },
callback: function(r) {
if (r.message) {
searchResults = r.message;
records = searchResults;
if (records.length > 0) loadRecord(0);
else {
$("#current-index").text(0);
$("#total-count").text(records.length);
$("input, textarea").val("");
}
}
}
});
}
$("#btn-save").click(saveRecord);
$("#btn-delete").click(deleteRecord);
$("#btn-prev").click(function() { if (currentIdx > 0) loadRecord(currentIdx - 1); });
$("#btn-next").click(function() { if (currentIdx < records.length - 1) loadRecord(currentIdx + 1); });
$("#btn-first").click(function() { if (records.length > 0) loadRecord(0); });
$("#btn-last").click(function() { if (records.length > 0) loadRecord(records.length - 1); });
$("#btn-search").click(searchRecords);
$("#btn-reset").click(function() { $("#search-value").val(""); searchResults = []; fetchRecords(); });
$("#btn-print").click(function() { window.print(); });
fetchRecords();
};
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-20 15:03:29.017530",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-20 15:03:29.017530",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "customer-records",
"owner": "Administrator",
"page_name": "customer-records",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Customer Records"
}
@@ -0,0 +1,85 @@
import frappe
@frappe.whitelist()
def get_records():
# For now return Lead records mapped to form fields
leads = frappe.db.sql("""
SELECT name, company_name, email_id, mobile_no, phone, address_line1, city, state, pincode,
title, status, industry, source
FROM tabLead
ORDER BY creation DESC
LIMIT 1000
""", as_dict=True)
result = []
for l in leads:
result.append({
"name": l.name,
"additional_numbers": "",
"field_star": l.status or "",
"customer_address": l.address_line1 or "",
"any_letter": l.title or "",
"city": l.city or "",
"field_e": l.email_id or "",
"zip": l.pincode or "",
"company_name": l.company_name or "",
"contacted_date": "",
"contact_persons": l.title or "",
"follow_up_date": "",
"email_address": l.email_id or "",
"last_pu_date": "",
"contact_numbers": (l.phone or "") + "\n" + (l.mobile_no or ""),
"hours_operation": l.industry or "",
"comments": l.source or ""
})
return result
@frappe.whitelist()
def save_record(data):
data = frappe.parse_json(data)
# For now just return OK - Lead update can be wired later
return {"status": "ok", "message": "Saved " + (data.get("name") or "")}
@frappe.whitelist()
def delete_record(name):
# For now return OK
return {"status": "ok", "message": "Deleted " + name}
@frappe.whitelist()
def search_records(field, value):
leads = frappe.db.sql("""
SELECT name, company_name, email_id, mobile_no, phone, address_line1, city, state, pincode,
title, status, industry, source
FROM tabLead
WHERE LOWER(company_name) LIKE %s
OR LOWER(title) LIKE %s
OR LOWER(address_line1) LIKE %s
OR LOWER(city) LIKE %s
OR LOWER(pincode) LIKE %s
OR LOWER(email_id) LIKE %s
OR LOWER(phone) LIKE %s
OR LOWER(mobile_no) LIKE %s
ORDER BY creation DESC
LIMIT 100
""", tuple(["%" + value + "%"] * 8), as_dict=True)
result = []
for l in leads:
result.append({
"name": l.name,
"additional_numbers": "",
"field_star": l.status or "",
"customer_address": l.address_line1 or "",
"any_letter": l.title or "",
"city": l.city or "",
"field_e": l.email_id or "",
"zip": l.pincode or "",
"company_name": l.company_name or "",
"contacted_date": "",
"contact_persons": l.title or "",
"follow_up_date": "",
"email_address": l.email_id or "",
"last_pu_date": "",
"contact_numbers": (l.phone or "") + "\n" + (l.mobile_no or ""),
"hours_operation": l.industry or "",
"comments": l.source or ""
})
return result
@@ -0,0 +1,5 @@
.badge-fresh { background-color: #28a745; }
.badge-aging { background-color: #ffc107; color: #212529; }
.badge-expired { background-color: #dc3545; }
.badge-needs { background-color: #fd7e14; }
.badge-error { background-color: #6c757d; }
@@ -0,0 +1,8 @@
<style>
.badge.badge-fresh { background-color: #28a745; }
.badge.badge-aging { background-color: #ffc107; color: #212529; }
.badge.badge-expired { background-color: #dc3545; }
.badge.badge-needs { background-color: #fd7e14; }
.badge.badge-error { background-color: #6c757d; }
</style>
<div id="ebay-pricing-page"></div>
@@ -0,0 +1,323 @@
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 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 class="row">
<div class="col-md-6">
<h4>Apply Pricing to Inventory</h4>
<div class="form-group">
<select class="form-control" id="ebay-apply-item" style="width: 100%;">
<option value="">Select Item to apply pricing...</option>
</select>
</div>
<button class="btn btn-success" id="ebay-apply-btn">
<i class="fa fa-check"></i> Apply Pricing
</button>
<button class="btn btn-info" id="ebay-apply-all-btn" style="margin-left: 0.5rem;">
<i class="fa fa-check-double"></i> Apply All
</button>
</div>
<div class="col-md-6 text-right">
<div class="well" style="display: inline-block; text-align: left;">
<h5>Pricing Status</h5>
<div id="pricing-stats">
<p class="text-muted">Click Apply All to see stats</p>
</div>
</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);
// Load item dropdown
load_item_dropdown();
// Event handlers
$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);
});
$container.find('#ebay-apply-btn').on('click', function() {
let item = $container.find('#ebay-apply-item').val();
if (!item) {
frappe.msgprint(__('Select an Item first'));
return;
}
apply_pricing(item);
});
$container.find('#ebay-apply-all-btn').on('click', function() {
apply_pricing_all();
});
function load_item_dropdown() {
frappe.call({
method: 'frappe.client.get_list',
args: {
doctype: 'Item',
filters: {
'disabled': 0,
'item_group': ['in', ['Laptop', 'Desktop', 'Tablet', 'Phone', 'Workstation']]
},
fields: ['name', 'item_name'],
limit: 1000
},
callback: function(r) {
if (r.message) {
let $select = $container.find('#ebay-apply-item');
r.message.forEach(item => {
$select.append(`<option value="${item.name}">${item.item_name || item.name}</option>`);
});
}
}
});
}
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 apply_pricing(item_code) {
frappe.call({
method: 'westech_r2.api.ebay_pricing.batch_apply_pricing',
args: { item_code: item_code },
freeze: true,
freeze_message: __('Applying pricing to Serial Nos...'),
callback: function(r) {
if (r.message) {
render_pricing_stats(r.message);
frappe.msgprint(__('Pricing applied: {0} priced, {1} commodity, {2} needs grading',
[r.message.priced, r.message.commodity, r.message.needs_grading]));
}
}
});
}
function apply_pricing_all() {
frappe.call({
method: 'westech_r2.api.ebay_pricing.batch_apply_pricing',
args: { batch_size: 1000 },
freeze: true,
freeze_message: __('Applying pricing to all Serial Nos...'),
callback: function(r) {
if (r.message) {
render_pricing_stats(r.message);
frappe.msgprint(__('Batch pricing applied: {0} priced, {1} commodity, {2} needs grading, {3} errors',
[r.message.priced, r.message.commodity, r.message.needs_grading, r.message.errors]));
}
}
});
}
function render_pricing_stats(stats) {
let html = `
<table class="table table-condensed" style="margin-bottom: 0;">
<tr><td>Priced</td><td><span class="badge badge-success">${stats.priced || 0}</span></td></tr>
<tr><td>Commodity</td><td><span class="badge badge-warning">${stats.commodity || 0}</span></td></tr>
<tr><td>Needs Grading</td><td><span class="badge badge-info">${stats.needs_grading || 0}</span></td></tr>
<tr><td>Needs Price Point</td><td><span class="badge badge-primary">${stats.needs_price_point || 0}</span></td></tr>
<tr><td>Errors</td><td><span class="badge badge-danger">${stats.errors || 0}</span></td></tr>
</table>
`;
$container.find('#pricing-stats').html(html);
}
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 = 'badge-needs';
if (row.pricing_status === 'Priced') status_class = 'badge-fresh';
else if (row.pricing_status === 'Manual Override') status_class = 'badge-fresh';
else if (row.pricing_status === 'Expired') status_class = 'badge-expired';
else if (row.pricing_status === 'Error') status_class = 'badge-error';
let age = row.days_since_pricing || 0;
let age_badge = age < 90 ? 'badge-fresh' : (age < 120 ? 'badge-aging' : 'badge-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,26 @@
{
"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 @@
# eBay Pricing desk page
@@ -0,0 +1,5 @@
.badge-fresh { background-color: #28a745; }
.badge-aging { background-color: #ffc107; color: #212529; }
.badge-expired { background-color: #dc3545; }
.badge-needs { background-color: #fd7e14; }
.badge-error { background-color: #6c757d; }
@@ -0,0 +1,8 @@
<style>
.badge.badge-fresh { background-color: #28a745; }
.badge.badge-aging { background-color: #ffc107; color: #212529; }
.badge.badge-expired { background-color: #dc3545; }
.badge.badge-needs { background-color: #fd7e14; }
.badge.badge-error { background-color: #6c757d; }
</style>
<div id="ebay-pricing-page"></div>
@@ -0,0 +1 @@
ebay-pricing.js
@@ -0,0 +1 @@
ebay-pricing.json
@@ -0,0 +1 @@
# eBay Pricing desk page
@@ -0,0 +1 @@
@@ -0,0 +1,4 @@
frappe.pages["eim-portal"].on_page_load = function(wrapper) {
wrapper.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:60vh;font-family:sans-serif;"><div style="text-align:center;"><i class="fa fa-spinner fa-spin" style="font-size:24px;color:#2d7d46;"></i><p style="margin-top:12px;color:#555;">Redirecting to EIM Device Portal...</p></div></div>';
setTimeout(function() { window.location.href = "https://eim.diagalon.com"; }, 500);
};
@@ -0,0 +1,13 @@
{
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 14:00:00",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "eim-portal",
"owner": "Administrator",
"standard": "Yes",
"title": "EIM Device Portal"
}
@@ -0,0 +1,5 @@
import frappe
def get_context(context):
frappe.local.flags.redirect_location = "https://eim.diagalon.com"
raise frappe.Redirect
@@ -0,0 +1,7 @@
frappe.pages['eim-portal'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'EIM Device Portal',
single_column: true
});
}
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 15:09:48.653878",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "eim-portal",
"owner": "Administrator",
"page_name": "eim-portal",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "EIM Device Portal"
}
@@ -0,0 +1,11 @@
.intake-station .card { border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; }
.intake-station .card-header { padding: 15px; }
.intake-station .card-body { padding: 20px; }
.intake-station .form-group { margin-bottom: 15px; }
.intake-station .form-control { border-radius: 4px; padding: 8px 12px; font-size: 16px; }
.intake-station .form-control:focus { border-color: #6f42c1; box-shadow: 0 0 0 0.2rem rgba(111,66,193,0.25); }
.intake-station label { font-weight: 600; margin-bottom: 4px; }
.intake-station h5 { margin-bottom: 15px; padding-bottom: 8px; border-bottom: 2px solid #e0e0e0; }
.intake-station .table th { background: #f8f9fa; }
.intake-station .btn-primary { background: linear-gradient(135deg, #6f42c1, #28a745) !important; border: none !important; }
.intake-station .label { font-size: 0.85em; }
@@ -0,0 +1,580 @@
frappe.pages['intake'].on_page_load = function(wrapper) {
try {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'Customer Management',
single_column: true
});
page.set_primary_action('New Customer', function() {
show_intake_form();
}, 'add');
page.add_inner_button('Refresh', function() {
load_customer_list();
});
$(wrapper).find('.layout-main-section').html(`
<div class="intake-station" style="padding: 20px;">
<div id="intake-form-container">
<div class="card" style="margin-bottom: 20px;">
<div class="card-header" style="background: linear-gradient(135deg, #6f42c1, #28a745); color: white; padding: 15px;">
<h4 style="margin:0; color: white;">Customer Management</h4>
</div>
<div class="card-body" style="padding: 20px;">
<form id="intake-form">
<div class="row">
<div class="col-md-4">
<h5 style="color:#6f42c1;">Customer</h5>
<div class="form-group">
<label>Customer <span class="text-danger">*</span></label>
<div id="customer-number-control"></div>
</div>
<div class="form-group">
<label>Company Name</label>
<input type="text" id="company_name" class="form-control" readonly style="background:#f8f9fa;">
</div>
<div class="form-group">
<label>Driver</label>
<div id="driver-control"></div>
</div>
<div class="form-group">
<label>Contact Name</label>
<input type="text" id="contact_name" class="form-control">
</div>
<div class="form-group">
<label>Contact #</label>
<input type="tel" id="contact_number" class="form-control">
</div>
<div class="form-group">
<label>Contact Email</label>
<input type="email" id="contact_email" class="form-control">
</div>
<div class="form-group">
<label>Address</label>
<input type="text" id="address_line" class="form-control">
</div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">Dates & Source</h5>
<div class="form-group">
<label>Received Date <span class="text-danger">*</span></label>
<input type="date" id="received_date" class="form-control" required>
</div>
<div class="form-group">
<label>Weekday</label>
<input type="text" id="weekday" class="form-control" readonly style="background:#f8f9fa;">
</div>
<div class="form-group">
<label>Pickup / Drop-off</label>
<select id="pickup" class="form-control">
<option value="">—</option>
<option value="Pickup">Pickup</option>
<option value="Drop-off">Drop-off</option>
</select>
</div>
<div class="form-group">
<label>Hours of Operation</label>
<input type="text" id="hours_of_operation" class="form-control" readonly style="background:#f8f9fa;" placeholder="Auto-filled from Customer">
</div>
<div class="form-group">
<label>Data Status</label>
<select id="data_status" class="form-control">
<option value="">—</option>
<option value="D0">D0 (Unknown)</option>
<option value="D1">D1 (Contains Data)</option>
</select>
</div>
<div class="form-group">
<label>RED / R2</label>
<select id="red_r2" class="form-control">
<option value="">—</option>
<option value="RED">RED</option>
<option value="R2">R2</option>
<option value="Both">Both</option>
<option value="Clear">Clear</option>
</select>
</div>
<div class="form-group">
<label>Notes</label>
<textarea id="notes" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Legacy Notes</label>
<textarea id="legacy_notes" class="form-control" rows="2" readonly style="background:#fafafa;" title="Auto-filled from Customer record"></textarea>
</div>
</div>
<div class="col-md-4" id="load-info-section" style="display:none;">
<h5 style="color:#6f42c1;">Items & Weight</h5>
<div class="form-group">
<label>Barcode</label>
<input type="text" id="barcode" class="form-control" placeholder="Scan barcode...">
</div>
<div class="form-group">
<label>Total Items</label>
<input type="number" id="total_items" class="form-control" value="0" min="0">
</div>
<div class="form-group">
<label>Number of Labels</label>
<input type="number" id="num_labels" class="form-control" value="1" min="1" max="20">
</div>
<hr>
<div class="form-group">
<label>Weight <span class="text-danger">*</span></label>
<input type="text" id="weights" class="form-control" placeholder="e.g. 340 lbs" required>
</div>
<div class="form-group">
<label>Invoice / Check Request</label>
<input type="text" id="invoice_check_request" class="form-control">
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" id="amount" class="form-control" step="0.01" value="0">
</div>
<div class="form-group">
<label>Paid / Received</label>
<select id="paid_received" class="form-control">
<option value="">—</option>
<option value="Paid">Paid</option>
<option value="Received">Received</option>
<option value="Pending">Pending</option>
</select>
</div>
</div>
</div>
<div class="row" style="margin-top: 20px;">
<div class="col-md-12">
<button type="submit" class="btn btn-primary btn-lg" style="background: linear-gradient(135deg, #6f42c1, #28a745); border: none;">
Save Contact Info
</button>
<button type="button" class="btn btn-default btn-lg" id="btn-print-labels" disabled>
Print Labels
</button>
<button type="button" class="btn btn-default btn-lg" id="btn-generate-cor" disabled>
CoR/AoR
</button>
<button type="button" class="btn btn-default btn-lg" id="btn-cancel">
Cancel
</button>
<span id="save-status" class="ml-3" style="font-size: 1.2em;"></span>
</div>
</div>
</form>
</div>
</div>
</div>
<div id="recent-pallets" style="display:none;">
<div class="card">
<div class="card-header" style="background: #f8f9fa; display: flex; justify-content: space-between; align-items: center;">
<h5 style="margin:0;">Customers</h5>
<input type="text" id="customer-search" class="form-control" placeholder="Search..." style="width: 300px;">
</div>
<div class="card-body" style="padding: 0;">
<table class="table table-striped table-hover" id="customer-table">
<thead>
<tr>
<th>Company</th>
<th>Contact</th>
<th>Phone</th>
<th>Address</th>
<th></th>
</tr>
</thead>
<tbody id="customer-tbody">
<tr><td colspan="5" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
`);
setup_link_controls();
set_today_date();
// Trigger weekday calculation after date field is set
setTimeout(function() {
var dateVal = $('#received_date').val();
if (dateVal) {
var d = new Date(dateVal + 'T12:00:00');
var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
$('#weekday').val(days[d.getDay()] || '');
}
}, 200);
// Show/hide load info based on pickup dropdown
.on('change', function() {
var val = .val();
if (val) {
.show();
} else {
.hide();
}
});
// Initial state - hide if blank
.trigger('change');
load_customer_list();
$('#received_date').on('change', function() {
var d = new Date($(this).val() + 'T12:00:00');
var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
$('#weekday').val(days[d.getDay()] || '');
});
$('#intake-form').on('submit', function(e) {
e.preventDefault();
save_customer();
});
$('#btn-cancel').on('click', function() {
show_customer_list();
});
$('#btn-print-labels').on('click', function() {
frappe.msgprint('Label printing coming soon.');
});
$('#btn-generate-cor').on('click', function() {
generate_cor_report();
});
$('#customer-search').on('input', function() {
var q = $(this).val().toLowerCase();
$('#customer-tbody tr').each(function() {
var text = $(this).text().toLowerCase();
$(this).toggle(text.indexOf(q) > -1);
});
});
} catch(e) {
console.error('[INTAKE] FATAL:', e.message, e.stack);
$(wrapper).find('.layout-main-section').html(
'<div style="padding:20px;"><h3>Error Loading Page</h3><pre>' + e.message + '</pre></div>'
);
}
};
// ── Link Controls ──────────────────────────────────────────────
var customer_number_control = null;
var driver_control = null;
var selected_customer_name = null;
function setup_link_controls() {
customer_number_control = frappe.ui.form.make_control({
parent: $('#customer-number-control'),
df: {
fieldtype: 'Link',
fieldname: 'customer_number',
options: 'Customer',
label: 'Customer',
reqd: 1,
placeholder: 'Search customer...',
onchange: function() {
var val = customer_number_control.get_value();
if (val) {
selected_customer_name = val;
fetch_customer_details(val);
} else {
selected_customer_name = null;
clear_customer_fields();
}
}
},
only_input: true,
});
customer_number_control.refresh();
$('#customer-number-control .control-input').css('margin', '0');
$('#customer-number-control .help-box').remove();
driver_control = frappe.ui.form.make_control({
parent: $('#driver-control'),
df: {
fieldtype: 'Link',
fieldname: 'driver',
options: 'Employee',
label: 'Driver',
placeholder: 'Search driver...',
onchange: function() {}
},
only_input: true,
});
driver_control.refresh();
$('#driver-control .control-input').css('margin', '0');
$('#driver-control .help-box').remove();
}
function fetch_customer_details(customer_name) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Customer', name: customer_name},
callback: function(r) {
if (r.message) {
var c = r.message;
$('#company_name').val(c.customer_name || '');
$('#contact_name').val(c.contact_persons || '');
$('#hours_of_operation').val(c.hours_of_operation || '');
$('#legacy_notes').val(c.legacy_notes || '');
// Parse phone and email from contact_persons if not in dedicated fields
var phone = c.mobile_no || '';
var email = c.email_id || '';
var addressLine = '';
// Extract phone from contact_persons text blob
if (!phone && c.contact_persons) {
var phoneMatch = c.contact_persons.match(/\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/);
if (phoneMatch) phone = phoneMatch[0];
}
// Extract email from contact_persons text blob
if (!email && c.contact_persons) {
var emailMatch = c.contact_persons.match(/[\w.+-]+@[\w.-]+\.\w+/);
if (emailMatch) email = emailMatch[0];
}
$('#contact_number').val(phone);
$('#contact_email').val(email);
// Enable CoR button once we have a customer with data
$('#btn-generate-cor').prop('disabled', false);
// Get address — always fetch from Address record for full street+city+state+zip
if (c.customer_primary_address) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Address', name: c.customer_primary_address},
callback: function(r3) {
if (r3.message) {
var addr = r3.message;
var line = (addr.address_line1 || '') + (addr.address_line2 ? ', ' + addr.address_line2 : '');
var full = line + (addr.city ? ', ' + addr.city : '') + (addr.state ? ', ' + addr.state : '') + (addr.pincode ? ' ' + addr.pincode : '');
$('#address_line').val(full);
}
}
});
} else if (c.primary_address) {
// Fallback: parse text blob (less reliable)
var lines = c.primary_address.split('\n');
addressLine = lines.join(', ');
$('#address_line').val(addressLine);
}
// If still missing phone/email, try linked Contact
if ((!phone || !email) && c.customer_primary_contact) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Contact', name: c.customer_primary_contact},
callback: function(r2) {
if (r2.message) {
var ct = r2.message;
if (!phone) $('#contact_number').val(ct.phone || ct.mobile_no || '');
if (!email) $('#contact_email').val(ct.email_id || '');
}
}
});
}
}
}
});
}
function clear_customer_fields() {
$('#company_name').val('');
$('#contact_name').val('');
$('#contact_number').val('');
$('#contact_email').val('');
$('#address_line').val('');
$('#hours_of_operation').val('');
}
function clear_form() {
if (customer_number_control) customer_number_control.set_value('');
if (driver_control) driver_control.set_value('');
$('#company_name').val('');
$('#received_date').val('');
$('#weekday').val('');
$('#pickup').val('');
$('#data_status').val('');
$('#red_r2').val('');
$('#barcode').val('');
$('#total_items').val(0);
$('#num_labels').val(1);
$('#contact_name').val('');
$('#contact_number').val('');
$('#contact_email').val('');
$('#address_line').val('');
$('#hours_of_operation').val('');
$('#legacy_notes').val('');
$('#weights').val('');
$('#invoice_check_request').val('');
$('#amount').val(0);
$('#paid_received').val('');
$('#notes').val('');
$('#btn-print-labels').prop('disabled', true);
$('#btn-generate-cor').prop('disabled', true);
$('#save-status').html('');
}
function set_today_date() {
var today = new Date().toISOString().split('T')[0];
$('#received_date').val(today);
$('#received_date').trigger('change');
}
function show_intake_form() {
$('#intake-form-container').show();
$('#recent-pallets').hide();
}
function show_customer_list() {
$('#intake-form-container').hide();
$('#recent-pallets').show();
clear_form();
load_customer_list();
}
function load_customer_list() {
frappe.call({
method: 'frappe.client.get_list',
args: {
doctype: 'Customer',
fields: ['name', 'customer_name', 'customer_number', 'mobile_no', 'email_id', 'contact_persons', 'primary_address', 'customer_primary_address'],
limit_page_length: 50,
order_by: 'customer_name asc'
},
callback: function(r) {
var tbody = $('#customer-tbody');
tbody.empty();
if (!r.message || r.message.length === 0) {
tbody.append('<tr><td colspan="5" class="text-center">No customers found.</td></tr>');
return;
}
r.message.forEach(function(c) {
var contactName = '';
if (c.contact_persons) {
var parts = c.contact_persons.split('|');
if (parts.length > 0) contactName = parts[0].trim();
}
tbody.append(
'<tr style="cursor:pointer;" onclick="select_customer_from_list(\'' + c.name.replace(/'/g, "\\'") + '\')">' +
'<td><strong>' + (c.customer_name || c.name) + '</strong>' + (c.customer_number ? ' <span class="text-muted">(#' + c.customer_number + ')</span>' : '') + '</td>' +
'<td>' + contactName + '</td>' +
'<td>' + (c.mobile_no || '') + '</td>' +
'<td>' + (c.primary_address ? c.primary_address.replace(/\n/g, ', ') : '') + '</td>' +
'<td><button class="btn btn-xs btn-default"><i class="fa fa-arrow-right"></i></button></td>' +
'</tr>'
);
});
}
});
}
function select_customer_from_list(customer_name) {
if (customer_number_control) customer_number_control.set_value(customer_name);
fetch_customer_details(customer_name);
}
function edit_pallet(name) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Pallet', name: name},
callback: function(r) {
if (r.message) {
show_intake_form();
var d = r.message;
$('#intake-form-container').data('pallet-name', name);
$('#received_date').val(d.received_date || '');
if (customer_number_control) customer_number_control.set_value(d.customer_number || '');
if (driver_control) driver_control.set_value(d.driver || '');
$('#company_name').val(d.company_name || '');
$('#pickup').val(d.pickup || '');
$('#data_status').val(d.data_status || '');
$('#red_r2').val(d.red_r2 || '');
$('#barcode').val(d.barcode || '');
$('#total_items').val(d.total_items || 0);
$('#num_labels').val(d.num_labels || 1);
$('#contact_name').val(d.contact_name || '');
$('#contact_number').val(d.contact_number || '');
$('#contact_email').val(d.contact_email || '');
$('#address_line').val(d.address_line || '');
$('#hours_of_operation').val(d.hours_of_operation || '');
$('#legacy_notes').val(d.legacy_notes || '');
$('#weights').val(d.weights || '');
$('#invoice_check_request').val(d.invoice_check_request || '');
$('#amount').val(d.amount || 0);
$('#paid_received').val(d.paid_received || '');
$('#notes').val(d.notes || '');
$('#received_date').trigger('change');
$('#btn-print-labels').prop('disabled', false);
$('#btn-generate-cor').prop('disabled', false);
}
}
});
}
function save_customer() {
var customer_name = customer_number_control ? customer_number_control.get_value() : null;
if (!customer_name) {
frappe.msgprint("Please select a customer first.");
return;
}
frappe.call({
method: "frappe.client.get",
args: { doctype: "Customer", name: customer_name },
callback: function(r) {
if (r.message) {
var doc = r.message;
doc.contact_persons = .val();
doc.mobile_no = .val();
doc.email_id = .val();
doc.hours_of_operation = .val();
doc.legacy_notes = .val();
frappe.call({
method: "frappe.client.save",
args: { doc: doc },
callback: function(r2) {
if (r2.message) {
frappe.msgprint("Customer updated!");
.html("<span style=\"color:green;\">Saved!</span>");
}
}
});
}
}
});
}
function generate_cor_report() {
var companyName = $('#company_name').val();
if (!companyName) {
frappe.msgprint('Please select a customer first.');
return;
}
var args = {
company_name: companyName,
weights: $('#weights').val() || '',
received_date: $('#received_date').val() || '',
red_r2: $('#red_r2').val() || '',
contact_name: $('#contact_name').val() || '',
contact_number: $('#contact_number').val() || '',
address_line: $('#address_line').val() || '',
pallet_name: $('#intake-form-container').data('pallet-name') || ''
};
window.open('/api/method/westech_r2.api.cor_generator.generate_cor?'
+ '&company_name=' + encodeURIComponent(args.company_name)
+ '&weights=' + encodeURIComponent(args.weights)
+ '&received_date=' + encodeURIComponent(args.received_date)
+ '&red_r2=' + encodeURIComponent(args.red_r2)
+ '&contact_name=' + encodeURIComponent(args.contact_name)
+ '&contact_number=' + encodeURIComponent(args.contact_number)
+ '&address_line=' + encodeURIComponent(args.address_line)
+ '&pallet_name=' + encodeURIComponent(args.pallet_name)
);
}
window.edit_pallet = edit_pallet;
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-09 12:05:32.403207",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-23 01:31:28.579759",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "intake",
"owner": "Administrator",
"page_name": "intake",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Customer Management"
}
@@ -0,0 +1,5 @@
from frappe import _
def get_context(context):
context.no_cache = 1
context.title = _("Intake Station")
@@ -0,0 +1 @@
/* CSS */
@@ -0,0 +1,53 @@
<div class="ld-container" style="padding: 20px;">
<style>
.ld-header { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
.ld-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 13px; }
.ld-table th { background: #37474f; color: white; padding: 8px; text-align: left; }
.ld-table td { padding: 6px 8px; border-bottom: 1px solid #e0e0e0; }
.ld-table .num { text-align: right; font-family: monospace; }
.ld-btn { background: #455a64; color: white; border: none; padding: 8px 20px; border-radius: 4px; cursor: pointer; margin-bottom: 20px; margin-right: 10px; }
.ld-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; margin-right: 4px; }
.bg-rcv { background: #e3f2fd; color: #1565c0; }
.bg-hdr { background: #fff3e0; color: #e65100; }
.bg-tst { background: #e8f5e9; color: #2e7d32; }
.bg-r2 { background: #f3e5f5; color: #7b1fa2; }
.bg-des { background: #ffebee; color: #c62828; }
</style>
<div class="ld-header">
<h2>Load: <span id="ld-name"></span></h2>
<div>In Date: <span id="ld-date"></span> | Customer: <span id="ld-cust"></span> | Devices: <span id="ld-dev"></span> | Weight: <span id="ld-wt"></span> lbs</div>
</div>
<div style="margin-bottom: 15px;">
<span class="ld-badge bg-rcv">Receiving</span>
<span class="ld-badge bg-hdr">HDR / Disassembly</span>
<span class="ld-badge bg-tst">Test</span>
<span class="ld-badge bg-r2">R2 Downstream</span>
<span class="ld-badge bg-des">Destruction</span>
</div>
<button class="ld-btn" onclick="window.print()">Print Data Tracking Worksheet</button>
<button class="ld-btn" style="background: #1976d2;" onclick="window.location.href='/app/load-list'">Back to Loads</button>
<table class="ld-table" id="ld-table">
<thead>
<tr>
<th rowspan="2">Material Type</th>
<th colspan="3" style="text-align:center; background:#1565c0;">Receiving</th>
<th colspan="3" style="text-align:center; background:#e65100;">HDR / Disassembly</th>
<th colspan="3" style="text-align:center; background:#2e7d32;">Test</th>
<th style="text-align:center; background:#7b1fa2;">R2</th>
<th colspan="4" style="text-align:center; background:#c62828;">Destruction</th>
</tr>
<tr>
<th>Count</th><th>Status</th><th>Send To</th>
<th>Recv'd</th><th>HDD Out</th><th>Send To</th>
<th>Sanitized</th><th>Status</th><th>Send To</th>
<th>Sent</th>
<th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>
</tr>
</thead>
<tbody id="ld-body"></tbody>
</table>
</div>
@@ -0,0 +1,65 @@
frappe.pages["load-detail"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: "Load Detail",
single_column: true
});
var loadName = frappe.utils.get_url_arg("load");
if (!loadName) {
$(page.body).html("<div style='padding:40px;text-align:center;'><h2>No load specified</h2><p>Use ?load=MMDDYYYY-XXXX</p></div>");
return;
}
frappe.call({
method: "frappe.client.get",
args: { doctype: "Load", name: loadName },
callback: function(r) {
if (r.message) { showLoad(page, r.message); }
else { $(page.body).html("<div style='padding:40px;text-align:center;'><h2>Load not found</h2></div>"); }
}
});
function showLoad(page, load) {
var h = "<div style='padding:20px;'>";
h += "<div style='background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px;'>";
h += "<h2>Load: " + load.name + "</h2>";
h += "<div>In Date: " + (load.incoming_date || "N/A") + " | Customer: " + (load.customer_name || load.customer_number || "N/A") + "</div>";
h += "<div>Devices: " + (load.total_devices || 0) + " | Weight: " + (load.total_weight || 0) + " lbs</div>";
h += "</div>";
h += "<button class='btn btn-primary' onclick='window.print()' style='margin-bottom:15px;'>Print Data Tracking Worksheet</button> ";
h += "<a href='/app/load/" + encodeURIComponent(load.name) + "' class='btn btn-default' style='margin-bottom:15px;'>Open Form View</a> ";
h += "<a href='/app/load-update?load=" + encodeURIComponent(load.name) + "' class='btn btn-default' style='margin-bottom:15px;'>Edit Load</a>";
h += "<table style='width:100%;border-collapse:collapse;font-size:13px;margin-top:20px;'>";
h += "<thead><tr style='background:#37474f;color:white;'>";
h += "<th>Material Type</th><th>Count</th><th>Rcv Status</th><th>Send To</th>";
h += "<th>Recv'd</th><th>HDD Out</th><th>Dis Send To</th>";
h += "<th>Sanitized</th><th>Test Status</th><th>Test Send</th>";
h += "<th>R2 Sent</th><th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>";
h += "</tr></thead><tbody>";
if (load.material_items && load.material_items.length > 0) {
load.material_items.forEach(function(item) {
h += "<tr style='border-bottom:1px solid #e0e0e0;'>";
h += "<td><strong>" + (item.material_type || "") + "</strong></td>";
h += "<td style='text-align:right;'>" + (item.total_count || 0) + "</td>";
h += "<td>" + (item.initial_data_status || "") + "</td>";
h += "<td>" + (item.send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.devices_received || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_removed || 0) + "</td>";
h += "<td>" + (item.disassembly_send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.units_sanitized_software || 0) + "</td>";
h += "<td>" + (item.test_data_status || "") + "</td>";
h += "<td>" + (item.test_send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.r2_units_sent || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.units_physical_destruction || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_needs_sanitize || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_physical_destruction || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_logical_sanitization || 0) + "</td>";
h += "</tr>";
});
}
h += "</tbody></table></div>";
$(page.body).html(h);
}
};
@@ -0,0 +1,18 @@
{
"doctype": "Page",
"name": "load-detail",
"page_name": "load-detail",
"title": "Load Detail",
"page_type": "Web Page",
"module": "Westech R2",
"standard": "Yes",
"system_page": 0,
"roles": [
{
"role": "All"
}
],
"content": "",
"script": null,
"style": null
}
@@ -0,0 +1,3 @@
import frappe
no_cache = 1
@@ -0,0 +1 @@
/* CSS */
@@ -0,0 +1,53 @@
<div class="ld-container" style="padding: 20px;">
<style>
.ld-header { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
.ld-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 13px; }
.ld-table th { background: #37474f; color: white; padding: 8px; text-align: left; }
.ld-table td { padding: 6px 8px; border-bottom: 1px solid #e0e0e0; }
.ld-table .num { text-align: right; font-family: monospace; }
.ld-btn { background: #455a64; color: white; border: none; padding: 8px 20px; border-radius: 4px; cursor: pointer; margin-bottom: 20px; margin-right: 10px; }
.ld-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; margin-right: 4px; }
.bg-rcv { background: #e3f2fd; color: #1565c0; }
.bg-hdr { background: #fff3e0; color: #e65100; }
.bg-tst { background: #e8f5e9; color: #2e7d32; }
.bg-r2 { background: #f3e5f5; color: #7b1fa2; }
.bg-des { background: #ffebee; color: #c62828; }
</style>
<div class="ld-header">
<h2>Load: <span id="ld-name"></span></h2>
<div>In Date: <span id="ld-date"></span> | Customer: <span id="ld-cust"></span> | Devices: <span id="ld-dev"></span> | Weight: <span id="ld-wt"></span> lbs</div>
</div>
<div style="margin-bottom: 15px;">
<span class="ld-badge bg-rcv">Receiving</span>
<span class="ld-badge bg-hdr">HDR / Disassembly</span>
<span class="ld-badge bg-tst">Test</span>
<span class="ld-badge bg-r2">R2 Downstream</span>
<span class="ld-badge bg-des">Destruction</span>
</div>
<button class="ld-btn" onclick="window.print()">Print Data Tracking Worksheet</button>
<button class="ld-btn" style="background: #1976d2;" onclick="window.location.href='/app/load-list'">Back to Loads</button>
<table class="ld-table" id="ld-table">
<thead>
<tr>
<th rowspan="2">Material Type</th>
<th colspan="3" style="text-align:center; background:#1565c0;">Receiving</th>
<th colspan="3" style="text-align:center; background:#e65100;">HDR / Disassembly</th>
<th colspan="3" style="text-align:center; background:#2e7d32;">Test</th>
<th style="text-align:center; background:#7b1fa2;">R2</th>
<th colspan="4" style="text-align:center; background:#c62828;">Destruction</th>
</tr>
<tr>
<th>Count</th><th>Status</th><th>Send To</th>
<th>Recv'd</th><th>HDD Out</th><th>Send To</th>
<th>Sanitized</th><th>Status</th><th>Send To</th>
<th>Sent</th>
<th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>
</tr>
</thead>
<tbody id="ld-body"></tbody>
</table>
</div>
@@ -0,0 +1,65 @@
frappe.pages["load-detail"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: "Load Detail",
single_column: true
});
var loadName = frappe.utils.get_url_arg("load");
if (!loadName) {
$(page.body).html("<div style='padding:40px;text-align:center;'><h2>No load specified</h2><p>Use ?load=MMDDYYYY-XXXX</p></div>");
return;
}
frappe.call({
method: "frappe.client.get",
args: { doctype: "Load", name: loadName },
callback: function(r) {
if (r.message) { showLoad(page, r.message); }
else { $(page.body).html("<div style='padding:40px;text-align:center;'><h2>Load not found</h2></div>"); }
}
});
function showLoad(page, load) {
var h = "<div style='padding:20px;'>";
h += "<div style='background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px;'>";
h += "<h2>Load: " + load.name + "</h2>";
h += "<div>In Date: " + (load.incoming_date || "N/A") + " | Customer: " + (load.customer_name || load.customer_number || "N/A") + "</div>";
h += "<div>Devices: " + (load.total_devices || 0) + " | Weight: " + (load.total_weight || 0) + " lbs</div>";
h += "</div>";
h += "<button class='btn btn-primary' onclick='window.print()' style='margin-bottom:15px;'>Print Data Tracking Worksheet</button> ";
h += "<a href='/app/load/" + encodeURIComponent(load.name) + "' class='btn btn-default' style='margin-bottom:15px;'>Open Form View</a> ";
h += "<a href='/app/load-update?load=" + encodeURIComponent(load.name) + "' class='btn btn-default' style='margin-bottom:15px;'>Edit Load</a>";
h += "<table style='width:100%;border-collapse:collapse;font-size:13px;margin-top:20px;'>";
h += "<thead><tr style='background:#37474f;color:white;'>";
h += "<th>Material Type</th><th>Count</th><th>Rcv Status</th><th>Send To</th>";
h += "<th>Recv'd</th><th>HDD Out</th><th>Dis Send To</th>";
h += "<th>Sanitized</th><th>Test Status</th><th>Test Send</th>";
h += "<th>R2 Sent</th><th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>";
h += "</tr></thead><tbody>";
if (load.material_items && load.material_items.length > 0) {
load.material_items.forEach(function(item) {
h += "<tr style='border-bottom:1px solid #e0e0e0;'>";
h += "<td><strong>" + (item.material_type || "") + "</strong></td>";
h += "<td style='text-align:right;'>" + (item.total_count || 0) + "</td>";
h += "<td>" + (item.initial_data_status || "") + "</td>";
h += "<td>" + (item.send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.devices_received || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_removed || 0) + "</td>";
h += "<td>" + (item.disassembly_send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.units_sanitized_software || 0) + "</td>";
h += "<td>" + (item.test_data_status || "") + "</td>";
h += "<td>" + (item.test_send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.r2_units_sent || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.units_physical_destruction || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_needs_sanitize || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_physical_destruction || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_logical_sanitization || 0) + "</td>";
h += "</tr>";
});
}
h += "</tbody></table></div>";
$(page.body).html(h);
}
};
@@ -0,0 +1,18 @@
{
"doctype": "Page",
"name": "load-detail",
"page_name": "load-detail",
"title": "Load Detail",
"page_type": "Web Page",
"module": "Westech R2",
"standard": "Yes",
"system_page": 0,
"roles": [
{
"role": "All"
}
],
"content": "",
"script": null,
"style": null
}
@@ -0,0 +1,3 @@
import frappe
no_cache = 1
@@ -0,0 +1 @@
/* Load Update page CSS */
@@ -0,0 +1,4 @@
<div style="padding:20px;">
<h2>Load Update</h2>
<p>Use the form below to update load material items.</p>
</div>
@@ -0,0 +1,109 @@
frappe.pages["load-update"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: "Load Update",
single_column: true
});
var loadName = frappe.utils.get_url_arg("load");
if (!loadName) {
$(page.body).html("<div style='padding:40px;text-align:center;'><h2>No load specified</h2><p>Use ?load=MMDDYYYY-XXXX</p></div>");
return;
}
function loadLoad() {
frappe.call({
method: "frappe.client.get",
args: { doctype: "Load", name: loadName },
callback: function(r) {
if (r.message) { renderForm(page, r.message); }
else { $(page.body).html("<div style='padding:40px;text-align:center;'><h2>Load not found</h2></div>"); }
}
});
}
function renderForm(page, load) {
var h = "<div style='padding:20px;'>";
h += "<h2>Update Load: " + load.name + "</h2>";
h += "<p>Date: " + (load.incoming_date || "N/A") + " | Customer: " + (load.customer_name || load.customer_number || "N/A") + "</p>";
h += "<div style='margin-bottom:15px;'>";
h += "<button id='lu-save' class='btn btn-primary'>Save Changes</button> ";
h += "<button id='lu-print' class='btn btn-default'>Print Worksheet</button> ";
h += "<a href='/app/load/" + encodeURIComponent(load.name) + "' class='btn btn-default'>Open Form View</a>";
h += "</div>";
h += "<table style='width:100%;border-collapse:collapse;font-size:13px;'>";
h += "<thead><tr style='background:#37474f;color:white;'>";
h += "<th>Material Type</th><th>Count</th><th>Recv Status</th><th>Send To</th>";
h += "<th>Recv'd</th><th>HDD Out</th><th>Dis Send To</th>";
h += "<th>Sanitized</th><th>Test Status</th><th>Test Send</th>";
h += "<th>R2 Sent</th><th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>";
h += "</tr></thead><tbody>";
if (load.material_items && load.material_items.length > 0) {
load.material_items.forEach(function(item, idx) {
var prefix = "item_" + idx + "_";
h += "<tr style='border-bottom:1px solid #e0e0e0;'>";
h += "<td><strong>" + (item.material_type || "") + "</strong></td>";
h += "<td><input type='number' id='" + prefix + "total_count' value='" + (item.total_count || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "initial_data_status' value='" + (item.initial_data_status || "") + "' style='width:60px;'></td>";
h += "<td><input type='text' id='" + prefix + "send_to' value='" + (item.send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "devices_received' value='" + (item.devices_received || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_removed' value='" + (item.hdd_removed || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "disassembly_send_to' value='" + (item.disassembly_send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "units_sanitized_software' value='" + (item.units_sanitized_software || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "test_data_status' value='" + (item.test_data_status || "") + "' style='width:60px;'></td>";
h += "<td><input type='text' id='" + prefix + "test_send_to' value='" + (item.test_send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "r2_units_sent' value='" + (item.r2_units_sent || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "units_physical_destruction' value='" + (item.units_physical_destruction || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_needs_sanitize' value='" + (item.hdd_needs_sanitize || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_physical_destruction' value='" + (item.hdd_physical_destruction || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_logical_sanitization' value='" + (item.hdd_logical_sanitization || 0) + "' style='width:50px;'></td>";
h += "</tr>";
});
}
h += "</tbody></table></div>";
$(page.body).html(h);
$("#lu-save").on("click", function() {
var items = [];
load.material_items.forEach(function(item, idx) {
var prefix = "#item_" + idx + "_";
items.push({
name: item.name,
total_count: parseInt($(prefix + "total_count").val()) || 0,
initial_data_status: $(prefix + "initial_data_status").val(),
send_to: $(prefix + "send_to").val(),
devices_received: parseInt($(prefix + "devices_received").val()) || 0,
hdd_removed: parseInt($(prefix + "hdd_removed").val()) || 0,
disassembly_send_to: $(prefix + "disassembly_send_to").val(),
units_sanitized_software: parseInt($(prefix + "units_sanitized_software").val()) || 0,
test_data_status: $(prefix + "test_data_status").val(),
test_send_to: $(prefix + "test_send_to").val(),
r2_units_sent: parseInt($(prefix + "r2_units_sent").val()) || 0,
units_physical_destruction: parseInt($(prefix + "units_physical_destruction").val()) || 0,
hdd_needs_sanitize: parseInt($(prefix + "hdd_needs_sanitize").val()) || 0,
hdd_physical_destruction: parseInt($(prefix + "hdd_physical_destruction").val()) || 0,
hdd_logical_sanitization: parseInt($(prefix + "hdd_logical_sanitization").val()) || 0
});
});
frappe.call({
method: "westech_r2.page.load-update.load-update.save_load_items",
args: { load_name: loadName, items: JSON.stringify(items) },
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.show_alert({message: "Saved", indicator: "green"});
loadLoad();
} else {
frappe.show_alert({message: "Save failed", indicator: "red"});
}
}
});
});
$("#lu-print").on("click", function() {
window.print();
});
}
loadLoad();
};
@@ -0,0 +1,18 @@
{
"doctype": "Page",
"name": "load-update",
"page_name": "load-update",
"title": "Load Update",
"page_type": "Web Page",
"module": "Westech R2",
"standard": "Yes",
"system_page": 0,
"roles": [
{
"role": "All"
}
],
"content": "",
"script": null,
"style": null
}
@@ -0,0 +1,17 @@
import frappe
import json
@frappe.whitelist()
def save_load_items(load_name, items):
items = json.loads(items)
load_doc = frappe.get_doc("Load", load_name)
for item_data in items:
for row in load_doc.material_items:
if row.name == item_data["name"]:
for field, value in item_data.items():
if field != "name":
row.set(field, value)
break
load_doc.save(ignore_permissions=True)
frappe.db.commit()
return {"status": "ok", "message": "Saved " + str(len(items)) + " items"}
@@ -0,0 +1 @@
/* Load Update page CSS */
@@ -0,0 +1,4 @@
<div style="padding:20px;">
<h2>Load Update</h2>
<p>Use the form below to update load material items.</p>
</div>
@@ -0,0 +1,109 @@
frappe.pages["load-update"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: "Load Update",
single_column: true
});
var loadName = frappe.utils.get_url_arg("load");
if (!loadName) {
$(page.body).html("<div style='padding:40px;text-align:center;'><h2>No load specified</h2><p>Use ?load=MMDDYYYY-XXXX</p></div>");
return;
}
function loadLoad() {
frappe.call({
method: "frappe.client.get",
args: { doctype: "Load", name: loadName },
callback: function(r) {
if (r.message) { renderForm(page, r.message); }
else { $(page.body).html("<div style='padding:40px;text-align:center;'><h2>Load not found</h2></div>"); }
}
});
}
function renderForm(page, load) {
var h = "<div style='padding:20px;'>";
h += "<h2>Update Load: " + load.name + "</h2>";
h += "<p>Date: " + (load.incoming_date || "N/A") + " | Customer: " + (load.customer_name || load.customer_number || "N/A") + "</p>";
h += "<div style='margin-bottom:15px;'>";
h += "<button id='lu-save' class='btn btn-primary'>Save Changes</button> ";
h += "<button id='lu-print' class='btn btn-default'>Print Worksheet</button> ";
h += "<a href='/app/load/" + encodeURIComponent(load.name) + "' class='btn btn-default'>Open Form View</a>";
h += "</div>";
h += "<table style='width:100%;border-collapse:collapse;font-size:13px;'>";
h += "<thead><tr style='background:#37474f;color:white;'>";
h += "<th>Material Type</th><th>Count</th><th>Recv Status</th><th>Send To</th>";
h += "<th>Recv'd</th><th>HDD Out</th><th>Dis Send To</th>";
h += "<th>Sanitized</th><th>Test Status</th><th>Test Send</th>";
h += "<th>R2 Sent</th><th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>";
h += "</tr></thead><tbody>";
if (load.material_items && load.material_items.length > 0) {
load.material_items.forEach(function(item, idx) {
var prefix = "item_" + idx + "_";
h += "<tr style='border-bottom:1px solid #e0e0e0;'>";
h += "<td><strong>" + (item.material_type || "") + "</strong></td>";
h += "<td><input type='number' id='" + prefix + "total_count' value='" + (item.total_count || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "initial_data_status' value='" + (item.initial_data_status || "") + "' style='width:60px;'></td>";
h += "<td><input type='text' id='" + prefix + "send_to' value='" + (item.send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "devices_received' value='" + (item.devices_received || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_removed' value='" + (item.hdd_removed || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "disassembly_send_to' value='" + (item.disassembly_send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "units_sanitized_software' value='" + (item.units_sanitized_software || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "test_data_status' value='" + (item.test_data_status || "") + "' style='width:60px;'></td>";
h += "<td><input type='text' id='" + prefix + "test_send_to' value='" + (item.test_send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "r2_units_sent' value='" + (item.r2_units_sent || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "units_physical_destruction' value='" + (item.units_physical_destruction || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_needs_sanitize' value='" + (item.hdd_needs_sanitize || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_physical_destruction' value='" + (item.hdd_physical_destruction || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_logical_sanitization' value='" + (item.hdd_logical_sanitization || 0) + "' style='width:50px;'></td>";
h += "</tr>";
});
}
h += "</tbody></table></div>";
$(page.body).html(h);
$("#lu-save").on("click", function() {
var items = [];
load.material_items.forEach(function(item, idx) {
var prefix = "#item_" + idx + "_";
items.push({
name: item.name,
total_count: parseInt($(prefix + "total_count").val()) || 0,
initial_data_status: $(prefix + "initial_data_status").val(),
send_to: $(prefix + "send_to").val(),
devices_received: parseInt($(prefix + "devices_received").val()) || 0,
hdd_removed: parseInt($(prefix + "hdd_removed").val()) || 0,
disassembly_send_to: $(prefix + "disassembly_send_to").val(),
units_sanitized_software: parseInt($(prefix + "units_sanitized_software").val()) || 0,
test_data_status: $(prefix + "test_data_status").val(),
test_send_to: $(prefix + "test_send_to").val(),
r2_units_sent: parseInt($(prefix + "r2_units_sent").val()) || 0,
units_physical_destruction: parseInt($(prefix + "units_physical_destruction").val()) || 0,
hdd_needs_sanitize: parseInt($(prefix + "hdd_needs_sanitize").val()) || 0,
hdd_physical_destruction: parseInt($(prefix + "hdd_physical_destruction").val()) || 0,
hdd_logical_sanitization: parseInt($(prefix + "hdd_logical_sanitization").val()) || 0
});
});
frappe.call({
method: "westech_r2.page.load-update.load-update.save_load_items",
args: { load_name: loadName, items: JSON.stringify(items) },
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.show_alert({message: "Saved", indicator: "green"});
loadLoad();
} else {
frappe.show_alert({message: "Save failed", indicator: "red"});
}
}
});
});
$("#lu-print").on("click", function() {
window.print();
});
}
loadLoad();
};
@@ -0,0 +1,18 @@
{
"doctype": "Page",
"name": "load-update",
"page_name": "load-update",
"title": "Load Update",
"page_type": "Web Page",
"module": "Westech R2",
"standard": "Yes",
"system_page": 0,
"roles": [
{
"role": "All"
}
],
"content": "",
"script": null,
"style": null
}
@@ -0,0 +1,17 @@
import frappe
import json
@frappe.whitelist()
def save_load_items(load_name, items):
items = json.loads(items)
load_doc = frappe.get_doc("Load", load_name)
for item_data in items:
for row in load_doc.material_items:
if row.name == item_data["name"]:
for field, value in item_data.items():
if field != "name":
row.set(field, value)
break
load_doc.save(ignore_permissions=True)
frappe.db.commit()
return {"status": "ok", "message": "Saved " + str(len(items)) + " items"}
@@ -0,0 +1 @@
# Pallet List page for Westech R2
@@ -0,0 +1,82 @@
<style>
.pallet-list-page { font-family: Helvetica Neue, Arial, sans-serif; }
.pallet-list-page h2 { color: #6f42c1; margin-bottom: 16px; font-size: 20px; }
.pallet-search-box { margin-bottom: 16px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.pallet-search-box input, .pallet-search-box select {
padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;
}
.pallet-search-box button {
padding: 6px 14px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;
font-size: 13px; background: white;
}
.pallet-search-box button.btn-primary { background: #6f42c1; color: white; border-color: #6f42c1; }
.pallet-search-box button.btn-success { background: #28a745; color: white; border-color: #28a745; }
.pallet-table-container { padding: 0; overflow-x: auto; background: white; border-radius: 4px; }
.pallet-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.pallet-table th {
background: #6f42c1; color: white; padding: 8px 10px; text-align: left;
cursor: pointer; white-space: nowrap; font-weight: 500;
}
.pallet-table td { padding: 5px 10px; border-bottom: 1px solid #eee; }
.pallet-table tr:hover { background: #f5f5f5; }
.pallet-table tr.new-row { background: #FFF9E6 !important; }
.pallet-table a { color: #6f42c1; text-decoration: none; font-weight: 600; }
.status-received { color: #2196F3; font-weight: 600; }
.status-sorting { color: #FF9800; font-weight: 600; }
.status-processing { color: #9C27B0; font-weight: 600; }
.status-complete { color: #4CAF50; font-weight: 600; }
.status-shipped { color: #607D8B; font-weight: 600; }
.pallet-pagination { margin-top: 12px; display: flex; gap: 5px; align-items: center; }
.pallet-pagination button {
padding: 5px 12px; border: 1px solid #ddd; background: white; cursor: pointer;
border-radius: 4px; font-size: 13px; color: #6f42c1;
}
.pallet-pagination button.active { background: #6f42c1; color: white; border-color: #6f42c1; }
.pallet-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pallet-count { margin-left: auto; color: #666; font-size: 13px; }
</style>
<div class="pallet-list-page">
<h2>📦 Pallet List</h2>
<div class="pallet-search-box">
<input type="text" id="pallet-search" placeholder="Search pallet #..." style="max-width:200px;">
<select id="status-filter">
<option value="">All Statuses</option>
<option value="Received">Received</option>
<option value="Sorting">Sorting</option>
<option value="Processing">Processing</option>
<option value="Complete">Complete</option>
<option value="Shipped">Shipped</option>
</select>
<button class="btn btn-default" id="btn-clear">✕ Clear</button>
<button class="btn btn-success" id="btn-export">⬇ Export CSV</button>
<span class="pallet-count" id="pallet-count"></span>
</div>
<div class="pallet-table-container">
<table class="pallet-table" id="pallet-table">
<thead>
<tr>
<th data-sort="pallet_number">Pallet # ⇅</th>
<th data-sort="date_reserved">Date Reserved ⇅</th>
<th data-sort="received_date">Rec. Date ⇅</th>
<th data-sort="customer_number">Customer # ⇅</th>
<th data-sort="inbound_weight">Lbs ⇅</th>
<th data-sort="tester">Who ⇅</th>
<th data-sort="description">Items Tested ⇅</th>
<th data-sort="qty_to_sales">QTY Sale ⇅</th>
<th data-sort="weight_to_sales">Lbs Sale ⇅</th>
<th data-sort="finish_date">Finish Date ⇅</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody id="pallet-tbody">
<tr><td colspan="12" style="text-align:center;padding:40px;color:#666;">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="pallet-pagination" id="pallet-pagination"></div>
</div>
@@ -0,0 +1,128 @@
frappe.pages["pallet-list"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Pallet List"),
single_column: true
});
var content = frappe.render_template("pallet-list", {});
$(page.body).append(content);
var currentPage = 1;
var pageSize = 100;
var currentSort = "pallet_number";
var sortDir = "desc";
var searchTerm = "";
var statusFilter = "";
function loadPallets() {
frappe.call({
method: "westech_r2.page.pallet_list.pallet_list.get_pallets",
args: {
page: currentPage,
page_size: pageSize,
sort_field: currentSort,
sort_dir: sortDir,
status_filter: statusFilter,
search: searchTerm
},
callback: function(r) {
if (r.message) {
renderPallets(r.message.pallets, r.message.total);
}
}
});
}
function renderPallets(pallets, total) {
var tbody = $("#pallet-tbody");
tbody.empty();
if (!pallets || pallets.length === 0) {
tbody.append('<tr><td colspan="13" style="text-align:center;padding:40px;">No pallets found</td></tr>');
return;
}
pallets.forEach(function(p) {
var statusClass = "status-" + (p.status || "").toLowerCase().replace(/\s+/g, "-");
var link = "/app/pallet/" + encodeURIComponent(p.name);
var pn = (p.pallet_number || "").replace(/</g, "&lt;").replace(/>/g, "&gt;");
var row = '<tr>' +
'<td><a href="' + link + '" style="color:#3cc062;text-decoration:none;font-weight:600;">' + pn + '</a></td>' +
'<td>' + fmtDate(p.date_reserved) + '</td>' +
'<td>' + fmtDate(p.received_date) + '</td>' +
'<td>' + (p.customer_number || "") + '</td>' +
'<td>' + (p.company_name || "") + '</td>' +
'<td>' + (p.inbound_weight || "") + '</td>' +
'<td>' + (p.tester || "") + '</td>' +
'<td>' + (p.description || "") + '</td>' +
'<td>' + (p.qty_to_sales || "") + '</td>' +
'<td>' + (p.weight_to_sales || "") + '</td>' +
'<td>' + fmtDate(p.finish_date) + '</td>' +
'<td class="' + statusClass + '">' + (p.status || "") + '</td>' +
'<td>' + (p.notes || "").substring(0, 50) + '</td>' +
'</tr>';
tbody.append(row);
});
renderPagination(total);
}
function renderPagination(total) {
var totalPages = Math.ceil(total / pageSize);
var pagination = $("#pallet-pagination");
pagination.empty();
if (totalPages <= 1) return;
var prevBtn = $('<button>&laquo; Prev</button>').attr("disabled", currentPage === 1)
.css({padding: "5px 12px", border: "1px solid #ddd", background: "white", cursor: "pointer", marginRight: "5px"})
.on("click", function() {
if (currentPage > 1) { currentPage--; loadPallets(); }
});
pagination.append(prevBtn);
var startPage = Math.max(1, currentPage - 2);
var endPage = Math.min(totalPages, startPage + 4);
for (var i = startPage; i <= endPage; i++) {
var btn = $('<button>' + i + '</button>')
.css({padding: "5px 12px", border: "1px solid #ddd", background: i === currentPage ? "#3cc062" : "white",
color: i === currentPage ? "white" : "#333", cursor: "pointer", marginRight: "5px"})
.on("click", function(page) {
return function() { currentPage = page; loadPallets(); };
}(i));
pagination.append(btn);
}
var nextBtn = $('<button>Next &raquo;</button>').attr("disabled", currentPage === totalPages)
.css({padding: "5px 12px", border: "1px solid #ddd", background: "white", cursor: "pointer", marginRight: "5px"})
.on("click", function() {
if (currentPage < totalPages) { currentPage++; loadPallets(); }
});
pagination.append(nextBtn);
}
function fmtDate(v) {
if (!v) return "";
var s = String(v);
if (s.indexOf("T") > -1) s = s.split("T")[0];
if (s.indexOf(" ") > -1) s = s.split(" ")[0];
return s;
}
$("#pallet-search").on("input", function() {
searchTerm = $(this).val();
currentPage = 1;
loadPallets();
});
$("#status-filter").on("change", function() {
statusFilter = $(this).val();
currentPage = 1;
loadPallets();
});
loadPallets();
};
@@ -0,0 +1,19 @@
{
"content": null,
"creation": "2026-05-19 13:00:00.000000",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-19 13:00:00.000000",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "pallet-list",
"owner": "Administrator",
"page_name": "pallet-list",
"roles": [],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Pallet List"
}
@@ -0,0 +1,64 @@
import frappe
@frappe.whitelist()
def get_pallets(page=1, page_size=100, sort_field="pallet_number", sort_dir="desc", status_filter="", search=""):
page = int(page)
page_size = int(page_size)
offset = (page - 1) * page_size
conditions = [
"pallet_number IS NOT NULL",
"pallet_number != ''",
"date_reserved IS NOT NULL",
"customer_number IS NOT NULL",
"customer_number != ''"
]
junk = ["", "0", "0000", "N/A", "TBD", "null", "999990", "999995"]
junk_list = "', '".join(junk)
conditions.append("pallet_number NOT IN ('" + junk_list + "')")
conditions.append("pallet_number NOT LIKE '999%'")
conditions.append("pallet_number REGEXP '^[0-9]'")
if status_filter:
conditions.append("status = '" + frappe.db.escape(status_filter) + "'")
if search:
conditions.append("pallet_number LIKE '%" + frappe.db.escape(search) + "%'")
where_clause = " AND ".join(conditions)
total = frappe.db.sql("SELECT COUNT(*) FROM tabPallet WHERE " + where_clause)[0][0]
pallets = frappe.db.sql("SELECT name, pallet_number, date_reserved, received_date, customer_number, company_name, inbound_weight, tester, description, qty_to_sales, weight_to_sales, finish_date, notes, status FROM tabPallet WHERE " + where_clause + " ORDER BY CAST(pallet_number AS UNSIGNED) " + sort_dir + " LIMIT " + str(page_size) + " OFFSET " + str(offset), as_dict=True)
empty_pallet = frappe.db.sql("SELECT name, pallet_number, date_reserved, received_date, customer_number, company_name, inbound_weight, tester, description, qty_to_sales, weight_to_sales, finish_date, notes, status FROM tabPallet WHERE date_reserved IS NULL AND (customer_number IS NULL OR customer_number = '') AND pallet_number NOT IN ('" + junk_list + "') AND pallet_number NOT LIKE '999%' AND pallet_number REGEXP '^[0-9]' ORDER BY CAST(pallet_number AS UNSIGNED) DESC LIMIT 1", as_dict=True)
result = []
if empty_pallet:
empty_pallet[0]["_is_new"] = True
result.append(empty_pallet[0])
result.extend(pallets)
return {"pallets": result, "total": total, "page": page, "page_size": page_size}
@frappe.whitelist()
def update_pallet(docname, field, value):
pallet = frappe.get_doc("Pallet", docname)
pallet.set(field, value)
pallet.save(ignore_permissions=True)
frappe.db.commit()
return {"status": "ok", "message": "Updated " + field}
@frappe.whitelist()
def create_pallet(data):
data = frappe.parse_json(data)
pallet = frappe.new_doc("Pallet")
pallet.pallet_number = data.get("pallet_number")
pallet.status = data.get("status", "Received")
pallet.date_reserved = data.get("date_reserved")
for field, value in data.items():
if field not in ["pallet_number", "status", "date_reserved"] and value:
pallet.set(field, value)
pallet.insert(ignore_permissions=True)
frappe.db.commit()
return {"status": "ok", "name": pallet.name}
@@ -0,0 +1 @@
@@ -0,0 +1,4 @@
frappe.pages["r2-tracking"].on_page_load = function(wrapper) {
wrapper.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:60vh;font-family:sans-serif;"><div style="text-align:center;"><i class="fa fa-spinner fa-spin" style="font-size:24px;color:#1a6b8a;"></i><p style="margin-top:12px;color:#555;">Redirecting to R2 Data Tracking...</p></div></div>';
setTimeout(function() { window.location.href = "https://eim.diagalon.com/report/data-tracking-form"; }, 500);
};
@@ -0,0 +1,13 @@
{
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 14:00:00",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "r2-tracking",
"owner": "Administrator",
"standard": "Yes",
"title": "R2 Data Tracking"
}
@@ -0,0 +1,5 @@
import frappe
def get_context(context):
frappe.local.flags.redirect_location = "https://eim.diagalon.com/report/data-tracking-form"
raise frappe.Redirect
@@ -0,0 +1,7 @@
frappe.pages['r2-tracking'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'R2 Data Tracking',
single_column: true
});
}
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 15:09:48.707863",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "r2-tracking",
"owner": "Administrator",
"page_name": "r2-tracking",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "R2 Data Tracking"
}
@@ -0,0 +1,642 @@
frappe.pages['receiving'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'Receiving',
single_column: true
});
// Inline HTML
$(wrapper).find('.layout-main-section').html(`
<div class="receiving-station" style="padding: 20px;">
<div class="row">
<div class="col-md-12">
<h3 style="margin-top: 0; color: #2F5496;">🚛 Receiving</h3>
<p class="text-muted">Schedule pickups, manage routes, and check in loads.</p>
</div>
</div>
<ul class="nav nav-tabs" role="tablist" id="receiving-tabs">
<li role="presentation" class="active"><span class="stage-tab active" data-target="#stage-a" style="cursor:pointer">📋 Stage A — Schedule Pickup</span></li>
<li role="presentation"><span class="stage-tab" data-target="#stage-b" style="cursor:pointer">🗺️ Stage B — Route & Dispatch</span></li>
<li role="presentation"><span class="stage-tab" data-target="#stage-c" style="cursor:pointer">⚖️ Stage C — Load Check-in</span></li>
</ul>
<div class="tab-content" style="padding-top: 20px;">
<div role="tabpanel" class="tab-pane active" id="stage-a">
<div class="row">
<div class="col-md-3">
<div class="panel panel-primary">
<div class="panel-heading">📅 Pickup Calendar — Next 30 Days</div>
<div class="panel-body" id="pickup-calendar"><div class="text-muted text-center">Loading...</div></div>
</div>
</div>
<div class="col-md-9">
<div class="panel panel-default">
<div class="panel-heading">
<div class="row">
<div class="col-md-6"><strong>Scheduled Pickups</strong><span id="pickup-count-label" class="text-muted" style="margin-left: 8px;"></span></div>
<div class="col-md-6 text-right">
<button class="btn btn-primary btn-sm" id="btn-new-pickup">+ New Pickup</button>
<input type="date" id="pickup-date-filter" class="form-control input-sm" style="display:inline-block;width:auto;vertical-align:middle;margin-left:8px;">
<button class="btn btn-default btn-sm" id="btn-clear-date" style="margin-left:4px;">Clear</button>
</div>
</div>
</div>
<div class="panel-body" style="padding: 0; overflow-x: auto;">
<table class="table table-striped table-hover" id="pickup-table" style="font-size: 13px; margin-bottom: 0;">
<thead><tr><th>Date</th><th>Weekday</th><th>Type</th><th>Customer</th><th>Contact</th><th>Address</th><th>Est. Items</th><th>Data</th><th>RED/R2</th><th>Status</th><th>Notes</th><th>Truck</th><th>AoR</th><th>CoD</th></tr></thead>
<tbody id="pickup-tbody"><tr><td colspan="14" class="text-center text-muted">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="new-pickup-form" style="display:none; margin-top: 16px;">
<div class="panel panel-primary">
<div class="panel-heading">+ New Scheduled Pickup</div>
<div class="panel-body">
<form id="pickup-form">
<div class="row">
<div class="col-md-4">
<h5 style="color:#6f42c1;">📅 Pickup Info</h5>
<div class="form-group"><label>Pickup Date <span class="text-danger">*</span></label><input type="date" id="sp-pickup_date" class="form-control" required></div>
<div class="form-group"><label>Type <span class="text-danger">*</span></label><select id="sp-pickup_type" class="form-control" required><option value="Pickup">Pickup</option><option value="Drop-off">Drop-off</option></select></div>
<div class="form-group">
<label>Customer <span class="text-danger">*</span></label>
<div class="input-group">
<div id="sp-customer-control" style="flex:1;"></div>
<span class="input-group-btn"><button class="btn btn-success" id="btn-new-customer" type="button" title="New Customer">+</button></span>
</div>
</div>
<div class="form-group"><label>Company Name</label><input type="text" id="sp-company_name" class="form-control"></div>
<div class="form-group"><label>Contact Name</label><input type="text" id="sp-contact_name" class="form-control"></div>
<div class="form-group"><label>Contact Phone</label><input type="text" id="sp-contact_phone" class="form-control"></div>
<div class="form-group"><label>Contact Email</label><input type="email" id="sp-contact_email" class="form-control"></div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">📍 Address</h5>
<div class="form-group"><label>Street Address</label><input type="text" id="sp-address_line" class="form-control"></div>
<div class="form-group"><label>City</label><input type="text" id="sp-city" class="form-control"></div>
<div class="form-group"><label>State</label><input type="text" id="sp-state" class="form-control" value="AZ"></div>
<div class="form-group"><label>ZIP</label><input type="text" id="sp-zip_code" class="form-control"></div>
<div class="form-group"><label>Hours of Operation</label><input type="text" id="sp-hours_of_operation" class="form-control" placeholder="e.g. Mon-Fri 8am-5pm"></div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">📦 Load Info</h5>
<div class="form-group"><label>Estimated Items</label><input type="number" id="sp-estimated_items" class="form-control"></div>
<div class="form-group"><label>Estimated Weight</label><input type="text" id="sp-estimated_weight" class="form-control"></div>
<div class="form-group"><label>Load Contents</label><input type="text" id="sp-load_contents" class="form-control" placeholder="Wire, Monitors, Laptops..."></div>
<div class="form-group"><label>Data Status</label><select id="sp-data_status" class="form-control"><option value="">—</option><option value="D0">D0</option><option value="D1">D1</option><option value="ND1">ND1</option><option value="ND2">ND2</option><option value="ND3">ND3</option><option value="ND4">ND4</option></select></div>
<div class="form-group"><label>RED / R2</label><select id="sp-red_r2" class="form-control"><option value="">—</option><option value="RED">RED</option><option value="NIST">NIST</option><option value="Red+NIST">Red+NIST</option><option value="R2">R2</option><option value="Both">Both</option><option value="Neither">Neither</option></select></div>
<div class="form-group"><div class="checkbox"><label><input type="checkbox" id="sp-needs_aor"> <strong>Needs AoR</strong></label></div><div class="checkbox"><label><input type="checkbox" id="sp-needs_cod"> <strong>Needs CoD</strong></label></div></div>
<div class="form-group"><label>Notes / Special Handling</label><textarea id="sp-notes" class="form-control" rows="3"></textarea></div>
</div>
</div>
<div class="row" style="margin-top: 16px;"><div class="col-md-12"><button type="submit" class="btn btn-primary btn-lg">Save Pickup</button><button type="button" class="btn btn-default btn-lg" id="btn-cancel-pickup">Cancel</button></div></div>
</form>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="stage-b">
<div class="row" style="margin-bottom: 16px;"><div class="col-md-12"><div class="btn-group"><input type="date" id="route-date" class="form-control" style="display:inline-block;width:auto;"><button class="btn btn-primary" id="btn-load-routes">Load Pickups</button><button class="btn btn-primary" id="btn-auto-route">🧮 Auto-Route</button><button class="btn btn-success" id="btn-route-sheet">🖨️ Route Sheet</button><button class="btn btn-success" id="btn-green-sheet">📄 Green Sheet</button><button class="btn btn-success" id="btn-labels">🏷️ Labels</button></div></div></div>
<div class="row" id="route-columns">
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 1"><div class="panel-heading">🚛 Truck 1 <span id="truck1-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck1-stops"></div></div></div>
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 2"><div class="panel-heading">🚛 Truck 2 <span id="truck2-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck2-stops"></div></div></div>
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 3"><div class="panel-heading">🚛 Truck 3 <span id="truck3-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck3-stops"></div></div></div>
</div>
<div class="row" style="margin-top: 16px;"><div class="col-md-12"><div class="panel panel-default truck-column" data-truck=""><div class="panel-heading">📋 Unassigned <span id="unassigned-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="unassigned-stops"></div></div></div></div>
</div>
<div role="tabpanel" class="tab-pane" id="stage-c">
<div class="row" style="margin-bottom: 16px;"><div class="col-md-12"><button class="btn btn-primary" id="btn-new-checkin">+ Check In Load</button><button class="btn btn-success" id="btn-cor-report">📋 CoR Report</button></div></div>
<div class="panel panel-default">
<div class="panel-heading">Recent Check-ins</div>
<div class="panel-body" style="padding: 0; overflow-x: auto;">
<table class="table table-striped table-hover" id="checkin-table" style="font-size: 13px; margin-bottom: 0;">
<thead><tr><th>Date</th><th>Customer</th><th>Load #</th><th class="text-right">Pallets</th><th class="text-right">Weight</th><th>Contents</th><th>Data Status</th><th>RED/R2</th></tr></thead>
<tbody id="checkin-tbody"><tr><td colspan="8" class="text-center text-muted">Loading...</td></tr></tbody>
</table>
</div>
</div>
<div id="checkin-form" style="display:none; margin-top: 16px;">
<div class="panel panel-primary">
<div class="panel-heading">+ Load Check-in</div>
<div class="panel-body">
<form id="checkin-form-inner">
<div class="row">
<div class="col-md-4">
<h5 style="color:#6f42c1;">📅 Pickup Reference</h5>
<div class="form-group"><label>Scheduled Pickup <span class="text-danger">*</span></label><div id="ci-pickup-control"></div></div>
<div id="ci-pickup-details" style="display:none; background:#f8f9fa; border:1px solid #ddd; border-radius:4px; padding:10px; margin-bottom:10px;">
<div id="ci-customer-info" style="font-weight:700;"></div>
<div id="ci-address-info" style="font-size:12px; color:#666;"></div>
<div id="ci-contact-info" style="font-size:12px; color:#666;"></div>
<div id="ci-special-handling" style="display:none; margin-top:8px; padding:6px; background:#FFCDD2; border:1px solid #C62828; border-radius:4px; font-size:12px;"></div>
<div id="ci-pickup-notes" style="display:none; margin-top:8px; padding:6px; background:#F5F5F5; border:1px solid #999; border-radius:4px; font-size:12px;"></div>
</div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">⚖️ Actual Load</h5>
<div class="form-group"><label>Received Date <span class="text-danger">*</span></label><input type="date" id="ci-received_date" class="form-control" required></div>
<div class="form-group"><label>Actual # of Pallets/Gaylords <span class="text-danger">*</span></label><input type="number" id="ci-actual_pallets" class="form-control" min="1" required></div>
<div class="form-group"><label>Total Weight (lbs)</label><input type="text" id="ci-total_weight" class="form-control"></div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">📦 Contents & Classification</h5>
<div class="form-group"><label>Load Contents</label><textarea id="ci-load_contents" class="form-control" rows="2" placeholder="e.g. 80% wire 20% tablets"></textarea></div>
<div class="form-group"><label>Data Status</label><select id="ci-data_status" class="form-control"><option value="">—</option><option value="D0">D0</option><option value="D1">D1</option><option value="ND1">ND1</option><option value="ND2">ND2</option><option value="ND3">ND3</option><option value="ND4">ND4</option></select></div>
<div class="form-group"><label>RED / R2</label><select id="ci-red_r2" class="form-control"><option value="">—</option><option value="RED">RED</option><option value="NIST">NIST</option><option value="Red+NIST">Red+NIST</option><option value="R2">R2</option><option value="Both">Both</option><option value="Neither">Neither</option></select></div>
</div>
</div>
<div class="row" style="margin-top: 8px;"><div class="col-md-12"><button type="submit" class="btn btn-primary btn-lg">✓ Check In Load</button><button type="button" class="btn btn-default btn-lg" id="btn-cancel-checkin">Cancel</button></div></div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.truck-stops { min-height: 60px; }
.stop-card { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 10px 12px; margin: 6px 0; cursor: grab; font-size: 13px; }
.stop-card:hover { border-color: #2F5496; }
.stop-card .stop-co { font-weight: 700; color: #2F5496; }
.stop-card .stop-addr { color: #666; font-size: 12px; margin-top: 2px; }
.stop-card .stop-meta { display: flex; gap: 8px; margin-top: 4px; font-size: 11px; }
.stop-card .stop-meta span { background: #D6E4F0; color: #2F5496; padding: 1px 6px; border-radius: 3px; }
.stop-card.dragging { opacity: 0.5; }
.cal-day { display: inline-block; width: 36px; height: 36px; line-height: 36px; text-align: center; margin: 1px; border-radius: 4px; font-size: 12px; cursor: pointer; }
.cal-day:hover { background: #D6E4F0; }
.cal-day.has-pickups { background: #2F5496; color: #fff; font-weight: 700; }
.stage-tab { display: inline-block; padding: 10px 15px; font-size: 14px; color: #555; } .stage-tab:hover { color: #2F5496; } .stage-tab.active { color: #2F5496; font-weight: 600; border-bottom: 2px solid #2F5496; }
.cal-day.today { border: 2px solid #C62828; }
</style>
`);
// Prevent Frappe router from intercepting tab clicks
// Tab switching — direct DOM to avoid Frappe router intercepting <a> clicks
$("#receiving-tabs").on("click", ".stage-tab", function() {
var target = $(this).data("target");
$("#receiving-tabs li, .stage-tab").removeClass("active");
$(this).addClass("active").closest("li").addClass("active");
$(".tab-pane").removeClass("active");
$(target).addClass("active");
});
// ── Stage A: Link Controls ──
var customer_control = null;
function setupCustomerLink() {
customer_control = frappe.ui.form.make_control({
parent: $("#sp-customer-control"),
df: {
fieldtype: "Link",
fieldname: "customer_number",
options: "Customer",
label: "Customer",
reqd: 1,
placeholder: "Search by name, number, or address...",
onchange: function() {
var val = customer_control.get_value();
if (val) fetchCustomerDetails(val);
else clearCustomerFields();
}
},
only_input: true,
});
customer_control.refresh();
$("#sp-customer-control .control-input").css("margin", "0");
$("#sp-customer-control .help-box").remove();
}
// New Customer button
$(document).on("click", "#btn-new-customer", function() {
frappe.call({
method: "frappe.client.insert",
args: { doc: { doctype: "Customer", customer_type: "Company", customer_group: "All Customer Groups", territory: "United States" } },
callback: function(r) {
if (r.message) {
frappe.set_route("Form", "Customer", r.message.name);
}
}
});
});
function fetchCustomerDetails(customer_name) {
frappe.call({
method: "frappe.client.get",
args: { doctype: "Customer", name: customer_name },
callback: function(r) {
if (!r.message) return;
var c = r.message;
$("#sp-company_name").val(c.customer_name || "");
$("#sp-contact_name").val(c.contact_name || "");
$("#sp-contact_phone").val(c.contact_phone || "");
$("#sp-contact_email").val(c.contact_email || "");
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Address",
filters: [["Dynamic Link", "link_name", "=", customer_name]],
fields: ["address_line1", "city", "state", "pincode"],
limit_page_length: 1
},
callback: function(ra) {
if (ra.message && ra.message.length) {
var a = ra.message[0];
$("#sp-address_line").val(a.address_line1 || "");
$("#sp-city").val(a.city || "");
$("#sp-state").val(a.state || "AZ");
$("#sp-zip_code").val(a.pincode || "");
}
}
});
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Contact",
filters: [["Dynamic Link", "link_name", "=", customer_name]],
fields: ["first_name", "last_name", "email_id", "phone", "mobile_no"],
limit_page_length: 1
},
callback: function(rc) {
if (rc.message && rc.message.length) {
var ct = rc.message[0];
if (!$("#sp-contact_name").val()) {
$("#sp-contact_name").val((ct.first_name || "") + " " + (ct.last_name || ""));
}
if (!$("#sp-contact_phone").val()) {
$("#sp-contact_phone").val(ct.phone || ct.mobile_no || "");
}
if (!$("#sp-contact_email").val()) {
$("#sp-contact_email").val(ct.email_id || "");
}
}
}
});
}
});
}
function clearCustomerFields() {
$("#sp-company_name, #sp-contact_name, #sp-contact_phone, #sp-contact_email, #sp-address_line, #sp-city, #sp-state, #sp-zip_code").val("");
$("#sp-state").val("AZ");
}
// ── Stage A: Load Pickups ──
function loadPickups() {
var dateFilter = $("#pickup-date-filter").val();
frappe.call({
method: "westech_r2.api.receiving_api.get_pickups",
args: { date: dateFilter || undefined },
callback: function(r) {
if (r.message) {
renderPickupTable(r.message.pickups || []);
renderCalendar(r.message.calendar || []);
}
}
});
}
function renderPickupTable(pickups) {
var tbody = $("#pickup-tbody");
$("#pickup-count-label").text("(" + pickups.length + ")");
if (!pickups.length) {
tbody.html('<tr><td colspan="14" class="text-center text-muted">No pickups found</td></tr>');
return;
}
tbody.html(pickups.map(function(p) {
var dt = p.pickup_date ? new Date(p.pickup_date + "T00:00:00") : null;
var dn = dt ? dayName(dt) : "";
return '<tr style="cursor:pointer" data-pickup="' + esc(p.name) + '">' +
'<td>' + esc(p.pickup_date || "") + '</td>' +
'<td>' + dn + '</td>' +
'<td>' + esc(p.pickup_type || "") + '</td>' +
'<td><strong>' + esc(p.company_name || p.customer_number || "") + '</strong></td>' +
'<td>' + esc(p.contact_name || "") + '</td>' +
'<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc((p.address_line || "") + (p.city ? ", " + p.city : "")) + '</td>' +
'<td class="text-right">' + (p.estimated_items || "—") + '</td>' +
'<td>' + esc(p.data_status || "—") + '</td>' +
'<td>' + esc(p.red_r2 || "—") + '</td>' +
'<td>' + esc(p.status || "") + '</td>' +
'<td style="max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(p.notes || "") + '</td>' +
'<td>' + esc(p.truck || "") + '</td>' +
'<td>' + (p.needs_aor ? "✓" : "") + '</td>' +
'<td>' + (p.needs_cod ? "✓" : "") + '</td></tr>';
}).join(""));
}
function renderCalendar(calendar) {
var el = $("#pickup-calendar");
if (!calendar.length) { el.html('<div class="text-muted text-center">No data</div>'); return; }
var todayStr = frappe.datetime.nowdate();
var html = '<div style="text-align:center;">';
calendar.forEach(function(d) {
var cls = "cal-day";
if (d.count > 0) cls += " has-pickups";
if (d.date === todayStr) cls += " today";
var label = d.date.substring(5);
html += '<div class="' + cls + '" data-date="' + d.date + '" title="' + d.count + ' pickup(s)">' + label + '</div>';
});
html += '</div>';
el.html(html);
}
$(document).on("click", ".cal-day.has-pickups", function() {
$("#pickup-date-filter").val($(this).data("date"));
loadPickups();
});
// ── Stage A: New Pickup ──
$("#btn-new-pickup").on("click", function() {
$("#new-pickup-form").show();
$("#sp-pickup_date").val(frappe.datetime.nowdate());
setupCustomerLink();
});
$("#btn-cancel-pickup").on("click", function() {
$("#new-pickup-form").hide();
});
$("#pickup-form").on("submit", function(e) {
e.preventDefault();
var customerVal = customer_control ? customer_control.get_value() : "";
if (!customerVal) { frappe.msgprint("Select a customer"); return; }
var doc = {
doctype: "Scheduled Pickup",
pickup_date: $("#sp-pickup_date").val(),
pickup_type: $("#sp-pickup_type").val(),
customer_number: customerVal,
company_name: $("#sp-company_name").val(),
contact_name: $("#sp-contact_name").val(),
contact_phone: $("#sp-contact_phone").val(),
contact_email: $("#sp-contact_email").val(),
address_line: $("#sp-address_line").val(),
city: $("#sp-city").val(),
state: $("#sp-state").val(),
zip_code: $("#sp-zip_code").val(),
estimated_items: parseInt($("#sp-estimated_items").val()) || 0,
estimated_weight: $("#sp-estimated_weight").val(),
load_contents: $("#sp-load_contents").val(),
data_status: $("#sp-data_status").val(),
red_r2: $("#sp-red_r2").val(),
needs_aor: $("#sp-needs_aor").is(":checked") ? 1 : 0,
needs_cod: $("#sp-needs_cod").is(":checked") ? 1 : 0,
notes: $("#sp-notes").val(),
status: "Scheduled"
};
frappe.call({
method: "frappe.client.insert",
args: { doc: doc },
callback: function(r) {
if (r.message) {
frappe.show_alert({ message: "Pickup scheduled", indicator: "green" });
$("#new-pickup-form").hide();
loadPickups();
}
}
});
});
$("#pickup-date-filter").on("change", loadPickups);
$("#btn-clear-date").on("click", function() {
$("#pickup-date-filter").val("");
loadPickups();
});
// ── Stage B: Routing ──
function loadRoutes() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
$("#route-date").val(date);
frappe.call({
method: "westech_r2.api.receiving_api.get_pickups",
args: { date: date },
callback: function(r) {
if (r.message) renderRouteColumns(r.message.pickups || []);
}
});
}
function renderRouteColumns(pickups) {
var trucks = { "Truck 1": [], "Truck 2": [], "Truck 3": [], "Unassigned": [] };
pickups.forEach(function(p) {
var t = p.truck || "";
if (t && trucks[t]) trucks[t].push(p);
else trucks["Unassigned"].push(p);
});
["Truck 1", "Truck 2", "Truck 3"].forEach(function(t) {
var key = t.toLowerCase().replace(/ /g, "");
$("#" + key + "-count").text("(" + trucks[t].length + " stops)");
$("#" + key + "-stops").html(trucks[t].map(function(p, i) { return stopCard(p, i + 1); }).join(""));
});
$("#unassigned-count").text("(" + trucks["Unassigned"].length + ")");
$("#unassigned-stops").html(trucks["Unassigned"].map(function(p) { return stopCard(p, 0); }).join(""));
}
function stopCard(p, order) {
var h = '<div class="stop-card" data-pickup="' + esc(p.name) + '">';
if (order) h += '<div style="font-size:11px;color:#666;margin-bottom:2px">Stop #' + order + '</div>';
h += '<div class="stop-co">' + esc(p.company_name || p.customer_number || "Unknown") + '</div>';
h += '<div class="stop-addr">' + esc((p.address_line || "") + (p.city ? ", " + p.city : "")) + '</div>';
h += '<div class="stop-meta">';
if (p.estimated_items) h += '<span>' + p.estimated_items + ' items</span>';
if (p.data_status) h += '<span>' + esc(p.data_status) + '</span>';
if (p.red_r2) h += '<span>' + esc(p.red_r2) + '</span>';
if (p.needs_aor) h += '<span>AoR</span>';
if (p.needs_cod) h += '<span>CoD</span>';
h += '</div></div>';
return h;
}
$("#btn-load-routes").on("click", loadRoutes);
$("#btn-auto-route").on("click", function() {
var date = $("#route-date").val();
if (!date) { frappe.msgprint("Select a date first"); return; }
frappe.call({
method: "westech_r2.api.receiving_api.auto_route",
args: { date: date },
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({ message: "Routes optimized", indicator: "green" });
loadRoutes();
}
}
});
});
$("#btn-route-sheet").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_route_sheet?date=" + date, "_blank");
});
$("#btn-green-sheet").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_green_sheet?date=" + date, "_blank");
});
$("#btn-labels").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_labels?date=" + date, "_blank");
});
// ── Stage C: Check-in ──
var checkin_pickup_control = null;
var current_pickup_details = null;
function loadCheckins() {
frappe.call({
method: "westech_r2.api.receiving_api.get_checkins",
callback: function(r) {
if (r.message) renderCheckinTable(r.message.checkins || []);
}
});
}
function renderCheckinTable(checkins) {
var tbody = $("#checkin-tbody");
if (!checkins.length) {
tbody.html('<tr><td colspan="8" class="text-center text-muted">No check-ins yet</td></tr>');
return;
}
tbody.html(checkins.map(function(c) {
var palletInfo = (c.pallets || []).map(function(p) { return esc(p.pallet_number || p.name); }).join(", ");
return '<tr>' +
'<td>' + esc(c.incoming_date || "") + '</td>' +
'<td><strong>' + esc(c.customer_name || c.customer || "") + '</strong></td>' +
'<td>' + esc(c.name || "") + '</td>' +
'<td class="text-right">' + (c.pallet_count || 0) + '</td>' +
'<td class="text-right">' + (c.total_weight || "—") + '</td>' +
'<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(c.data_status || "") + '</td>' +
'<td>' + esc(c.red_r2 || "—") + '</td>' +
'<td style="font-size:11px;color:#666;">' + palletInfo + '</td></tr>';
}).join(""));
}
$("#btn-new-checkin").on("click", function() {
$("#checkin-form").show();
$("#ci-received_date").val(frappe.datetime.nowdate());
$("#ci-pickup-details").hide();
current_pickup_details = null;
checkin_pickup_control = frappe.ui.form.make_control({
parent: $("#ci-pickup-control"),
df: {
fieldtype: "Link",
fieldname: "pickup_ref",
options: "Scheduled Pickup",
label: "Scheduled Pickup",
reqd: 1,
placeholder: "Search pickup...",
get_query: function() {
return {
filters: [
["Scheduled Pickup", "status", "in", ["Scheduled", "Routed", "In Progress"]]
]
};
},
onchange: function() {
var val = checkin_pickup_control ? checkin_pickup_control.get_value() : "";
if (val) loadPickupDetails(val);
else {
$("#ci-pickup-details").hide();
current_pickup_details = null;
}
}
},
only_input: true,
});
checkin_pickup_control.refresh();
$("#ci-pickup-control .control-input").css("margin", "0");
$("#ci-pickup-control .help-box").remove();
});
function loadPickupDetails(pickup_name) {
frappe.call({
method: "westech_r2.api.receiving_api.get_pickup_details",
args: { pickup_name: pickup_name },
callback: function(r) {
if (!r.message) return;
current_pickup_details = r.message;
var d = r.message;
$("#ci-pickup-details").show();
$("#ci-customer-info").text((d.company_name || d.customer_number || "Unknown") + (d.red_r2 ? " — " + d.red_r2 : ""));
$("#ci-address-info").text((d.address_line || "") + (d.city ? ", " + d.city : "") + (d.state ? ", " + d.state : "") + (d.zip_code ? " " + d.zip_code : ""));
$("#ci-contact-info").text((d.contact_name || "") + (d.contact_phone ? " • " + d.contact_phone : "") + (d.contact_email ? " • " + d.contact_email : ""));
// Special handling for RED/NIST
if (d.red_r2 && d.red_r2 !== "Neither" && d.red_r2 !== "") {
$("#ci-special-handling").show().html("<strong>⚠ " + esc(d.red_r2) + "</strong> — Special handling required" + (d.needs_aor ? " • AoR ✓" : "") + (d.needs_cod ? " • CoD ✓" : ""));
} else {
$("#ci-special-handling").hide();
}
// Notes
if (d.notes) {
$("#ci-pickup-notes").show().html("<strong>Notes:</strong> " + esc(d.notes));
} else {
$("#ci-pickup-notes").hide();
}
// Pre-fill check-in fields from pickup
$("#ci-actual_pallets").val(d.estimated_items || 1);
$("#ci-data_status").val(d.data_status || "");
$("#ci-red_r2").val(d.red_r2 || "");
}
});
}
$("#btn-cancel-checkin").on("click", function() {
$("#checkin-form").hide();
});
$("#checkin-form-inner").on("submit", function(e) {
e.preventDefault();
var pickupName = checkin_pickup_control ? checkin_pickup_control.get_value() : "";
if (!pickupName) { frappe.msgprint("Select a pickup"); return; }
var actualPallets = parseInt($("#ci-actual_pallets").val()) || 0;
if (actualPallets < 1) { frappe.msgprint("Enter at least 1 pallet"); return; }
frappe.confirm(
"Check in " + actualPallets + " pallet(s) for this load?",
function() {
frappe.call({
method: "westech_r2.api.receiving_api.checkin_load",
args: {
pickup_name: pickupName,
received_date: $("#ci-received_date").val(),
actual_pallets: actualPallets,
total_weight: $("#ci-total_weight").val(),
load_contents: $("#ci-load_contents").val(),
data_status: $("#ci-data_status").val(),
red_r2: $("#ci-red_r2").val()
},
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({
message: "Load checked in! Created " + r.message.pallets_created + " pallet(s) in Load " + r.message.load,
indicator: "green"
});
$("#checkin-form").hide();
loadCheckins();
loadPickups();
}
}
});
}
);
});
$("#btn-cor-report").on("click", function() {
window.open("/api/method/westech_r2.api.receiving_api.cor_report", "_blank");
});
// ── Helpers ──
function esc(s) { return s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") : ""; }
function dayName(d) { return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]; }
// ── Init ──
loadPickups();
loadCheckins();
};
@@ -0,0 +1,566 @@
frappe.pages['receiving'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'Receiving',
single_column: true
});
// Inline HTML — same pattern as intake.js
$(wrapper).find('.layout-main-section').html(`
<div class="receiving-station" style="padding: 20px;">
<div class="row">
<div class="col-md-12">
<h3 style="margin-top: 0; color: #2F5496;">🚛 Receiving</h3>
<p class="text-muted">Schedule pickups, manage routes, and check in loads.</p>
</div>
</div>
<ul class="nav nav-tabs" role="tablist" id="receiving-tabs">
<li role="presentation" class="active"><a href="#stage-a" role="tab" data-toggle="tab">📋 Stage A — Schedule Pickup</a></li>
<li role="presentation"><a href="#stage-b" role="tab" data-toggle="tab">🗺️ Stage B — Route & Dispatch</a></li>
<li role="presentation"><a href="#stage-c" role="tab" data-toggle="tab">⚖️ Stage C — Load Check-in</a></li>
</ul>
<div class="tab-content" style="padding-top: 20px;">
<div role="tabpanel" class="tab-pane active" id="stage-a">
<div class="row">
<div class="col-md-3">
<div class="panel panel-primary">
<div class="panel-heading">📅 Pickup Calendar — Next 30 Days</div>
<div class="panel-body" id="pickup-calendar"><div class="text-muted text-center">Loading...</div></div>
</div>
</div>
<div class="col-md-9">
<div class="panel panel-default">
<div class="panel-heading">
<div class="row">
<div class="col-md-6"><strong>Scheduled Pickups</strong><span id="pickup-count-label" class="text-muted" style="margin-left: 8px;"></span></div>
<div class="col-md-6 text-right">
<button class="btn btn-primary btn-sm" id="btn-new-pickup">+ New Pickup</button>
<input type="date" id="pickup-date-filter" class="form-control input-sm" style="display:inline-block;width:auto;vertical-align:middle;margin-left:8px;">
<button class="btn btn-default btn-sm" id="btn-clear-date" style="margin-left:4px;">Clear</button>
</div>
</div>
</div>
<div class="panel-body" style="padding: 0; overflow-x: auto;">
<table class="table table-striped table-hover" id="pickup-table" style="font-size: 13px; margin-bottom: 0;">
<thead><tr><th>Date</th><th>Weekday</th><th>Type</th><th>Customer</th><th>Contact</th><th>Address</th><th>Est. Items</th><th>Data</th><th>RED/R2</th><th>Status</th><th>Notes</th><th>Truck</th><th>AoR</th><th>CoD</th></tr></thead>
<tbody id="pickup-tbody"><tr><td colspan="14" class="text-center text-muted">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="new-pickup-form" style="display:none; margin-top: 16px;">
<div class="panel panel-primary">
<div class="panel-heading">+ New Scheduled Pickup</div>
<div class="panel-body">
<form id="pickup-form">
<div class="row">
<div class="col-md-4">
<h5 style="color:#6f42c1;">📅 Pickup Info</h5>
<div class="form-group"><label>Pickup Date <span class="text-danger">*</span></label><input type="date" id="sp-pickup_date" class="form-control" required></div>
<div class="form-group"><label>Type <span class="text-danger">*</span></label><select id="sp-pickup_type" class="form-control" required><option value="Pickup">Pickup</option><option value="Drop-off">Drop-off</option></select></div>
<div class="form-group"><label>Customer <span class="text-danger">*</span></label><div id="sp-customer-control"></div></div>
<div class="form-group"><label>Company Name</label><input type="text" id="sp-company_name" class="form-control" readonly style="background:#f8f9fa;"></div>
<div class="form-group"><label>Contact Name</label><input type="text" id="sp-contact_name" class="form-control"></div>
<div class="form-group"><label>Contact Phone</label><input type="text" id="sp-contact_phone" class="form-control"></div>
<div class="form-group"><label>Contact Email</label><input type="email" id="sp-contact_email" class="form-control"></div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">📍 Address</h5>
<div class="form-group"><label>Street Address</label><input type="text" id="sp-address_line" class="form-control"></div>
<div class="form-group"><label>City</label><input type="text" id="sp-city" class="form-control"></div>
<div class="form-group"><label>State</label><input type="text" id="sp-state" class="form-control" value="AZ"></div>
<div class="form-group"><label>ZIP</label><input type="text" id="sp-zip_code" class="form-control"></div>
<div class="form-group"><label>Hours of Operation</label><input type="text" id="sp-hours_of_operation" class="form-control" placeholder="e.g. Mon-Fri 8am-5pm"></div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">📦 Load Info</h5>
<div class="form-group"><label>Estimated Items</label><input type="number" id="sp-estimated_items" class="form-control"></div>
<div class="form-group"><label>Estimated Weight</label><input type="text" id="sp-estimated_weight" class="form-control"></div>
<div class="form-group"><label>Load Contents</label><input type="text" id="sp-load_contents" class="form-control" placeholder="Wire, Monitors, Laptops..."></div>
<div class="form-group"><label>Data Status</label><select id="sp-data_status" class="form-control"><option value="">—</option><option value="D0">D0</option><option value="D1">D1</option><option value="ND1">ND1</option><option value="ND2">ND2</option><option value="ND3">ND3</option><option value="ND4">ND4</option></select></div>
<div class="form-group"><label>RED / R2</label><select id="sp-red_r2" class="form-control"><option value="">—</option><option value="RED">RED</option><option value="R2">R2</option><option value="Both">Both</option><option value="Neither">Neither</option></select></div>
<div class="form-group"><div class="checkbox"><label><input type="checkbox" id="sp-needs_aor"> <strong>Needs AoR</strong></label></div><div class="checkbox"><label><input type="checkbox" id="sp-needs_cod"> <strong>Needs CoD</strong></label></div></div>
<div class="form-group"><label>Notes</label><textarea id="sp-notes" class="form-control" rows="2"></textarea></div>
<div class="form-group"><label>Legacy Notes</label><textarea id="sp-legacy_notes" class="form-control" rows="2" style="background:#fafafa;" readonly></textarea></div>
</div>
</div>
<div class="row" style="margin-top: 16px;"><div class="col-md-12"><button type="submit" class="btn btn-primary btn-lg">Save Pickup</button><button type="button" class="btn btn-default btn-lg" id="btn-cancel-pickup">Cancel</button></div></div>
</form>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="stage-b">
<div class="row" style="margin-bottom: 16px;"><div class="col-md-12"><div class="btn-group"><input type="date" id="route-date" class="form-control" style="display:inline-block;width:auto;"><button class="btn btn-primary" id="btn-load-routes">Load Pickups</button><button class="btn btn-primary" id="btn-auto-route">🧮 Auto-Route</button><button class="btn btn-success" id="btn-route-sheet">🖨️ Route Sheet</button><button class="btn btn-success" id="btn-green-sheet">📄 Green Sheet</button><button class="btn btn-success" id="btn-labels">🏷️ Labels</button></div></div></div>
<div class="row" id="route-columns">
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 1"><div class="panel-heading">🚛 Truck 1 <span id="truck1-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck1-stops"></div></div></div>
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 2"><div class="panel-heading">🚛 Truck 2 <span id="truck2-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck2-stops"></div></div></div>
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 3"><div class="panel-heading">🚛 Truck 3 <span id="truck3-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck3-stops"></div></div></div>
</div>
<div class="row" style="margin-top: 16px;"><div class="col-md-12"><div class="panel panel-default truck-column" data-truck=""><div class="panel-heading">📋 Unassigned <span id="unassigned-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="unassigned-stops"></div></div></div></div>
</div>
<div role="tabpanel" class="tab-pane" id="stage-c">
<div class="row" style="margin-bottom: 16px;"><div class="col-md-12"><button class="btn btn-primary" id="btn-new-checkin">+ Check In Load</button><button class="btn btn-success" id="btn-cor-report">📋 CoR Report</button></div></div>
<div class="panel panel-default">
<div class="panel-heading">Recent Check-ins</div>
<div class="panel-body" style="padding: 0; overflow-x: auto;">
<table class="table table-striped table-hover" id="checkin-table" style="font-size: 13px; margin-bottom: 0;">
<thead><tr><th>Date</th><th>Customer</th><th>Type</th><th class="text-right">Actual Pallets</th><th class="text-right">Actual Weight</th><th>Load Contents</th><th>Data Status</th><th>RED/R2</th><th>Status</th></tr></thead>
<tbody id="checkin-tbody"><tr><td colspan="9" class="text-center text-muted">Loading...</td></tr></tbody>
</table>
</div>
</div>
<div id="checkin-form" style="display:none; margin-top: 16px;">
<div class="panel panel-primary">
<div class="panel-heading">+ Load Check-in</div>
<div class="panel-body">
<form id="checkin-form-inner">
<div class="row">
<div class="col-md-4"><div class="form-group"><label>Scheduled Pickup <span class="text-danger">*</span></label><div id="ci-pickup-control"></div></div></div>
<div class="col-md-4"><div class="form-group"><label>Received Date <span class="text-danger">*</span></label><input type="date" id="ci-received_date" class="form-control" required></div></div>
<div class="col-md-4"><div class="form-group"><label>Actual # of Pallets/Gaylords</label><input type="number" id="ci-actual_pallets" class="form-control"></div></div>
</div>
<div class="row">
<div class="col-md-4"><div class="form-group"><label>Actual Weight (lbs)</label><input type="text" id="ci-actual_weight" class="form-control"></div></div>
<div class="col-md-8"><div class="form-group"><label>Load Contents</label><textarea id="ci-load_contents" class="form-control" rows="2" placeholder="Wire, Monitors, Laptops, MRI machine..."></textarea></div></div>
</div>
<div class="row" style="margin-top: 8px;"><div class="col-md-12"><button type="submit" class="btn btn-primary btn-lg">Check In</button><button type="button" class="btn btn-default btn-lg" id="btn-cancel-checkin">Cancel</button></div></div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.truck-stops { min-height: 60px; }
.stop-card { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 10px 12px; margin: 6px 0; cursor: grab; font-size: 13px; }
.stop-card:hover { border-color: #2F5496; }
.stop-card .stop-co { font-weight: 700; color: #2F5496; }
.stop-card .stop-addr { color: #666; font-size: 12px; margin-top: 2px; }
.stop-card .stop-meta { display: flex; gap: 8px; margin-top: 4px; font-size: 11px; }
.stop-card .stop-meta span { background: #D6E4F0; color: #2F5496; padding: 1px 6px; border-radius: 3px; }
.stop-card.dragging { opacity: 0.5; }
.truck-column { min-height: 200px; }
#pickup-calendar { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; font-size: 12px; }
.cal-day { text-align: center; padding: 6px 4px; border-radius: 6px; }
.cal-day.has-pickups { background: #D6E4F0; cursor: pointer; }
.cal-day.today { background: #2F5496; color: #fff; }
.cal-day .day-num { font-weight: 600; }
.cal-day .day-count { font-size: 10px; font-weight: 700; }
</style>
`);
// ── Stage Tabs ──
$("#receiving-tabs a").on("click", function(e) {
e.preventDefault();
$(this).tab("show");
var stage = $(this).attr("href").replace("#stage-", "");
if (stage === "a") loadPickups();
if (stage === "b") loadRoutes();
if (stage === "c") loadCheckins();
});
// ── Stage A: Link Controls ──
var customer_control = null;
function setupCustomerLink() {
customer_control = frappe.ui.form.make_control({
parent: $("#sp-customer-control"),
df: {
fieldtype: "Link",
fieldname: "customer_number",
options: "Customer",
label: "Customer",
reqd: 1,
placeholder: "Search customer...",
onchange: function() {
var val = customer_control.get_value();
if (val) fetchCustomerDetails(val);
else clearCustomerFields();
}
},
only_input: true,
});
customer_control.refresh();
$("#sp-customer-control .control-input").css("margin", "0");
$("#sp-customer-control .help-box").remove();
}
function fetchCustomerDetails(customer_name) {
frappe.call({
method: "frappe.client.get",
args: { doctype: "Customer", name: customer_name },
callback: function(r) {
if (!r.message) return;
var c = r.message;
$("#sp-company_name").val(c.customer_name || "");
$("#sp-contact_name").val(c.contact_name || "");
$("#sp-contact_phone").val(c.contact_phone || "");
$("#sp-contact_email").val(c.contact_email || "");
$("#sp-legacy_notes").val(c.legacy_notes || "");
$("#sp-hours_of_operation").val(c.hours_of_operation || "");
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Address",
filters: [["Dynamic Link", "link_name", "=", customer_name]],
fields: ["address_line1", "city", "state", "pincode"],
limit_page_length: 1
},
callback: function(ra) {
if (ra.message && ra.message.length) {
var a = ra.message[0];
$("#sp-address_line").val(a.address_line1 || "");
$("#sp-city").val(a.city || "");
$("#sp-state").val(a.state || "AZ");
$("#sp-zip_code").val(a.pincode || "");
}
}
});
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Contact",
filters: [["Dynamic Link", "link_name", "=", customer_name]],
fields: ["first_name", "last_name", "email_id", "phone", "mobile_no"],
limit_page_length: 1
},
callback: function(rc) {
if (rc.message && rc.message.length) {
var ct = rc.message[0];
if (!$("#sp-contact_name").val()) {
$("#sp-contact_name").val((ct.first_name || "") + " " + (ct.last_name || ""));
}
if (!$("#sp-contact_phone").val()) {
$("#sp-contact_phone").val(ct.phone || ct.mobile_no || "");
}
if (!$("#sp-contact_email").val()) {
$("#sp-contact_email").val(ct.email_id || "");
}
}
}
});
}
});
}
function clearCustomerFields() {
$("#sp-company_name, #sp-contact_name, #sp-contact_phone, #sp-contact_email, #sp-address_line, #sp-city, #sp-state, #sp-zip_code, #sp-legacy_notes, #sp-hours_of_operation").val("");
$("#sp-state").val("AZ");
}
// ── Stage A: Load Pickups ──
function loadPickups() {
var dateFilter = $("#pickup-date-filter").val();
frappe.call({
method: "westech_r2.api.receiving_api.get_pickups",
args: { date: dateFilter },
callback: function(r) {
if (r.message) {
renderPickupTable(r.message.pickups || []);
renderCalendar(r.message.calendar || []);
// weekly chart removed
$("#pickup-count-label").text((r.message.pickups || []).length + " pickups");
}
}
});
}
function renderPickupTable(pickups) {
var tbody = $("#pickup-tbody");
if (!pickups.length) {
tbody.html('<tr><td colspan="14" class="text-center text-muted">No scheduled pickups</td></tr>');
return;
}
var statusColors = { "Scheduled": "#2196F3", "Routed": "#009688", "In Progress": "#FF9800", "Complete": "#4CAF50", "Cancelled": "#F44336" };
var h = "";
pickups.forEach(function(p) {
var st = p.status || "Scheduled";
var sc = statusColors[st] || "#999";
var weekday = p.pickup_date ? dayName(new Date(p.pickup_date + "T12:00:00")) : "";
var typeBadge = p.pickup_type === "Drop-off"
? '<span class="badge" style="background:#E3F2FD;color:#1565C0">Drop-off</span>'
: '<span class="badge" style="background:#FFF3E0;color:#E65100">Pickup</span>';
h += '<tr style="cursor:pointer" onclick=\"window.open(\'/app/scheduled-pickup/\' + encodeURIComponent(p.name) + \'\', \'_blank\')\">';
h += '<td>' + esc(p.pickup_date || "") + '</td>';
h += '<td class="text-muted">' + weekday + '</td>';
h += '<td>' + typeBadge + '</td>';
h += '<td><strong>' + esc(p.company_name || p.customer_number || "") + '</strong></td>';
h += '<td style="font-size:12px">' + esc((p.contact_name || "") + (p.contact_phone ? " • " + p.contact_phone : "")) + '</td>';
h += '<td style="font-size:12px">' + esc((p.address_line || "") + (p.city ? ", " + p.city : "")) + '</td>';
h += '<td class="text-right">' + (p.estimated_items || "—") + '</td>';
h += '<td>' + esc(p.data_status || "—") + '</td>';
h += '<td>' + esc(p.red_r2 || "—") + '</td>';
h += '<td><span class="badge" style="background:' + sc + '22;color:' + sc + '">' + esc(st) + '</span></td>';
h += '<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(p.notes || "") + '</td>';
h += '<td>' + esc(p.truck || "—") + '</td>';
h += '<td class="text-center">' + (p.needs_aor ? "✓" : "") + '</td>';
h += '<td class="text-center">' + (p.needs_cod ? "✓" : "") + '</td>';
h += '</tr>';
});
tbody.html(h);
}
function renderCalendar(days) {
var el = $("#pickup-calendar");
if (!days || !days.length) { el.html('<div class="text-muted text-center">No upcoming pickups</div>'); return; }
var today = frappe.datetime.nowdate();
var h = '<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;font-size:12px">';
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].forEach(function(d) {
h += '<div style="text-align:center;font-weight:600;color:#666;padding:4px">' + d + '</div>';
});
var first = new Date(days[0].date + "T12:00:00");
for (var i = 0; i < first.getDay(); i++) h += '<div></div>';
days.forEach(function(d) {
var isToday = d.date === today;
var hasCount = d.count > 0;
var bg = isToday ? "cal-day today" : (hasCount ? "cal-day has-pickups" : "cal-day");
var onclick = hasCount ? "onclick=$('#pickup-date-filter').val('" + d.date + "');loadPickups();" : "";
h += '<div class="' + bg + '" ' + onclick + '>';
h += '<div style="font-weight:' + (isToday ? "700" : "400") + '">' + d.date.split("-")[2] + '</div>';
if (hasCount) h += '<div class="day-count">' + d.count + '</div>';
h += '</div>';
});
h += '</div>';
el.html(h);
}
// ── Stage A: New Pickup ──
$("#btn-new-pickup").on("click", function() {
$("#new-pickup-form").show();
$("#sp-pickup_date").val(frappe.datetime.nowdate());
setupCustomerLink();
});
$("#btn-cancel-pickup").on("click", function() {
$("#new-pickup-form").hide();
if (customer_control) customer_control.set_value("");
});
$("#pickup-form").on("submit", function(e) {
e.preventDefault();
var doc = {
doctype: "Scheduled Pickup",
pickup_date: $("#sp-pickup_date").val(),
pickup_type: $("#sp-pickup_type").val(),
customer_number: customer_control ? customer_control.get_value() : "",
company_name: $("#sp-company_name").val(),
contact_name: $("#sp-contact_name").val(),
contact_phone: $("#sp-contact_phone").val(),
contact_email: $("#sp-contact_email").val(),
address_line: $("#sp-address_line").val(),
city: $("#sp-city").val(),
state: $("#sp-state").val(),
zip_code: $("#sp-zip_code").val(),
estimated_items: parseInt($("#sp-estimated_items").val()) || 0,
estimated_weight: $("#sp-estimated_weight").val(),
load_contents: $("#sp-load_contents").val(),
data_status: $("#sp-data_status").val(),
red_r2: $("#sp-red_r2").val(),
needs_aor: $("#sp-needs_aor").is(":checked") ? 1 : 0,
needs_cod: $("#sp-needs_cod").is(":checked") ? 1 : 0,
notes: $("#sp-notes").val(),
legacy_notes: $("#sp-legacy_notes").val(),
status: "Scheduled"
};
frappe.call({
method: "frappe.client.insert",
args: { doc: doc },
callback: function(r) {
if (r.message) {
frappe.show_alert({ message: "Pickup scheduled", indicator: "green" });
$("#new-pickup-form").hide();
loadPickups();
}
}
});
});
$("#pickup-date-filter").on("change", loadPickups);
$("#btn-clear-date").on("click", function() {
$("#pickup-date-filter").val("");
loadPickups();
});
// ── Stage B: Routing ──
function loadRoutes() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
$("#route-date").val(date);
frappe.call({
method: "westech_r2.api.receiving_api.get_pickups",
args: { date: date },
callback: function(r) {
if (r.message) renderRouteColumns(r.message.pickups || []);
}
});
}
function renderRouteColumns(pickups) {
var trucks = { "Truck 1": [], "Truck 2": [], "Truck 3": [], "Unassigned": [] };
pickups.forEach(function(p) {
var t = p.truck || "";
if (t && trucks[t]) trucks[t].push(p);
else trucks["Unassigned"].push(p);
});
["Truck 1", "Truck 2", "Truck 3"].forEach(function(t) {
var key = t.toLowerCase().replace(/ /g, "");
$("#" + key + "-count").text("(" + trucks[t].length + " stops)");
$("#" + key + "-stops").html(trucks[t].map(function(p, i) { return stopCard(p, i + 1); }).join(""));
});
$("#unassigned-count").text("(" + trucks["Unassigned"].length + " stops)");
$("#unassigned-stops").html(trucks["Unassigned"].map(function(p) { return stopCard(p, 0); }).join(""));
}
function stopCard(p, order) {
var h = '<div class="stop-card" data-pickup="' + esc(p.name) + '">';
if (order) h += '<div style="font-size:11px;color:#666;margin-bottom:2px">Stop #' + order + '</div>';
h += '<div class="stop-co">' + esc(p.company_name || p.customer_number || "Unknown") + '</div>';
h += '<div class="stop-addr">' + esc((p.address_line || "") + (p.city ? ", " + p.city : "")) + '</div>';
h += '<div class="stop-meta">';
if (p.estimated_items) h += '<span>' + p.estimated_items + ' items</span>';
if (p.data_status) h += '<span>' + esc(p.data_status) + '</span>';
if (p.red_r2) h += '<span>' + esc(p.red_r2) + '</span>';
if (p.needs_aor) h += '<span>AoR</span>';
if (p.needs_cod) h += '<span>CoD</span>';
h += '</div></div>';
return h;
}
$("#btn-load-routes").on("click", loadRoutes);
$("#btn-auto-route").on("click", function() {
var date = $("#route-date").val();
if (!date) { frappe.msgprint("Select a date first"); return; }
frappe.call({
method: "westech_r2.api.receiving_api.auto_route",
args: { date: date },
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({ message: "Routes optimized", indicator: "green" });
loadRoutes();
}
}
});
});
$("#btn-route-sheet").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_route_sheet?date=" + date, "_blank");
});
$("#btn-green-sheet").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_green_sheet?date=" + date, "_blank");
});
$("#btn-labels").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_labels?date=" + date, "_blank");
});
// ── Stage C: Check-in ──
var checkin_pickup_control = null;
function loadCheckins() {
frappe.call({
method: "westech_r2.api.receiving_api.get_checkins",
callback: function(r) {
if (r.message) renderCheckinTable(r.message.checkins || []);
}
});
}
function renderCheckinTable(checkins) {
var tbody = $("#checkin-tbody");
if (!checkins.length) {
tbody.html('<tr><td colspan="9" class="text-center text-muted">No check-ins yet</td></tr>');
return;
}
tbody.html(checkins.map(function(c) {
return '<tr><td>' + esc(c.pickup_date || "") + '</td>' +
'<td><strong>' + esc(c.company_name || "") + '</strong></td>' +
'<td>' + esc(c.pickup_type || "") + '</td>' +
'<td class="text-right">' + (c.estimated_items || "—") + '</td>' +
'<td class="text-right">' + (c.estimated_weight || "—") + '</td>' +
'<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(c.load_contents || "") + '</td>' +
'<td>' + esc(c.data_status || "—") + '</td>' +
'<td>' + esc(c.red_r2 || "—") + '</td>' +
'<td>' + esc(c.status || "") + '</td></tr>';
}).join(""));
}
$("#btn-new-checkin").on("click", function() {
$("#checkin-form").show();
$("#ci-received_date").val(frappe.datetime.nowdate());
checkin_pickup_control = frappe.ui.form.make_control({
parent: $("#ci-pickup-control"),
df: {
fieldtype: "Link",
fieldname: "pickup_ref",
options: "Scheduled Pickup",
label: "Scheduled Pickup",
reqd: 1,
placeholder: "Search pickup...",
get_query: function() {
return {
filters: [
["Scheduled Pickup", "status", "in", ["Scheduled", "Routed", "In Progress"]]
]
};
}
},
only_input: true,
});
checkin_pickup_control.refresh();
$("#ci-pickup-control .control-input").css("margin", "0");
$("#ci-pickup-control .help-box").remove();
});
$("#btn-cancel-checkin").on("click", function() {
$("#checkin-form").hide();
});
$("#checkin-form-inner").on("submit", function(e) {
e.preventDefault();
var pickupName = checkin_pickup_control ? checkin_pickup_control.get_value() : "";
if (!pickupName) { frappe.msgprint("Select a pickup"); return; }
var update = {};
update.status = "Complete";
if ($("#ci-actual_pallets").val()) update.estimated_items = parseInt($("#ci-actual_pallets").val());
if ($("#ci-actual_weight").val()) update.estimated_weight = $("#ci-actual_weight").val();
if ($("#ci-load_contents").val()) update.load_contents = $("#ci-load_contents").val();
frappe.call({
method: "frappe.client.set_value",
args: {
doctype: "Scheduled Pickup",
name: pickupName,
fieldname: update
},
callback: function(r) {
if (r.message) {
frappe.show_alert({ message: "Load checked in", indicator: "green" });
$("#checkin-form").hide();
loadCheckins();
}
}
});
});
$("#btn-cor-report").on("click", function() {
window.open("/api/method/westech_r2.api.receiving_api.cor_report", "_blank");
});
// ── Helpers ──
function esc(s) { return s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") : ""; }
function dayName(d) { return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]; }
// ── Init ──
loadPickups();
};

Some files were not shown because too many files have changed in this diff Show More