wip: receiving dashboard, customer records, archive old 2-level paths, various fixes
This commit is contained in:
@@ -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> '
|
||||
'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
|
||||
@@ -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> </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> </td><td> </td><td> </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
|
||||
@@ -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)}
|
||||
@@ -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"><</button>
|
||||
<button class="nav-btn" id="btn-next">></button>
|
||||
<button class="nav-btn" id="btn-first"><<</button>
|
||||
<button class="nav-btn" id="btn-last">>></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, "<").replace(/>/g, ">");
|
||||
|
||||
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>« 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 »</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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """) : ""; }
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """) : ""; }
|
||||
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
Reference in New Issue
Block a user