Compare commits

...

17 Commits

Author SHA1 Message Date
vagrant 739415cf9d feat: Special Handling import - SH Logs, CODs, Load Emails, deduped customers 2026-05-28 04:53:25 +00:00
vagrant d087fc0f22 feat: add Special Handling schema - SH Log, COD, Load Email DocTypes + Load/Pallet fields 2026-05-28 04:22:16 +00:00
vagrant 6fe6d61779 wip: receiving dashboard, customer records, archive old 2-level paths, various fixes 2026-05-28 03:30:45 +00:00
vagrant d4ed4b1d89 fix: intake page updates, customer_intake fixes, module dir 2026-05-25 14:44:40 +00:00
Westech Admin 0997de940e feat: Customer Interaction DocType for CRM contact logs from supplemental.xlsx 2026-05-23 07:38:41 +00:00
Westech Admin 313da27e56 fix: CoR server-side PDF, driver→Employee, customer list, address fix, button enable 2026-05-22 06:02:51 +00:00
Westech Admin be06bca0cf feat: intake page rework, new Pallet fields, address fix, button rename, COR generator, theme CSS 2026-05-22 05:15:09 +00:00
Westech Admin 3c11969c89 fix(module): use symlink for westech_r2/westech_r2 to resolve Frappe module path
Frappe get_module_path uses get_pymodule_path which constructs
westech_r2.westech_r2 for module resolution. The symlink makes
both "westech_r2" and "westech_r2.westech_r2" resolve to the same
directory, fixing the 3-level deep path bug.
2026-05-21 06:00:37 +00:00
Westech Admin a1704dc1bb fix(module): add nested westech_r2/__init__.py for Frappe module resolution
Frappe get_module_path uses get_pymodule_path which expects
westech_r2.westech_r2 to be importable. The nested __init__.py
with __path__ redirection makes this work correctly.
2026-05-21 05:59:26 +00:00
Westech Admin 375bf9f2b5 chore: clean remaining pycache deletions 2026-05-21 05:50:53 +00:00
Westech Admin 2fe1d76071 chore: remove __pycache__ and add to .gitignore 2026-05-21 05:50:08 +00:00
Westech Admin 8ade73a877 fix(intake): set standard=No so custom app page loads, not core frappe page
The intake page in westech_r2 app now properly overrides the core page.
Customer search, CRM auto-fill, and all business logic lives in the
westech_r2 app where it belongs. Core frappe/core/page/intake is untouched.
2026-05-21 05:50:01 +00:00
Westech Admin ee57b903d6 feat(receiving): add Receiving page with 3-stage workflow 2026-05-21 05:40:30 +00:00
Westech Admin 8f8cf5d78d Add get_scheduled_pickups to optimize_routes API 2026-05-21 01:47:24 +00:00
Westech Admin 7d8d1b1376 Fix page module paths: move all pages to correct app-level directory, add missing .py files 2026-05-21 01:45:30 +00:00
Westech Admin f48e93f96a Add missing page files: eim-portal, r2-tracking, wes-ai to westech_r2 app 2026-05-21 01:19:30 +00:00
Westech Admin 8be0cf57cf Add intake page to westech_r2 app 2026-05-21 01:17:59 +00:00
317 changed files with 996050 additions and 6222 deletions
+2
View File
@@ -1 +1,3 @@
*.pyc\n__pycache__/\n*.egg-info/ *.pyc\n__pycache__/\n*.egg-info/
__pycache__/
*.pyc
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Import print format HTML templates into ERPNext as Print Format records."""
import frappe
import os
TEMPLATE_DIR = "/home/frappe/erpnext-bench/apps/westech_r2/westech_r2/templates/print_format"
PRINT_FORMATS = [
{
"name": "Green Sheet",
"doc_type": "Pallet",
"file": "green-sheet.html",
"description": "R2 Data-Bearing Equipment Intake Sheet",
},
{
"name": "Purple Sheet",
"doc_type": "Load",
"file": "purple-sheet.html",
"description": "Load Tracking Worksheet",
},
{
"name": "Pallet Label 4x6",
"doc_type": "Pallet",
"file": "pallet-label-4x6.html",
"description": "4x6 Pallet Shipping Label",
},
{
"name": "R2 Special Handling Log",
"doc_type": "Pallet",
"file": "r2-special-handling-log.html",
"description": "R2 Special Handling Chain of Custody Log",
},
{
"name": "R2 UW Label",
"doc_type": "Pallet",
"file": "r2-uw-label.html",
"description": "R2 Unwanted/Unknown Material Label",
},
{
"name": "Special Handling Log",
"doc_type": "Load",
"file": "special-handling-log.html",
"description": "Special Handling Chain of Custody Log",
},
]
def import_print_formats():
frappe.init(site="erpnext.local")
frappe.connect()
for pf in PRINT_FORMATS:
file_path = os.path.join(TEMPLATE_DIR, pf["file"])
if not os.path.exists(file_path):
print(f"⚠️ File not found: {file_path}")
continue
with open(file_path, "r") as f:
html_content = f.read()
# Check if Print Format already exists
existing = frappe.get_doc("Print Format", pf["name"]) if frappe.db.exists("Print Format", pf["name"]) else None
if existing:
# Update existing
existing.html = html_content
existing.description = pf["description"]
existing.save()
print(f"✓ Updated: {pf['name']}")
else:
# Create new
new_pf = frappe.get_doc({
"doctype": "Print Format",
"name": pf["name"],
"print_format_name": pf["name"],
"doc_type": pf["doc_type"],
"html": html_content,
"description": pf["description"],
"standard": "No",
"custom_format": 1,
})
new_pf.insert()
print(f"✓ Created: {pf['name']}")
frappe.db.commit()
print("\n✓ All print formats imported successfully")
frappe.destroy()
if __name__ == "__main__":
import_print_formats()
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""Test render pallet label with real data."""
import sys
sys.path.insert(0, '/home/frappe/erpnext-bench/apps/frappe')
import frappe
frappe.init(site='erpnext.local', sites_path='/home/frappe/erpnext-bench/sites')
frappe.connect()
frappe.set_user('Administrator')
# Get a real pallet
pallet = frappe.get_doc("Pallet", {"pallet_number": ("is", "set")}, limit=1)
if not pallet:
print("No pallets found")
frappe.destroy()
exit(1)
print(f"Testing with Pallet: {pallet.pallet_number} (name: {pallet.name})")
# Get the print format
pf = frappe.get_doc("Print Format", "Pallet Label 4x6")
print(f"Print Format found: {pf.name}")
print(f" DocType: {pf.doc_type}")
print(f" Custom: {pf.custom_format}")
print(f" HTML length: {len(pf.html) if pf.html else 0} chars")
# Try rendering via get_print
from frappe import get_print
html = get_print("Pallet", pallet.name, print_format="Pallet Label 4x6")
# Write to file for inspection
with open("/tmp/pallet_label_test.html", "w") as f:
f.write(html)
print(f"✓ Rendered to /tmp/pallet_label_test.html")
print(f" Output length: {len(html)} chars")
frappe.destroy()
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Verify print formats were imported."""
import frappe
frappe.init(site="erpnext.local")
frappe.connect()
pfs = frappe.get_all("Print Format",
filters={"doc_type": ["in", ["Pallet", "Load"]]},
fields=["name", "doc_type", "custom_format"]
)
print("Print Formats for Pallet/Load:")
for pf in pfs:
print(f" - {pf['name']} ({pf['doc_type']}, custom={pf['custom_format']})")
frappe.destroy()
Binary file not shown.
@@ -0,0 +1,3 @@
from westech_r2.api import sales
from westech_r2.api import receiving_api
@@ -0,0 +1,166 @@
import frappe
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import inch
from reportlab.lib.colors import HexColor, black, white, grey
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
from reportlab.lib import colors
import io
import os
DARK_BLUE = HexColor('#2F5496')
LIGHT_BLUE = HexColor('#D6E4F0')
GRAY = HexColor('#666666')
@frappe.whitelist()
def generate_cor(company_name=None, weights=None, received_date=None, red_r2=None, contact_name=None, contact_number=None, address_line=None, pallet_name=None):
"""Generate Certificate of Recycling PDF from form data."""
# Format date
date_str = ''
if received_date:
from frappe.utils import formatdate
date_str = formatdate(received_date, 'MMMM d, Y')
items_recycled = 'e-waste'
if red_r2:
items_recycled += ' (' + red_r2 + ')'
output = io.BytesIO()
doc = SimpleDocTemplate(
output,
pagesize=letter,
topMargin=0.5 * inch,
bottomMargin=0.5 * inch,
leftMargin=0.75 * inch,
rightMargin=0.75 * inch
)
styles = getSampleStyleSheet()
# Custom styles matching the Electron app
date_style = ParagraphStyle('DateBlock', parent=styles['Normal'], fontSize=14, fontName='Times-Bold', alignment=TA_LEFT)
title_style = ParagraphStyle('CertTitle', parent=styles['Title'], fontSize=16, fontName='Times-Bold', textColor=black, spaceAfter=6, alignment=TA_CENTER, letterSpacing=0.05)
cert_style = ParagraphStyle('CertBody', parent=styles['Normal'], fontName='Times-Roman', fontSize=12, spaceAfter=12, alignment=TA_JUSTIFY)
body_style = ParagraphStyle('BodyText2', parent=styles['Normal'], fontName='Times-Roman', fontSize=12, spaceAfter=10, alignment=TA_JUSTIFY)
bullet_style = ParagraphStyle('BulletText', parent=styles['Normal'], fontName='Times-Roman', fontSize=10, spaceAfter=4, leftIndent=24, bulletIndent=12, alignment=TA_JUSTIFY)
optin_style = ParagraphStyle('OptIn', parent=styles['Normal'], fontName='Times-Roman', fontSize=12, spaceAfter=10, alignment=TA_JUSTIFY)
sig_style = ParagraphStyle('Signature', parent=styles['Normal'], fontName='Times-Bold', fontSize=18, spaceBefore=18)
footer_style = ParagraphStyle('Footer', parent=styles['Normal'], fontName='Times-Roman', fontSize=10, textColor=GRAY)
elements = []
# Header row: Date | Logo | Title
logo_path = os.path.join(frappe.get_app_path('westech_r2'), 'public', 'images', 'cor_logo.png')
logo_img = None
if os.path.exists(logo_path):
logo_img = Image(logo_path, width=2.45 * inch, height=0.8 * inch)
header_data = [
[Paragraph(date_str, date_style), logo_img or Paragraph('', styles['Normal']), Paragraph('CERTIFICATE OF RECYCLING', title_style)]
]
header_table = Table(header_data, colWidths=[1.8 * inch, 2.45 * inch, 2.75 * inch])
header_table.setStyle(TableStyle([
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, 0), 'LEFT'),
('ALIGN', (1, 0), (1, 0), 'CENTER'),
('ALIGN', (2, 0), (2, 0), 'CENTER'),
]))
elements.append(header_table)
elements.append(Spacer(1, 18))
# Certification paragraph
elements.append(Paragraph(
'Full Circle Electronics AZ, LLC (dba Westech Recyclers) certifies that the '
'materials submitted for recycling are received and will be properly recycled '
'in accordance with all state and federal recycling regulations and in '
'accordance with the R2 Standard.',
cert_style
))
# Data table
data_rows = [
['Company:', company_name or 'N/A'],
['Weight:', weights or 'N/A'],
['Items Recycled:', items_recycled],
]
if contact_name:
data_rows.append(['Contact:', contact_name])
if contact_number:
data_rows.append(['Phone:', contact_number])
if address_line:
data_rows.append(['Address:', address_line])
data_table = Table(data_rows, colWidths=[3.36 * inch, 3.64 * inch])
data_table.setStyle(TableStyle([
('FONTNAME', (0, 0), (-1, -1), 'Times-Roman'),
('FONTSIZE', (0, 0), (-1, -1), 12),
('ALIGN', (0, 0), (0, -1), 'RIGHT'),
('ALIGN', (1, 0), (1, -1), 'LEFT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bfbfbf')),
('TOPPADDING', (0, 0), (-1, -1), 4),
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
('LEFTPADDING', (0, 0), (-1, -1), 6),
('RIGHTPADDING', (0, 0), (-1, -1), 6),
]))
elements.append(data_table)
elements.append(Spacer(1, 12))
# Body paragraphs
elements.append(Paragraph(
'Full Circle Electronics AZ, LLC further acknowledges the acceptance and '
'recycling of any material potentially containing data. Data containing '
'materials are stored in our secured facility ensuring the security of the '
'unit(s) prior to data sanitization.',
body_style
))
elements.append(Paragraph(
'Data containing materials are sanitized in compliance with NIST 800-88 '
'guidelines which is set forth by the U.S. government for a robust methodology '
'for erasing data from storage media. Depending upon the media received, the '
'data destruction methods used are as follows:',
body_style
))
# Bullet list
bullets = [
'Hard disk and solid-state drives will either be logically sanitized using professional software or physically destroyed via shredding or degaussing.',
'Media cards and small storage devices will either be degaussed / shredded at our facility or sent straight to a smelter.',
'Data tapes or reels will either be degaussed or shredded at a vetted and approved downstream service provider.',
'Electronics with embedded storage chips will either be destroyed by physical destruction at our facility or at a vetted and approved downstream service provider.',
'Small electronics containing data will either be logically sanitized using the manufacturer\'s application for destroying data or sent to a vetted and approved downstream service provider.',
]
for b in bullets:
elements.append(Paragraph('\u2022 ' + b, bullet_style))
elements.append(Spacer(1, 6))
# Opt-in
elements.append(Paragraph(
'Opt-in option. If you desire to be informed of our data destruction process '
'changes or be notified of any unlikely security breaches, please let us know.',
optin_style
))
# Signature
elements.append(Paragraph('Westech Recyclers', sig_style))
# Footer
elements.append(Spacer(1, 10))
elements.append(Paragraph(
'220 S 9th St Phoenix, AZ 85034    '
'<link href="http://www.westechrecyclers.com" color="#1155cc">www.westechrecyclers.com</link> &nbsp;&nbsp; '
'602.256.7626',
footer_style
))
doc.build(elements)
output.seek(0)
frappe.response.filename = 'COR_' + (company_name or 'document').replace(' ', '_') + '.pdf'
frappe.response.filecontent = output.getvalue()
frappe.response.type = 'download'
frappe.response.display_content_as = 'attachment'
@@ -107,3 +107,21 @@ def _calculate_slots(gaylord_sizes_text):
slots = size_map.get(size, 1) slots = size_map.get(size, 1)
total += int(count) * slots total += int(count) * slots
return total or 1 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,499 @@
import json
import frappe
from frappe.utils import today, getdate, add_days
from datetime import timedelta
@frappe.whitelist()
def get_pickups(date=None):
"""Fetch scheduled pickups with optional date filter.
Returns pickups, calendar (next 30 days), and weekly chart data."""
filters = []
if date:
filters.append(["Scheduled Pickup", "pickup_date", "=", date])
fields = [
"name", "pickup_date", "pickup_type", "status", "truck", "stop_order",
"customer_number", "company_name",
"contact_name", "contact_phone", "contact_email",
"address_line", "city", "state", "zip_code",
"latitude", "longitude",
"estimated_items", "estimated_weight", "load_contents",
"num_labels", "data_status", "red_r2",
"notes", "legacy_notes", "needs_aor", "needs_cod",
]
pickups = frappe.get_list("Scheduled Pickup",
fields=fields,
filters=filters if filters else None,
order_by="pickup_date asc, stop_order asc",
limit_page_length=500,
)
# Build calendar data (next 30 days)
from_date = getdate(today())
to_date = add_days(from_date, 30)
all_pickups = frappe.get_list("Scheduled Pickup",
fields=["pickup_date"],
filters=[["Scheduled Pickup", "pickup_date", ">=", str(from_date)],
["Scheduled Pickup", "pickup_date", "<=", str(to_date)]],
limit_page_length=500,
)
pickup_counts = {}
for p in all_pickups:
d = p.get("pickup_date", "")
if d:
pickup_counts[d] = pickup_counts.get(d, 0) + 1
calendar = []
for i in range(30):
d = add_days(from_date, i)
ds = str(d)
calendar.append({"date": ds, "count": pickup_counts.get(ds, 0)})
# Build weekly chart data (last 12 weeks)
weekly = []
for i in range(11, -1, -1):
week_start = add_days(from_date, -(from_date.weekday() + 7 * i))
week_end = add_days(week_start, 6)
count = 0
for d_str, c in pickup_counts.items():
try:
d = getdate(d_str)
if week_start <= d <= week_end:
count += c
except (ValueError, TypeError):
pass
weekly.append({"label": week_start.strftime("%m/%d"), "count": count})
return {
"pickups": pickups,
"calendar": calendar,
"weekly": weekly,
}
@frappe.whitelist()
def auto_route(date=None):
"""Auto-assign pickups to trucks based on capacity and proximity."""
if not date:
date = today()
pickups = frappe.get_list("Scheduled Pickup",
filters={"pickup_date": date},
fields=["name", "company_name", "estimated_items", "estimated_weight",
"latitude", "longitude", "pickup_type"],
limit_page_length=200,
)
if not pickups:
return {"success": True, "assigned": 0}
trucks = ["Truck 1", "Truck 2", "Truck 3"]
sorted_p = sorted(pickups, key=lambda p: (float(p.get("latitude") or 0), float(p.get("longitude") or 0)))
n = len(sorted_p)
assigned = 0
for i, p in enumerate(sorted_p):
if p.get("pickup_type") == "Drop-off":
truck = ""
else:
truck = trucks[i % 3] if n <= 3 else trucks[min(i * 3 // n, 2)]
doc = frappe.get_doc("Scheduled Pickup", p["name"])
doc.truck = truck
doc.status = "Routed" if truck else "Scheduled"
doc.stop_order = i + 1
doc.save()
assigned += 1
frappe.db.commit()
return {"success": True, "assigned": assigned}
@frappe.whitelist()
def get_checkins():
"""Fetch completed check-ins — returns Loads with their Pallets."""
loads = frappe.get_list("Load",
fields=["name", "load_number", "incoming_date", "customer", "customer_name",
"total_devices", "total_weight", "data_status", "red_r2"],
order_by="incoming_date desc",
limit_page_length=100,
)
for load in loads:
pallets = frappe.get_list("Pallet",
filters={"load": load.name},
fields=["name", "pallet_number", "received_date", "inbound_weight",
"total_items", "data_status", "red_r2", "description", "status"],
limit_page_length=50,
)
load["pallets"] = pallets
load["pallet_count"] = len(pallets)
return {"checkins": loads}
@frappe.whitelist()
def checkin_load(pickup_name, received_date, actual_pallets, total_weight, load_contents, data_status=None, red_r2=None):
"""Check in a load: create Load + Pallets, mark pickup Complete."""
# Get the pickup
pickup = frappe.get_doc("Scheduled Pickup", pickup_name)
if not actual_pallets or int(actual_pallets) < 1:
frappe.throw("Actual pallet count must be at least 1")
actual_pallets = int(actual_pallets)
# Resolve customer - customer_number on pickup is a Link to Customer
customer_id = pickup.customer_number
if not customer_id or not frappe.db.exists("Customer", customer_id):
frappe.throw("Customer {} not found. Please verify the customer on the pickup.".format(customer_id))
# Generate Load name: MMDDYYYY-CustomerNumber format
from datetime import datetime
try:
dt = datetime.strptime(received_date, "%Y-%m-%d")
date_part = dt.strftime("%m%d%Y")
except (ValueError, TypeError):
date_part = received_date.replace("-", "")
cust_num = customer_id
load_name = "{}-{}".format(date_part, cust_num)
# Make unique if name already exists
base_name = load_name
counter = 1
while frappe.db.exists("Load", load_name):
load_name = "{}-{}".format(base_name, counter)
counter += 1
# Create Load
load = frappe.get_doc({
"doctype": "Load",
"name": load_name,
"load_number": load_name,
"incoming_date": received_date,
"customer": customer_id,
"customer_name": pickup.company_name or "",
"customer_number": customer_id,
"data_status": data_status or pickup.data_status or "",
"red_r2": red_r2 or pickup.red_r2 or "",
"total_weight": float(total_weight) if total_weight else 0,
"total_devices": actual_pallets,
"material_items": [{
"material_type": "# Of Pallets",
"total_count": actual_pallets,
"weight": float(total_weight) if total_weight else 0,
"initial_data_status": data_status or pickup.data_status or "D0",
}],
})
load.insert()
frappe.db.commit()
# Create Pallets — autoname=pallet_number, so set name=pallet_number
for i in range(actual_pallets):
pallet_num = "{}-P{}".format(load_name, i + 1)
pallet = frappe.get_doc({
"doctype": "Pallet",
"name": pallet_num,
"pallet_number": pallet_num,
"received_date": received_date,
"load": load.name,
"company_name": pickup.company_name or "",
"inbound_weight": str(round(float(total_weight) / actual_pallets, 1)) if total_weight and actual_pallets else "",
"description": load_contents or "",
"data_status": data_status or pickup.data_status or "",
"red_r2": red_r2 or pickup.red_r2 or "",
"contact_name": pickup.contact_name or "",
"contact_number": pickup.contact_phone or "",
"contact_email": pickup.contact_email or "",
"address_line": (pickup.address_line or "") + ((", " + pickup.city) if pickup.city else "") + ((", " + pickup.state) if pickup.state else ""),
"needs_aor": pickup.needs_aor or 0,
"needs_cod": pickup.needs_cod or 0,
"notes": pickup.notes or "",
"pickup": pickup.pickup_type or "",
"status": "Received",
})
pallet.insert()
frappe.db.commit()
# Update pickup status
pickup.status = "Complete"
pickup.save()
frappe.db.commit()
return {
"success": True,
"load": load.name,
"pallets_created": actual_pallets,
}
@frappe.whitelist()
def get_pickup_details(pickup_name):
"""Get full details of a Scheduled Pickup for the check-in form."""
pickup = frappe.get_doc("Scheduled Pickup", pickup_name)
return {
"name": pickup.name,
"pickup_date": pickup.pickup_date,
"pickup_type": pickup.pickup_type,
"customer_number": pickup.customer_number,
"company_name": pickup.company_name,
"contact_name": pickup.contact_name,
"contact_phone": pickup.contact_phone,
"contact_email": pickup.contact_email,
"address_line": pickup.address_line,
"city": pickup.city,
"state": pickup.state,
"zip_code": pickup.zip_code,
"estimated_items": pickup.estimated_items,
"estimated_weight": pickup.estimated_weight,
"data_status": pickup.data_status,
"red_r2": pickup.red_r2,
"needs_aor": pickup.needs_aor,
"needs_cod": pickup.needs_cod,
"notes": pickup.notes,
}
@frappe.whitelist()
def cor_report():
"""Generate Certificate of Recycling report."""
pickups = frappe.get_list("Scheduled Pickup",
filters={"status": "Complete"},
fields=["name", "pickup_date", "company_name", "customer_number",
"estimated_items", "estimated_weight", "load_contents",
"data_status", "red_r2", "needs_aor", "needs_cod"],
order_by="pickup_date desc",
limit_page_length=200,
)
html = "<!DOCTYPE html><html><head><title>CoR Report</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:40px;}"
html += "table{border-collapse:collapse;width:100%;}"
html += "th,td{border:1px solid #ddd;padding:8px;text-align:left;font-size:13px;}"
html += "th{background:#2F5496;color:#fff;}h1{color:#2F5496;}</style></head><body>"
html += "<h1>Certificate of Recycling (CoR) Report</h1>"
html += "<p>Generated: " + frappe.utils.now() + "</p>"
html += "<p>Total completed loads: " + str(len(pickups)) + "</p>"
if pickups:
html += "<table><tr><th>Date</th><th>Customer</th><th>Items</th><th>Weight</th>"
html += "<th>Contents</th><th>Data Status</th><th>RED/R2</th><th>AoR</th><th>CoD</th></tr>"
for p in pickups:
html += "<tr><td>" + str(p.get("pickup_date", "")) + "</td>"
html += "<td>" + str(p.get("company_name", "")) + "</td>"
html += "<td>" + str(p.get("estimated_items", "")) + "</td>"
html += "<td>" + str(p.get("estimated_weight", "")) + "</td>"
html += "<td>" + str(p.get("load_contents", "")) + "</td>"
html += "<td>" + str(p.get("data_status", "")) + "</td>"
html += "<td>" + str(p.get("red_r2", "")) + "</td>"
html += "<td>" + ("" if p.get("needs_aor") else "") + "</td>"
html += "<td>" + ("" if p.get("needs_cod") else "") + "</td></tr>"
html += "</table>"
else:
html += "<p>No completed loads found.</p>"
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
@frappe.whitelist()
def print_route_sheet(date=None):
"""Generate printable route sheet."""
if not date:
date = today()
filters = {"pickup_date": date} if date else {}
pickups = frappe.get_list("Scheduled Pickup",
filters=filters,
fields=["name", "pickup_date", "pickup_type", "status", "truck", "stop_order",
"company_name", "contact_name", "contact_phone", "contact_email",
"address_line", "city", "state", "zip_code",
"estimated_items", "estimated_weight", "load_contents",
"data_status", "red_r2", "needs_aor", "needs_cod", "notes"],
order_by="truck asc, stop_order asc",
limit_page_length=200,
)
trucks = {}
unassigned = []
for p in pickups:
t = p.get("truck", "")
if t and t != "Unassigned":
trucks.setdefault(t, []).append(p)
else:
unassigned.append(p)
html = "<!DOCTYPE html><html><head><title>Route Sheet</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:30px;}"
html += "table{border-collapse:collapse;width:100%;margin-bottom:12px;}"
html += "th,td{border:1px solid #999;padding:6px 10px;text-align:left;font-size:12px;}"
html += "th{background:#2F5496;color:#fff;}h1{color:#2F5496;font-size:20px;}"
html += ".truck-header{background:#f0f0f0;padding:8px;font-weight:700;font-size:14px;border:1px solid #ccc;}"
html += "@media print{body{margin:10px;}}</style></head><body>"
html += "<h1>Route Sheet — " + str(date or "Today") + "</h1>"
for truck_name, stops in sorted(trucks.items()):
html += '<div class="truck-header">🚛 ' + truck_name + "" + str(len(stops)) + " stops</div>"
html += "<table><tr><th>#</th><th>Customer</th><th>Address</th><th>Contact</th>"
html += "<th>Items</th><th>Weight</th><th>Data</th><th>RED/R2</th><th>AoR</th><th>CoD</th><th>Notes</th></tr>"
for i, s in enumerate(stops, 1):
addr = str(s.get("address_line", "")) + ", " + str(s.get("city", "")) + ", " + str(s.get("state", "")) + " " + str(s.get("zip_code", ""))
html += "<tr><td>" + str(i) + "</td><td>" + str(s.get("company_name", "")) + "</td>"
html += "<td>" + addr + "</td><td>" + str(s.get("contact_name", "")) + "<br>" + str(s.get("contact_phone", "")) + "</td>"
html += "<td>" + str(s.get("estimated_items", "")) + "</td><td>" + str(s.get("estimated_weight", "")) + "</td>"
html += "<td>" + str(s.get("data_status", "")) + "</td><td>" + str(s.get("red_r2", "")) + "</td>"
html += "<td>" + ("" if s.get("needs_aor") else "") + "</td>"
html += "<td>" + ("" if s.get("needs_cod") else "") + "</td>"
html += "<td>" + str(s.get("notes", "")) + "</td></tr>"
html += "</table>"
if unassigned:
html += "<h2>Unassigned</h2><table><tr><th>Customer</th><th>Address</th><th>Notes</th></tr>"
for s in unassigned:
html += "<tr><td>" + str(s.get("company_name", "")) + "</td>"
html += "<td>" + str(s.get("address_line", "")) + "</td>"
html += "<td>" + str(s.get("notes", "")) + "</td></tr>"
html += "</table>"
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
@frappe.whitelist()
def print_green_sheet(date=None):
"""Generate Green Sheet printout for each pallet.
Shows customer info, service level banner, driver instructions, RED LINE instructions."""
if not date:
date = today()
# Get completed loads for this date
loads = frappe.get_list("Load",
filters={"incoming_date": date},
fields=["name", "load_number", "customer", "customer_name",
"incoming_date", "total_weight", "data_status", "red_r2"],
order_by="name asc",
limit_page_length=200,
)
html = "<!DOCTYPE html><html><head><title>Green Sheets</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:30px;}"
html += ".green-sheet{border:2px solid #2E7D32;border-radius:6px;padding:16px;margin:20px 0;page-break-after:always;}"
html += ".gs-title{color:#2E7D32;font-size:18px;font-weight:700;border-bottom:2px solid #2E7D32;padding-bottom:4px;}"
html += ".gs-customer{background:#E8F5E9;border:1px solid #66BB6A;border-radius:4px;padding:8px;margin:8px 0;}"
html += ".gs-service-banner{background:#C62828;color:#fff;font-size:16px;font-weight:700;text-align:center;padding:8px;border-radius:4px;margin:8px 0;}"
html += ".gs-driver{background:#F5F5F5;border:1px solid #999;border-radius:4px;padding:8px;margin:8px 0;}"
html += ".gs-redline{background:#FFCDD2;border:2px solid #C62828;border-radius:4px;padding:8px;margin:8px 0;}"
html += ".gs-r2warning{background:#FFF9C4;border:1px solid #F9A825;border-radius:4px;padding:8px;margin:8px 0;font-size:12px;}"
html += "table{border-collapse:collapse;width:100%;margin:8px 0;}"
html += "th,td{border:1px solid #999;padding:6px;text-align:left;font-size:12px;}"
html += "th{background:#2E7D32;color:#fff;}"
html += ".gs-footer{margin-top:12px;font-size:11px;color:#666;border-top:1px solid #ccc;padding-top:8px;}"
html += "@media print{body{margin:10px;}.green-sheet{page-break-after:always;}}</style></head><body>"
for load in loads:
pallets = frappe.get_list("Pallet",
filters={"load": load.name},
fields=["name", "pallet_number", "inbound_weight", "total_items",
"data_status", "red_r2", "description", "needs_aor", "needs_cod", "notes", "status"],
limit_page_length=50,
)
for pallet in pallets:
html += '<div class="green-sheet">'
html += '<div class="gs-title">🟢 GREEN SHEET — Data-Bearing Equipment Tracking</div>'
html += '<div style="text-align:right;font-size:12px;">Pallet # ' + str(pallet.get("pallet_number", "")) + ' | Load # ' + str(load.get("name", "")) + ' | ' + str(load.get("incoming_date", "")) + '</div>'
# Customer block
html += '<div class="gs-customer">'
service_level = ""
if pallet.get("red_r2"):
service_level = pallet.get("red_r2", "")
html += '<strong>(' + str(load.get("customer", "")) + ') — ' + str(service_level) + '' + str(load.get("customer_name", "")) + '</strong>'
html += '</div>'
# Service level banner (only for RED/NIST)
rr = pallet.get("red_r2", "")
if rr and rr != "Neither":
html += '<div class="gs-service-banner">SERVICE LEVEL: ' + str(rr).upper() + '</div>'
# Driver instructions (notes)
if pallet.get("notes"):
html += '<div class="gs-driver"><strong>Driver Instructions:</strong><br>' + str(pallet.get("notes", "")) + '</div>'
# RED LINE instructions (for RED/NIST)
if rr and rr not in ("", "Neither", "R2"):
html += '<div class="gs-redline"><strong>⚠ RED LINE INSTRUCTIONS</strong><br>All data-bearing equipment must be tracked. Destruction method per customer specification.</div>'
# Pallet details table
html += '<table><tr><th>Pallet Designation</th><th>Data Status</th></tr>'
html += '<tr><td>' + str(pallet.get("status", "Received")) + '</td><td>' + str(pallet.get("data_status", "")) + '</td></tr>'
html += '<tr><th>Inbound Weight</th><th>Total Items</th></tr>'
html += '<tr><td>' + str(pallet.get("inbound_weight", "")) + '</td><td>' + str(pallet.get("total_items", "")) + '</td></tr>'
html += '<tr><th>AoR/CoR</th><th>Contents</th></tr>'
aor_cor = ""
if pallet.get("needs_aor"): aor_cor += "✓ AoR "
if pallet.get("needs_cod"): aor_cor += "✓ CoD"
html += '<tr><td>' + (aor_cor or "None") + '</td><td>' + str(pallet.get("description", "")) + '</td></tr>'
html += '</table>'
# Material tracking (hand-write on paper)
html += '<table><tr><th>Material</th><th>%</th><th>Weight</th><th>Sign Off</th><th>Date</th></tr>'
for _ in range(4):
html += '<tr><td>&nbsp;</td><td></td><td></td><td></td><td></td></tr>'
html += '</table>'
# R2 warning
html += '<div class="gs-r2warning">⚠ R2 REQUIREMENT: This pallet contains data-bearing equipment. All devices must be tracked through erasure with 5% verification audit.</div>'
# Signatures
html += '<table><tr><th>Received By</th><th>Inspected By</th><th>Verified By</th></tr>'
html += '<tr><td>&nbsp;</td><td>&nbsp;</td><td>&nbsp;</td></tr></table>'
# Footer
html += '<div class="gs-footer">Westech Electronics • Green Sheet — Pallet # ' + str(pallet.get("pallet_number", "")) + ' • Printed ' + str(frappe.utils.nowdate()) + ' • KEEP WITH PALLET AT ALL TIMES</div>'
html += '</div>'
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
@frappe.whitelist()
def print_labels(date=None):
"""Generate printable labels."""
if not date:
date = today()
filters = {"pickup_date": date} if date else {}
pickups = frappe.get_list("Scheduled Pickup",
filters=filters,
fields=["name", "company_name", "pickup_date", "num_labels", "data_status", "red_r2"],
limit_page_length=200,
)
html = "<!DOCTYPE html><html><head><title>Labels</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:10px;}"
html += ".label{border:2px solid #000;width:3in;height:1.5in;display:inline-block;margin:4px;padding:6px;font-size:11px;page-break-inside:avoid;}"
html += ".label-co{font-weight:700;font-size:14px;}.label-date{font-size:10px;color:#666;}"
html += ".label-status{font-size:10px;margin-top:2px;}"
html += "@media print{body{margin:0;}}</style></head><body>"
for p in pickups:
n = p.get("num_labels") or 1
for _ in range(n):
html += '<div class="label">'
html += '<div class="label-co">' + str(p.get("company_name", "")) + '</div>'
html += '<div class="label-date">' + str(p.get("pickup_date", "")) + '</div>'
html += '<div class="label-status">' + str(p.get("data_status", "")) + " | " + str(p.get("red_r2", "")) + '</div>'
html += '</div>'
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
@@ -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
@@ -11,6 +11,15 @@ frappe.ui.form.on('Pallet', {
'pallet': frm.doc.pallet_number || frm.doc.name 'pallet': frm.doc.pallet_number || frm.doc.name
}); });
}, __('View')); }, __('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) { customer_number: function(frm) {
@@ -2,7 +2,7 @@ frappe.ui.form.on('Scheduled Pickup', {
customer_number: function(frm) { customer_number: function(frm) {
var customer = frm.doc.customer_number; var customer = frm.doc.customer_number;
if (!customer) { if (!customer) {
clear_supplier_fields(frm); clear_customer_fields(frm);
return; return;
} }
frappe.call({ frappe.call({
@@ -52,7 +52,7 @@ frappe.ui.form.on('Scheduled Pickup', {
} }
}); });
function clear_supplier_fields(frm) { function clear_customer_fields(frm) {
frm.set_value('company_name', ''); frm.set_value('company_name', '');
frm.set_value('contact_name', ''); frm.set_value('contact_name', '');
frm.set_value('contact_phone', ''); frm.set_value('contact_phone', '');
@@ -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
@@ -7,12 +7,12 @@ def search_customers(q=""):
return [] return []
q = q.strip().lower() q = q.strip().lower()
customers = frappe.db.sql(""" customers = frappe.db.sql("""
SELECT c.name, c.customer_name, c.customer_number, c.phone, c.email_id, SELECT c.name, c.customer_name, c.customer_number, c.mobile_no,
a.address_line1, a.city, a.state, a.pincode a.address_line1, a.city, a.state, a.pincode
FROM tabCustomer c 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 `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 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.phone) LIKE %s 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 ORDER BY c.customer_name
LIMIT 20 LIMIT 20
""", ("%" + q + "%", "%" + q + "%", "%" + q + "%"), as_dict=True) """, ("%" + q + "%", "%" + q + "%", "%" + q + "%"), as_dict=True)
@@ -27,7 +27,7 @@ def get_customer(name):
addr = frappe.db.sql(""" addr = frappe.db.sql("""
SELECT a.address_line1, a.city, a.state, a.pincode, a.phone SELECT a.address_line1, a.city, a.state, a.pincode, a.phone
FROM tabAddress a FROM tabAddress a
JOIN tabDynamic Link dl ON dl.parent = a.name AND dl.link_doctype = 'Customer' AND dl.link_name = %s JOIN `tabDynamic Link` dl ON dl.parent = a.name AND dl.link_doctype = 'Customer' AND dl.link_name = %s
LIMIT 1 LIMIT 1
""", (name,), as_dict=True) """, (name,), as_dict=True)
if addr: if addr:
@@ -50,7 +50,7 @@ def create_customer_from_intake(data):
customer.customer_group = data.get("customer_group", "IT Recycling") customer.customer_group = data.get("customer_group", "IT Recycling")
customer.customer_type = "Company" customer.customer_type = "Company"
customer.customer_number = data.get("customer_number") customer.customer_number = data.get("customer_number")
customer.phone = data.get("phone") customer.mobile_no = data.get("phone")
customer.email_id = data.get("email_id") customer.email_id = data.get("email_id")
customer.legacy_notes = data.get("legacy_notes") customer.legacy_notes = data.get("legacy_notes")
customer.hours_of_operation = data.get("hours_of_operation") customer.hours_of_operation = data.get("hours_of_operation")
@@ -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 @@
@@ -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,3 @@
import frappe
no_cache = 1
@@ -0,0 +1,3 @@
import frappe
no_cache = 1
@@ -1,6 +1,6 @@
<style> <style>
.pallet-list-page { font-family: Helvetica Neue, Arial, sans-serif; } .pallet-list-page { font-family: Helvetica Neue, Arial, sans-serif; }
.pallet-list-page h2 { color: #2F5496; margin-bottom: 16px; font-size: 20px; } .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 { margin-bottom: 16px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.pallet-search-box input, .pallet-search-box select { .pallet-search-box input, .pallet-search-box select {
padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;
@@ -9,18 +9,18 @@
padding: 6px 14px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; padding: 6px 14px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;
font-size: 13px; background: white; font-size: 13px; background: white;
} }
.pallet-search-box button.btn-primary { background: #2F5496; color: white; border-color: #2F5496; } .pallet-search-box button.btn-primary { background: #6f42c1; color: white; border-color: #6f42c1; }
.pallet-search-box button.btn-success { background: #548235; color: white; border-color: #548235; } .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-container { padding: 0; overflow-x: auto; background: white; border-radius: 4px; }
.pallet-table { width: 100%; border-collapse: collapse; font-size: 13px; } .pallet-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.pallet-table th { .pallet-table th {
background: #2F5496; color: white; padding: 8px 10px; text-align: left; background: #6f42c1; color: white; padding: 8px 10px; text-align: left;
cursor: pointer; white-space: nowrap; font-weight: 500; cursor: pointer; white-space: nowrap; font-weight: 500;
} }
.pallet-table td { padding: 5px 10px; border-bottom: 1px solid #eee; } .pallet-table td { padding: 5px 10px; border-bottom: 1px solid #eee; }
.pallet-table tr:hover { background: #f5f5f5; } .pallet-table tr:hover { background: #f5f5f5; }
.pallet-table tr.new-row { background: #FFF9E6 !important; } .pallet-table tr.new-row { background: #FFF9E6 !important; }
.pallet-table a { color: #2F5496; text-decoration: none; font-weight: 600; } .pallet-table a { color: #6f42c1; text-decoration: none; font-weight: 600; }
.status-received { color: #2196F3; font-weight: 600; } .status-received { color: #2196F3; font-weight: 600; }
.status-sorting { color: #FF9800; font-weight: 600; } .status-sorting { color: #FF9800; font-weight: 600; }
.status-processing { color: #9C27B0; font-weight: 600; } .status-processing { color: #9C27B0; font-weight: 600; }
@@ -29,9 +29,9 @@
.pallet-pagination { margin-top: 12px; display: flex; gap: 5px; align-items: center; } .pallet-pagination { margin-top: 12px; display: flex; gap: 5px; align-items: center; }
.pallet-pagination button { .pallet-pagination button {
padding: 5px 12px; border: 1px solid #ddd; background: white; cursor: pointer; padding: 5px 12px; border: 1px solid #ddd; background: white; cursor: pointer;
border-radius: 4px; font-size: 13px; color: #2F5496; border-radius: 4px; font-size: 13px; color: #6f42c1;
} }
.pallet-pagination button.active { background: #2F5496; color: white; border-color: #2F5496; } .pallet-pagination button.active { background: #6f42c1; color: white; border-color: #6f42c1; }
.pallet-pagination button:disabled { opacity: 0.5; cursor: not-allowed; } .pallet-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pallet-count { margin-left: auto; color: #666; font-size: 13px; } .pallet-count { margin-left: auto; color: #666; font-size: 13px; }
</style> </style>
@@ -17,7 +17,7 @@ frappe.pages["pallet-list"].on_page_load = function(wrapper) {
function loadPallets() { function loadPallets() {
frappe.call({ frappe.call({
method: "westech_r2.page.pallet-list.pallet-list.get_pallets", method: "westech_r2.page.pallet_list.pallet_list.get_pallets",
args: { args: {
page: currentPage, page: currentPage,
page_size: pageSize, page_size: pageSize,
@@ -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 @@
@@ -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

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