Compare commits

...

44 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
Westech Admin 17584d0002 cleanup: remove broken hyphen-named pages 2026-05-21 01:09:59 +00:00
Westech Admin bb5c8143fc fix: rename page templates to underscores 2026-05-21 00:00:46 +00:00
Westech Admin 2079e27402 fix: Load.customer_name fetch_from points to customer.customer_name 2026-05-20 23:40:03 +00:00
Westech Admin efc06dd98a fix: Load.customer points to Customer, data_status D0/D1 2026-05-20 23:32:54 +00:00
Westech Admin 5be239fced fix: Pallet data_status options to D0/D1 only 2026-05-20 23:30:09 +00:00
Westech Admin 311e74fe18 fix: data_status D0/D1 only, add fields to intake page 2026-05-20 23:28:08 +00:00
Westech Admin 4f4717463e feat: CRM integration - customer intake, service invoice generation, COR print format\n\n- Added customer-intake page (search/create customers, create pallets)\n- Added whitelisted generate_service_invoice() API for native Sales Invoice creation\n- Added COR logo image asset\n- Removed stale fixtures/doctype.json (was causing bench migrate failures) 2026-05-20 22:57:53 +00:00
Westech Admin 6a5f2a3ebb feat: Customer Records page + CRM data import (7,966 records) 2026-05-20 22:14:10 +00:00
Westech Admin 0128d1aae7 feat: Add Customer Records page shortcut to CRM workspace 2026-05-20 22:04:56 +00:00
Westech Admin 6f23c32af3 feat: Add Customer Records page with Lead integration 2026-05-20 22:03:33 +00:00
Westech Admin 2e56814a1e Fix page files: remove duplicate dirs, fix ebay-pricing HTML, ensure hyphen+underscore filenames 2026-05-20 20:55:52 +00:00
Westech Admin 9fc0a92618 Fix page JS files with full content for load-detail, load-update, route-planner, pallet-list 2026-05-20 04:20:04 +00:00
Westech Admin 34d5c9cf59 Fix load-detail and load-update page JS, add route-planner, fix pallet_list directory 2026-05-20 04:15:43 +00:00
Westech Admin 233734c09f Update pallet-list API with input row and customer_number filter 2026-05-19 21:51:50 +00:00
Westech Admin c01b62f131 Add pallet-list page with input row, customer filter, and update pallet.js 2026-05-19 21:49:34 +00:00
Westech Admin ece68b80e9 Simplify pallet-list API 2026-05-19 21:45:39 +00:00
Westech Admin 73e0cd6c79 Clean up junk pallets and update pallet-list filters 2026-05-19 21:39:32 +00:00
Westech Admin fe91302ff5 Fix 2024 pallet numbering and update pallet-list page 2026-05-19 21:28:52 +00:00
Westech Admin 73e7df4735 Add pallet-list page and Pallet form auto-fill 2026-05-19 20:57:35 +00:00
Westech Admin 2a2dccdcdc Add deployment instructions for 2026-05-19 2026-05-19 18:38:08 +00:00
Westech Admin 232c4af3b6 Fix migration script: read cosmetic_grade from Device Condition Report, not Serial No
Serial No.cosmetic_grade is empty on production. The actual C# data lives
in Device Condition Report (42,940 records). Updated script to lookup
latest report per serial_no and map from there.
2026-05-19 18:24:48 +00:00
Westech Admin 2b57d2df56 Add data migration script for Serial No grades
Run after bench migrate. Maps cosmetic_grade C# values to new grade field:
- C5-C9 → High
- C4 → Med
- C3 → Low
- C0-C2, blank → Flagged

Requires: bench migrate has already created the new fields.
2026-05-19 18:15:09 +00:00
Westech Admin 94b5765fb0 Add Schema Diagram shortcut to Westech workspace - links to EIM schema diagram 2026-05-19 17:55:11 +00:00
Westech Admin 1ef2b63730 Fix eBay s-card parser, add credentials fallback, verified pricing pipeline
- eBay scraper: Fixed HTML parser for post-2024 s-card layout (s-card class)
- Credentials: Fixed fallback from frappe.conf when Settings doc is empty
- Source field: Normalized values (oxylabs/apify/unknown) for System Pricing DocType
- Verified: 80 Dell Latitude 5410 sold listings scraped
  - Low: 5, Median: 39.99, Avg: 58.43, High: 96.42
- Verified Serial No pricing: High/Med/Low correctly maps to market_high/median/low
- Verified CPU/RAM Fail → Flagged + Dismantle routing works
2026-05-19 16:43:47 +00:00
Westech Admin ebf907c9d5 Fix eBay HTML parser for post-2024 s-card layout, fix source field values 2026-05-19 16:33:17 +00:00
Westech Admin 750141827b Serial No: warehousing pricing + grading + hardware failure routing
- Grade options: High/Med/Low/Flagged (replaces A/B/C/D/F)
- assigned_price label → Recommended Price
- Client script: grade→price mapping (High→market_high, Med→market_median, Low→market_low)
- Flagged grade shows FLAGGED, no price, Dismantle status
- New fields: cpu_test (Pass/Fail), ram_test (Pass/Fail)
- CPU/RAM Fail → auto Flagged grade, Dismantle status, route to Dismantle warehouse
- Server-side validation prevents pricing on failed hardware
- pricing_status options updated: Needs Pricing/Priced/Flagged/Dismantle/Manual Override/Expired/Error
2026-05-19 15:33:34 +00:00
Westech Admin 5203f02f0b Fix workspace icons 2026-05-18 00:58:12 +00:00
321 changed files with 67217 additions and 4518 deletions
+2
View File
@@ -1 +1,3 @@
*.pyc\n__pycache__/\n*.egg-info/
__pycache__/
*.pyc
+118
View File
@@ -0,0 +1,118 @@
# Westech R2 Deployment — 2026-05-19
## What we're deploying
- **Serial No warehousing pricing + grading** (High/Med/Low/Flagged)
- **CPU/RAM test gates** (Fail → auto Flagged + Dismantle)
- **eBay scraper fix** (post-2024 s-card HTML parser)
- **Schema Diagram** shortcut in Westech workspace
- **Intake button removal** from EIM dashboard
## Pre-deploy (do this first)
```bash
# SSH into production
ssh -o ProxyCommand="cloudflared access ssh --hostname ssh.advante.ch" vagrant@ssh.advante.ch
# Fresh backup (already automated but take one now)
sudo bash /opt/backups/backup-eim.sh
sudo -u frappe bash /opt/backups/backup-mariadb.sh
# Verify backups exist
ls -la /opt/backups/eim/eim_$(date +%Y%m%d)*.db.xz
ls -la /opt/backups/mariadb/erpnext_$(date +%Y%m%d)*.sql.xz
```
## Step 1: Pull code
```bash
cd /home/frappe/erpnext-bench/apps/westech_r2
sudo -u frappe git pull origin main
cd /opt/eim/app
sudo -u vagrant git pull origin main
```
## Step 2: Migrate ERPNext schema
```bash
cd /home/frappe/erpnext-bench
sudo -u frappe bash -c 'source env/bin/activate && python3 -c "
import frappe
frappe.init(site=\"erpnext.local\", sites_path=\"/home/frappe/erpnext-bench/sites\")
frappe.connect()
from frappe.migrate import migrate
migrate()
frappe.destroy()
"'
```
This creates the new fields: `grade`, `cpu_test`, `ram_test`, `assigned_price`, `pricing_status`, etc.
## Step 3: Run data migration
**CRITICAL — do this immediately after migrate finishes.**
Maps `Device Condition Report.cosmetic_grade` → new `Serial No.grade`:
```bash
cd /home/frappe/erpnext-bench
sudo -u frappe bash -c 'source env/bin/activate && python3 /home/frappe/erpnext-bench/apps/westech_r2/westech_r2/migrations/migrate_serial_grades.py'
```
Expected output:
```
Total Serial Nos: ~43,000
High: ~C5-C9 count
Med: ~C4 count
Low: ~C3 count
Flagged: everything else
```
## Step 4: Restart services
```bash
# Restart ERPNext
sudo supervisorctl restart frappe-bench-web: frappe-bench-worker:
# Restart EIM
cd /opt/eim/app && sudo systemctl restart eim.service
```
## Step 5: Verify
1. Open any Serial No in ERPNext
2. Confirm `Grade` dropdown shows: High / Med / Low / Flagged
3. Set grade to High → `Recommended Price` should populate from Item market data
4. Set `CPU Test` to Fail → grade should auto-lock to Flagged, status = Dismantle
5. Westech workspace should show "Schema Diagram" shortcut
6. EIM dashboard should NOT show "Intake Station" button
## Rollback (if needed)
```bash
# Stop services
sudo supervisorctl stop frappe-bench-web: frappe-bench-worker:
sudo systemctl stop eim.service
# Restore MariaDB
sudo -u frappe bash -c 'cd /opt/backups/mariadb && xz -dc erpnext_YYYYMMDD_HHMMSS.sql.xz | mysql -u root -p erpnext_db'
# Restore EIM DB
sudo sqlite3 /opt/eim/eim.db ".restore /opt/backups/eim/eim_YYYYMMDD_HHMMSS.db.xz"
# Revert code
cd /home/frappe/erpnext-bench/apps/westech_r2 && sudo -u frappe git reset --hard HEAD~7
cd /opt/eim/app && sudo -u vagrant git reset --hard HEAD~1
# Restart
sudo supervisorctl start frappe-bench-web: frappe-bench-worker:
sudo systemctl start eim.service
```
## If shit breaks
- **"Grade cannot be X" errors**: Migration script didn't run. Run it.
- **No prices showing**: eBay Pricing Settings missing Oxylabs creds. Check site_config.json.
- **Fields missing**: `bench migrate` failed. Check logs: `tail -f /home/frappe/erpnext-bench/logs/migrate.log`
## Contact
If anything goes wrong and you can't fix it: stop, do NOT touch production further, and get help.
+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'
@@ -35,7 +35,10 @@ def _get_oxylabs_creds():
user = password = ""
if settings:
user = settings.get("oxylabs_user") or ""
password = settings.get_password("oxylabs_password") or ""
try:
password = settings.get_password("oxylabs_password") or ""
except Exception:
pass
if not user:
user = frappe.conf.get("oxylabs_user", "")
if not password:
@@ -47,7 +50,10 @@ def _get_apify_token():
settings = _get_settings()
token = ""
if settings:
token = settings.get_password("apify_token") or ""
try:
token = settings.get_password("apify_token") or ""
except Exception:
pass
if not token:
token = frappe.conf.get("apify_token", "")
return token
@@ -292,7 +298,7 @@ def _parse_prices(items, manufacturer, model, source="oxylabs"):
"price_average": round(avg, 2),
"price_auction": round(median, 2),
"sample_count": len(prices),
"source": f"ebay_{source}",
"source": source or "unknown",
"scraped_at": now(),
}
@@ -311,9 +317,17 @@ def _upsert_system_pricing(manufacturer, model, pricing):
doc.manufacturer = manufacturer
doc.model = model
for key in ("price_high", "price_low", "price_average", "price_auction",
"sample_count", "source", "scraped_at"):
"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()
@@ -107,3 +107,21 @@ def _calculate_slots(gaylord_sizes_text):
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,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,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,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', '');
}
@@ -2,7 +2,7 @@ frappe.ui.form.on('Scheduled Pickup', {
customer_number: function(frm) {
var customer = frm.doc.customer_number;
if (!customer) {
clear_supplier_fields(frm);
clear_customer_fields(frm);
return;
}
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('contact_name', '');
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
@@ -0,0 +1 @@
{"creation":"2026-05-20 15:00:00.000000","docstatus":0,"doctype":"Page","idx":0,"module":"westech_r2","name":"customer-intake","page_name":"customer-intake","roles":[],"script":null,"standard":"Yes","style":null,"system_page":0,"title":"Customer Intake"}
@@ -0,0 +1,96 @@
<div class="container" style="padding:20px;">
<div class="row">
<div class="col-md-12">
<h3>Customer Intake</h3>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label>Search Customer</label>
<input type="text" id="intake-search" class="form-control" placeholder="Type company name, number, or phone...">
<div id="search-results" class="list-group" style="margin-top:8px; max-height:250px; overflow-y:auto;"></div>
</div>
<div class="col-md-6">
<div id="no-match" style="display:none;">
<button id="btn-add-new" class="btn btn-primary">Add New Customer</button>
</div>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-4">
<label>Customer Name</label>
<input type="text" id="cust-name" class="form-control">
</div>
<div class="col-md-4">
<label>Customer Number</label>
<input type="text" id="cust-number" class="form-control">
</div>
<div class="col-md-4">
<label>Phone</label>
<input type="text" id="cust-phone" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-6">
<label>Address Line 1</label>
<input type="text" id="cust-address" class="form-control">
</div>
<div class="col-md-2">
<label>City</label>
<input type="text" id="cust-city" class="form-control">
</div>
<div class="col-md-2">
<label>State</label>
<input type="text" id="cust-state" class="form-control">
</div>
<div class="col-md-2">
<label>Zip</label>
<input type="text" id="cust-zip" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-4">
<label>Contact Persons</label>
<input type="text" id="cust-contacts" class="form-control">
</div>
<div class="col-md-4">
<label>Email</label>
<input type="text" id="cust-email" class="form-control">
</div>
<div class="col-md-4">
<label>Hours of Operation</label>
<input type="text" id="cust-hours" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-4">
<label>Data Status</label>
<select id="pallet-data-status" class="form-control">
<option value="D0">D0 - Unknown</option>
<option value="D1">D1 - Contains Data</option>
</select>
</div>
<div class="col-md-4">
<label>Status</label>
<select id="pallet-status" class="form-control">
<option value="Received">Received</option>
<option value="Sorting">Sorting</option>
<option value="Processing">Processing</option>
<option value="Complete">Complete</option>
<option value="Shipped">Shipped</option>
</select>
</div>
<div class="col-md-4">
<label>Inbound Weight (lbs)</label>
<input type="text" id="pallet-weight" class="form-control">
</div>
</div>
<div class="row" style="margin-top:15px;">
<div class="col-md-12">
<button id="btn-save-cust" class="btn btn-success">Save Customer</button>
<button id="btn-create-pallet" class="btn btn-warning" style="margin-left:10px;">Create Pallet</button>
<span id="cust-status" style="margin-left:15px;"></span>
</div>
</div>
</div>
@@ -0,0 +1,125 @@
frappe.pages["customer-intake"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({parent: wrapper, title: __("Customer Intake"), single_column: true});
var content = frappe.render_template("customer-intake", {});
$(page.body).append(content);
var currentCustomer = null;
var searchTimer = null;
function clearForm() {
currentCustomer = null;
$("#cust-name, #cust-number, #cust-phone, #cust-address, #cust-city, #cust-state, #cust-zip, #cust-contacts, #cust-email, #cust-hours").val("");
$("#search-results").empty();
$("#no-match").hide();
$("#cust-status").text("");
}
function fillForm(c) {
currentCustomer = c;
$("#cust-name").val(c.customer_name || "");
$("#cust-number").val(c.customer_number || "");
$("#cust-phone").val(c.phone || c.address_phone || "");
$("#cust-address").val(c.address_line1 || "");
$("#cust-city").val(c.city || "");
$("#cust-state").val(c.state || "");
$("#cust-zip").val(c.pincode || "");
$("#cust-contacts").val(c.contact_persons || "");
$("#cust-email").val(c.email_id || "");
$("#cust-hours").val(c.hours_of_operation || "");
$("#cust-status").text("Selected: " + (c.customer_name || c.name));
$("#no-match").hide();
}
function doSearch() {
var q = $("#intake-search").val().trim();
if (q.length < 2) { $("#search-results").empty(); return; }
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.search_customers",
args: {q: q},
callback: function(r) {
var list = $("#search-results").empty();
if (r.message && r.message.length) {
r.message.forEach(function(c) {
var item = $('<div class="list-group-item" style="cursor:pointer;">')
.html('<b>' + frappe.utils.escape_html(c.customer_name || c.name) + '</b> ' +
(c.address_line1 ? '<br>' + frappe.utils.escape_html(c.address_line1) : '') +
(c.city ? ', ' + c.city : '') +
(c.phone ? ' <br><small>' + c.phone + '</small>' : ''));
item.on("click", function() { fillForm(c); });
list.append(item);
});
$("#no-match").hide();
} else {
$("#no-match").show();
}
}
});
}
$("#intake-search").on("input", function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(doSearch, 300);
});
$("#btn-add-new").click(function() {
clearForm();
$("#cust-name").val($("#intake-search").val());
$("#cust-status").text("Adding new customer...");
});
$("#btn-save-cust").click(function() {
var data = {
customer_name: $("#cust-name").val(),
customer_number: $("#cust-number").val(),
phone: $("#cust-phone").val(),
address_line1: $("#cust-address").val(),
city: $("#cust-city").val(),
state: $("#cust-state").val(),
pincode: $("#cust-zip").val(),
contact_persons: $("#cust-contacts").val(),
email_id: $("#cust-email").val(),
hours_of_operation: $("#cust-hours").val()
};
if (currentCustomer && currentCustomer.name) {
data.name = currentCustomer.name;
}
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.create_customer_from_intake",
args: {data: JSON.stringify(data)},
callback: function(r) {
if (r.message && r.message.status === "ok") {
currentCustomer = {name: r.message.customer, customer_name: data.customer_name};
$("#cust-status").text("Saved: " + r.message.customer);
frappe.show_alert("Customer saved", 3);
} else {
frappe.show_alert("Error saving customer", 5);
}
}
});
});
$("#btn-create-pallet").click(function() {
if (!currentCustomer || !currentCustomer.name) {
frappe.msgprint("Please select or save a customer first.");
return;
}
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.create_pallet",
args: {
data: JSON.stringify({
customer: currentCustomer.name,
customer_number: $("#cust-number").val(),
data_status: $("#pallet-data-status").val(),
status: $("#pallet-status").val(),
inbound_weight: $("#pallet-weight").val()
})
},
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.msgprint("Pallet created: " + r.message.pallet);
$("#cust-status").text("Pallet: " + r.message.pallet);
}
}
});
});
};
@@ -0,0 +1,84 @@
import frappe
from frappe import _
@frappe.whitelist()
def search_customers(q=""):
if not q or len(q) < 2:
return []
q = q.strip().lower()
customers = frappe.db.sql("""
SELECT c.name, c.customer_name, c.customer_number, c.mobile_no,
a.address_line1, a.city, a.state, a.pincode
FROM tabCustomer c
LEFT JOIN `tabDynamic Link` dl ON dl.link_doctype = 'Customer' AND dl.link_name = c.name AND dl.parenttype = 'Address'
LEFT JOIN tabAddress a ON a.name = dl.parent
WHERE LOWER(c.customer_name) LIKE %s OR LOWER(c.customer_number) LIKE %s OR LOWER(c.mobile_no) LIKE %s
ORDER BY c.customer_name
LIMIT 20
""", ("%" + q + "%", "%" + q + "%", "%" + q + "%"), as_dict=True)
return customers
@frappe.whitelist()
def get_customer(name):
if not name:
return {}
cust = frappe.get_doc("Customer", name)
result = cust.as_dict()
addr = frappe.db.sql("""
SELECT a.address_line1, a.city, a.state, a.pincode, a.phone
FROM tabAddress a
JOIN `tabDynamic Link` dl ON dl.parent = a.name AND dl.link_doctype = 'Customer' AND dl.link_name = %s
LIMIT 1
""", (name,), as_dict=True)
if addr:
result.update({
"address_line1": addr[0].address_line1,
"city": addr[0].city,
"state": addr[0].state,
"pincode": addr[0].pincode,
"address_phone": addr[0].phone
})
return result
@frappe.whitelist()
def create_customer_from_intake(data):
data = frappe.parse_json(data)
if not data.get("customer_name"):
frappe.throw(_("Customer name required"))
customer = frappe.new_doc("Customer")
customer.customer_name = data.get("customer_name")
customer.customer_group = data.get("customer_group", "IT Recycling")
customer.customer_type = "Company"
customer.customer_number = data.get("customer_number")
customer.mobile_no = data.get("phone")
customer.email_id = data.get("email_id")
customer.legacy_notes = data.get("legacy_notes")
customer.hours_of_operation = data.get("hours_of_operation")
customer.contact_persons = data.get("contact_persons")
customer.save()
if data.get("address_line1") or data.get("city"):
addr = frappe.new_doc("Address")
addr.address_title = customer.customer_name
addr.address_type = "Billing"
addr.address_line1 = data.get("address_line1", "Unknown")
addr.city = data.get("city", "Unknown")
addr.state = data.get("state", "")
addr.pincode = data.get("pincode", "")
addr.country = "United States"
addr.append("links", {"link_doctype": "Customer", "link_name": customer.name})
addr.save()
return {"status": "ok", "customer": customer.name}
@frappe.whitelist()
def create_pallet(data):
data = frappe.parse_json(data)
if not data.get("customer"):
frappe.throw(_("Customer required"))
pallet = frappe.new_doc("Pallet")
pallet.customer = data.get("customer")
pallet.customer_number = data.get("customer_number")
pallet.data_status = data.get("data_status", "D0")
pallet.status = data.get("status", "Received")
pallet.inbound_weight = data.get("inbound_weight", "")
pallet.save()
return {"status": "ok", "pallet": pallet.name}
@@ -0,0 +1 @@
# Customer Records page
@@ -0,0 +1 @@
{"content": null,"creation": "2026-05-20 22:00:00.000000","docstatus": 0,"doctype": "Page","idx": 0,"modified": "2026-05-20 22:00:00.000000","modified_by": "Administrator","module": "Westech R2","name": "customer-records","owner": "Administrator","page_name": "customer-records","roles": [{"doctype": "Has Role","idx": 1,"name": "a80mopj93i","parent": "customer-records","parentfield": "roles","parenttype": "Page","role": "All"}],"script": null,"standard": "Yes","style": null,"system_page": 0,"title": "Customer Records"}
@@ -0,0 +1,227 @@
<style>
.customer-records-page {
font-family: Segoe UI, Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: white;
}
.customer-records-page h1 {
font-size: 36px;
font-weight: 900;
text-align: center;
margin-bottom: 20px;
letter-spacing: 2px;
text-transform: uppercase;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.toolbar button {
padding: 8px 20px;
border: 2px solid #333;
background: #f0f0f0;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
border-radius: 3px;
}
.toolbar button:hover { background: #e0e0e0; }
.toolbar .record-counter {
font-size: 18px;
font-weight: 600;
margin: 0 10px;
}
.toolbar .nav-btn {
padding: 6px 14px;
font-size: 18px;
min-width: 40px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 40px;
margin-bottom: 20px;
}
.form-group {
display: flex;
align-items: center;
gap: 10px;
}
.form-group label {
font-weight: 600;
font-size: 13px;
min-width: 140px;
text-align: right;
white-space: nowrap;
}
.form-group input,
.form-group textarea {
flex: 1;
padding: 6px 10px;
border: 1px solid #ccc;
font-size: 13px;
background: #f8f8f8;
}
.form-group textarea {
min-height: 60px;
resize: vertical;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid #333;
}
.search-bar label {
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
}
.search-bar select,
.search-bar input {
padding: 6px 10px;
border: 1px solid #ccc;
font-size: 13px;
}
.search-bar .search-label {
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
margin: 0 10px;
}
.search-bar button {
padding: 6px 20px;
border: 2px solid #333;
background: #f0f0f0;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
}
.search-bar button:hover { background: #e0e0e0; }
</style>
<div class="customer-records-page">
<h1>Modify Records</h1>
<div class="toolbar">
<button id="btn-save">Save</button>
<span class="record-counter">
<span id="current-index">0</span> of <span id="total-count">0</span>
</span>
<button class="nav-btn" id="btn-prev">&lt;</button>
<button class="nav-btn" id="btn-next">&gt;</button>
<button class="nav-btn" id="btn-first">&lt;&lt;</button>
<button class="nav-btn" id="btn-last">&gt;&gt;</button>
<button id="btn-delete">Delete</button>
<button id="btn-print">Print</button>
</div>
<div class="form-grid">
<div class="form-group">
<label>Record #</label>
<input type="text" id="record-number" readonly>
</div>
<div class="form-group">
<label>Additional Numbers:</label>
<input type="text" id="additional-numbers">
</div>
<div class="form-group">
<label>*</label>
<input type="text" id="field-star">
</div>
<div class="form-group">
<label>Customer Address:</label>
<input type="text" id="customer-address">
</div>
<div class="form-group">
<label>Any Letter:</label>
<input type="text" id="any-letter">
</div>
<div class="form-group">
<label>City:</label>
<input type="text" id="city">
</div>
<div class="form-group">
<label>E</label>
<input type="text" id="field-e">
</div>
<div class="form-group">
<label>Zip:</label>
<input type="text" id="zip">
</div>
<div class="form-group">
<label>Company Name:</label>
<input type="text" id="company-name">
</div>
<div class="form-group">
<label>Contacted date:</label>
<input type="date" id="contacted-date">
</div>
<div class="form-group full-width">
<label>Contact Person(s):</label>
<textarea id="contact-persons"></textarea>
</div>
<div class="form-group">
<label>Follow up date:</label>
<input type="text" id="follow-up-date">
</div>
<div class="form-group">
<label>E-Mail Address:</label>
<input type="email" id="email-address">
</div>
<div class="form-group">
<label>Last P/U date:</label>
<input type="text" id="last-pu-date">
</div>
<div class="form-group full-width">
<label>Contact Numbers:</label>
<textarea id="contact-numbers"></textarea>
</div>
<div class="form-group full-width">
<label>Hours of Operation:</label>
<input type="text" id="hours-operation">
</div>
<div class="form-group full-width">
<label>Comments:</label>
<textarea id="comments"></textarea>
</div>
</div>
<div class="search-bar">
<label>SEARCH:</label>
<select id="search-field">
<option value="address">Address</option>
<option value="company_name">Company Name</option>
<option value="contact_person">Contact Person</option>
<option value="phone">Phone</option>
<option value="email">Email</option>
<option value="city">City</option>
<option value="zip">Zip</option>
<option value="record_number">Record #</option>
</select>
<span class="search-label">FOR</span>
<input type="text" id="search-value" style="min-width:200px;">
<button id="btn-search">Search!</button>
<button id="btn-reset">Reset</button>
</div>
</div>
@@ -0,0 +1,148 @@
frappe.pages["customer-records"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Customer Records"),
single_column: true
});
var content = frappe.render_template("customer-records", {});
$(page.body).append(content);
var records = [];
var currentIdx = -1;
var searchResults = [];
function loadRecord(idx) {
if (idx < 0 || idx >= records.length) return;
currentIdx = idx;
var r = records[idx];
$("#current-index").text(idx + 1);
$("#total-count").text(records.length);
$("#record-number").val(r.name || "");
$("#additional-numbers").val(r.additional_numbers || "");
$("#field-star").val(r.field_star || "");
$("#customer-address").val(r.customer_address || "");
$("#any-letter").val(r.any_letter || "");
$("#city").val(r.city || "");
$("#field-e").val(r.field_e || "");
$("#zip").val(r.zip || "");
$("#company-name").val(r.company_name || "");
$("#contacted-date").val(r.contacted_date || "");
$("#contact-persons").val(r.contact_persons || "");
$("#follow-up-date").val(r.follow_up_date || "");
$("#email-address").val(r.email_address || "");
$("#last-pu-date").val(r.last_pu_date || "");
$("#contact-numbers").val(r.contact_numbers || "");
$("#hours-operation").val(r.hours_operation || "");
$("#comments").val(r.comments || "");
}
function fetchRecords() {
frappe.call({
method: "westech_r2.page.customer-records.customer-records.get_records",
callback: function(r) {
if (r.message) {
records = r.message;
if (records.length > 0) loadRecord(0);
else $("#current-index").text(0);
$("#total-count").text(records.length);
}
}
});
}
function saveRecord() {
if (currentIdx < 0) return;
var data = {
name: $("#record-number").val(),
additional_numbers: $("#additional-numbers").val(),
field_star: $("#field-star").val(),
customer_address: $("#customer-address").val(),
any_letter: $("#any-letter").val(),
city: $("#city").val(),
field_e: $("#field-e").val(),
zip: $("#zip").val(),
company_name: $("#company-name").val(),
contacted_date: $("#contacted-date").val(),
contact_persons: $("#contact-persons").val(),
follow_up_date: $("#follow-up-date").val(),
email_address: $("#email-address").val(),
last_pu_date: $("#last-pu-date").val(),
contact_numbers: $("#contact-numbers").val(),
hours_operation: $("#hours-operation").val(),
comments: $("#comments").val()
};
frappe.call({
method: "westech_r2.page.customer-records.customer-records.save_record",
args: { data: JSON.stringify(data) },
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.show_alert("Saved successfully");
records[currentIdx] = data;
}
}
});
}
function deleteRecord() {
if (currentIdx < 0) return;
frappe.confirm("Delete record #" + $("#record-number").val() + "?", function() {
frappe.call({
method: "westech_r2.page.customer-records.customer-records.delete_record",
args: { name: $("#record-number").val() },
callback: function(r) {
if (r.message && r.message.status === "ok") {
records.splice(currentIdx, 1);
if (records.length > 0) {
currentIdx = Math.min(currentIdx, records.length - 1);
loadRecord(currentIdx);
} else {
$("#current-index").text(0);
$("#total-count").text(0);
$("input, textarea").val("");
}
frappe.show_alert("Deleted");
}
}
});
});
}
function searchRecords() {
var field = $("#search-field").val();
var value = $("#search-value").val().toLowerCase();
if (!value) {
searchResults = [];
fetchRecords();
return;
}
frappe.call({
method: "westech_r2.page.customer-records.customer-records.search_records",
args: { field: field, value: value },
callback: function(r) {
if (r.message) {
searchResults = r.message;
records = searchResults;
if (records.length > 0) loadRecord(0);
else {
$("#current-index").text(0);
$("#total-count").text(records.length);
$("input, textarea").val("");
}
}
}
});
}
$("#btn-save").click(saveRecord);
$("#btn-delete").click(deleteRecord);
$("#btn-prev").click(function() { if (currentIdx > 0) loadRecord(currentIdx - 1); });
$("#btn-next").click(function() { if (currentIdx < records.length - 1) loadRecord(currentIdx + 1); });
$("#btn-first").click(function() { if (records.length > 0) loadRecord(0); });
$("#btn-last").click(function() { if (records.length > 0) loadRecord(records.length - 1); });
$("#btn-search").click(searchRecords);
$("#btn-reset").click(function() { $("#search-value").val(""); searchResults = []; fetchRecords(); });
$("#btn-print").click(function() { window.print(); });
fetchRecords();
};
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-20 15:03:29.017530",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-20 15:03:29.017530",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "customer-records",
"owner": "Administrator",
"page_name": "customer-records",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Customer Records"
}
@@ -0,0 +1,85 @@
import frappe
@frappe.whitelist()
def get_records():
# For now return Lead records mapped to form fields
leads = frappe.db.sql("""
SELECT name, company_name, email_id, mobile_no, phone, address_line1, city, state, pincode,
title, status, industry, source
FROM tabLead
ORDER BY creation DESC
LIMIT 1000
""", as_dict=True)
result = []
for l in leads:
result.append({
"name": l.name,
"additional_numbers": "",
"field_star": l.status or "",
"customer_address": l.address_line1 or "",
"any_letter": l.title or "",
"city": l.city or "",
"field_e": l.email_id or "",
"zip": l.pincode or "",
"company_name": l.company_name or "",
"contacted_date": "",
"contact_persons": l.title or "",
"follow_up_date": "",
"email_address": l.email_id or "",
"last_pu_date": "",
"contact_numbers": (l.phone or "") + "\n" + (l.mobile_no or ""),
"hours_operation": l.industry or "",
"comments": l.source or ""
})
return result
@frappe.whitelist()
def save_record(data):
data = frappe.parse_json(data)
# For now just return OK - Lead update can be wired later
return {"status": "ok", "message": "Saved " + (data.get("name") or "")}
@frappe.whitelist()
def delete_record(name):
# For now return OK
return {"status": "ok", "message": "Deleted " + name}
@frappe.whitelist()
def search_records(field, value):
leads = frappe.db.sql("""
SELECT name, company_name, email_id, mobile_no, phone, address_line1, city, state, pincode,
title, status, industry, source
FROM tabLead
WHERE LOWER(company_name) LIKE %s
OR LOWER(title) LIKE %s
OR LOWER(address_line1) LIKE %s
OR LOWER(city) LIKE %s
OR LOWER(pincode) LIKE %s
OR LOWER(email_id) LIKE %s
OR LOWER(phone) LIKE %s
OR LOWER(mobile_no) LIKE %s
ORDER BY creation DESC
LIMIT 100
""", tuple(["%" + value + "%"] * 8), as_dict=True)
result = []
for l in leads:
result.append({
"name": l.name,
"additional_numbers": "",
"field_star": l.status or "",
"customer_address": l.address_line1 or "",
"any_letter": l.title or "",
"city": l.city or "",
"field_e": l.email_id or "",
"zip": l.pincode or "",
"company_name": l.company_name or "",
"contacted_date": "",
"contact_persons": l.title or "",
"follow_up_date": "",
"email_address": l.email_id or "",
"last_pu_date": "",
"contact_numbers": (l.phone or "") + "\n" + (l.mobile_no or ""),
"hours_operation": l.industry or "",
"comments": l.source or ""
})
return result
@@ -1,6 +1,3 @@
{% extends "templates/web.html" %}
{% block style %}
<link rel="stylesheet" href="/assets/westech_r2/css/ebay-pricing.css">
<style>
.badge.badge-fresh { background-color: #28a745; }
.badge.badge-aging { background-color: #ffc107; color: #212529; }
@@ -8,7 +5,4 @@
.badge.badge-needs { background-color: #fd7e14; }
.badge.badge-error { background-color: #6c757d; }
</style>
{% endblock %}
{% block page_content %}
<div id="ebay-pricing-page"></div>
{% endblock %}
@@ -0,0 +1,5 @@
.badge-fresh { background-color: #28a745; }
.badge-aging { background-color: #ffc107; color: #212529; }
.badge-expired { background-color: #dc3545; }
.badge-needs { background-color: #fd7e14; }
.badge-error { background-color: #6c757d; }
@@ -0,0 +1,8 @@
<style>
.badge.badge-fresh { background-color: #28a745; }
.badge.badge-aging { background-color: #ffc107; color: #212529; }
.badge.badge-expired { background-color: #dc3545; }
.badge.badge-needs { background-color: #fd7e14; }
.badge.badge-error { background-color: #6c757d; }
</style>
<div id="ebay-pricing-page"></div>
@@ -0,0 +1 @@
ebay-pricing.js
@@ -0,0 +1 @@
ebay-pricing.json
@@ -0,0 +1 @@
# eBay Pricing desk page
@@ -0,0 +1 @@
@@ -0,0 +1,4 @@
frappe.pages["eim-portal"].on_page_load = function(wrapper) {
wrapper.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:60vh;font-family:sans-serif;"><div style="text-align:center;"><i class="fa fa-spinner fa-spin" style="font-size:24px;color:#2d7d46;"></i><p style="margin-top:12px;color:#555;">Redirecting to EIM Device Portal...</p></div></div>';
setTimeout(function() { window.location.href = "https://eim.diagalon.com"; }, 500);
};
@@ -0,0 +1,13 @@
{
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 14:00:00",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "eim-portal",
"owner": "Administrator",
"standard": "Yes",
"title": "EIM Device Portal"
}
@@ -0,0 +1,5 @@
import frappe
def get_context(context):
frappe.local.flags.redirect_location = "https://eim.diagalon.com"
raise frappe.Redirect
@@ -0,0 +1,7 @@
frappe.pages['eim-portal'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'EIM Device Portal',
single_column: true
});
}
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 15:09:48.653878",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "eim-portal",
"owner": "Administrator",
"page_name": "eim-portal",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "EIM Device Portal"
}
@@ -0,0 +1,11 @@
.intake-station .card { border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; }
.intake-station .card-header { padding: 15px; }
.intake-station .card-body { padding: 20px; }
.intake-station .form-group { margin-bottom: 15px; }
.intake-station .form-control { border-radius: 4px; padding: 8px 12px; font-size: 16px; }
.intake-station .form-control:focus { border-color: #6f42c1; box-shadow: 0 0 0 0.2rem rgba(111,66,193,0.25); }
.intake-station label { font-weight: 600; margin-bottom: 4px; }
.intake-station h5 { margin-bottom: 15px; padding-bottom: 8px; border-bottom: 2px solid #e0e0e0; }
.intake-station .table th { background: #f8f9fa; }
.intake-station .btn-primary { background: linear-gradient(135deg, #6f42c1, #28a745) !important; border: none !important; }
.intake-station .label { font-size: 0.85em; }
@@ -0,0 +1,580 @@
frappe.pages['intake'].on_page_load = function(wrapper) {
try {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'Customer Management',
single_column: true
});
page.set_primary_action('New Customer', function() {
show_intake_form();
}, 'add');
page.add_inner_button('Refresh', function() {
load_customer_list();
});
$(wrapper).find('.layout-main-section').html(`
<div class="intake-station" style="padding: 20px;">
<div id="intake-form-container">
<div class="card" style="margin-bottom: 20px;">
<div class="card-header" style="background: linear-gradient(135deg, #6f42c1, #28a745); color: white; padding: 15px;">
<h4 style="margin:0; color: white;">Customer Management</h4>
</div>
<div class="card-body" style="padding: 20px;">
<form id="intake-form">
<div class="row">
<div class="col-md-4">
<h5 style="color:#6f42c1;">Customer</h5>
<div class="form-group">
<label>Customer <span class="text-danger">*</span></label>
<div id="customer-number-control"></div>
</div>
<div class="form-group">
<label>Company Name</label>
<input type="text" id="company_name" class="form-control" readonly style="background:#f8f9fa;">
</div>
<div class="form-group">
<label>Driver</label>
<div id="driver-control"></div>
</div>
<div class="form-group">
<label>Contact Name</label>
<input type="text" id="contact_name" class="form-control">
</div>
<div class="form-group">
<label>Contact #</label>
<input type="tel" id="contact_number" class="form-control">
</div>
<div class="form-group">
<label>Contact Email</label>
<input type="email" id="contact_email" class="form-control">
</div>
<div class="form-group">
<label>Address</label>
<input type="text" id="address_line" class="form-control">
</div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">Dates & Source</h5>
<div class="form-group">
<label>Received Date <span class="text-danger">*</span></label>
<input type="date" id="received_date" class="form-control" required>
</div>
<div class="form-group">
<label>Weekday</label>
<input type="text" id="weekday" class="form-control" readonly style="background:#f8f9fa;">
</div>
<div class="form-group">
<label>Pickup / Drop-off</label>
<select id="pickup" class="form-control">
<option value="">—</option>
<option value="Pickup">Pickup</option>
<option value="Drop-off">Drop-off</option>
</select>
</div>
<div class="form-group">
<label>Hours of Operation</label>
<input type="text" id="hours_of_operation" class="form-control" readonly style="background:#f8f9fa;" placeholder="Auto-filled from Customer">
</div>
<div class="form-group">
<label>Data Status</label>
<select id="data_status" class="form-control">
<option value="">—</option>
<option value="D0">D0 (Unknown)</option>
<option value="D1">D1 (Contains Data)</option>
</select>
</div>
<div class="form-group">
<label>RED / R2</label>
<select id="red_r2" class="form-control">
<option value="">—</option>
<option value="RED">RED</option>
<option value="R2">R2</option>
<option value="Both">Both</option>
<option value="Clear">Clear</option>
</select>
</div>
<div class="form-group">
<label>Notes</label>
<textarea id="notes" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Legacy Notes</label>
<textarea id="legacy_notes" class="form-control" rows="2" readonly style="background:#fafafa;" title="Auto-filled from Customer record"></textarea>
</div>
</div>
<div class="col-md-4" id="load-info-section" style="display:none;">
<h5 style="color:#6f42c1;">Items & Weight</h5>
<div class="form-group">
<label>Barcode</label>
<input type="text" id="barcode" class="form-control" placeholder="Scan barcode...">
</div>
<div class="form-group">
<label>Total Items</label>
<input type="number" id="total_items" class="form-control" value="0" min="0">
</div>
<div class="form-group">
<label>Number of Labels</label>
<input type="number" id="num_labels" class="form-control" value="1" min="1" max="20">
</div>
<hr>
<div class="form-group">
<label>Weight <span class="text-danger">*</span></label>
<input type="text" id="weights" class="form-control" placeholder="e.g. 340 lbs" required>
</div>
<div class="form-group">
<label>Invoice / Check Request</label>
<input type="text" id="invoice_check_request" class="form-control">
</div>
<div class="form-group">
<label>Amount</label>
<input type="number" id="amount" class="form-control" step="0.01" value="0">
</div>
<div class="form-group">
<label>Paid / Received</label>
<select id="paid_received" class="form-control">
<option value="">—</option>
<option value="Paid">Paid</option>
<option value="Received">Received</option>
<option value="Pending">Pending</option>
</select>
</div>
</div>
</div>
<div class="row" style="margin-top: 20px;">
<div class="col-md-12">
<button type="submit" class="btn btn-primary btn-lg" style="background: linear-gradient(135deg, #6f42c1, #28a745); border: none;">
Save Contact Info
</button>
<button type="button" class="btn btn-default btn-lg" id="btn-print-labels" disabled>
Print Labels
</button>
<button type="button" class="btn btn-default btn-lg" id="btn-generate-cor" disabled>
CoR/AoR
</button>
<button type="button" class="btn btn-default btn-lg" id="btn-cancel">
Cancel
</button>
<span id="save-status" class="ml-3" style="font-size: 1.2em;"></span>
</div>
</div>
</form>
</div>
</div>
</div>
<div id="recent-pallets" style="display:none;">
<div class="card">
<div class="card-header" style="background: #f8f9fa; display: flex; justify-content: space-between; align-items: center;">
<h5 style="margin:0;">Customers</h5>
<input type="text" id="customer-search" class="form-control" placeholder="Search..." style="width: 300px;">
</div>
<div class="card-body" style="padding: 0;">
<table class="table table-striped table-hover" id="customer-table">
<thead>
<tr>
<th>Company</th>
<th>Contact</th>
<th>Phone</th>
<th>Address</th>
<th></th>
</tr>
</thead>
<tbody id="customer-tbody">
<tr><td colspan="5" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
`);
setup_link_controls();
set_today_date();
// Trigger weekday calculation after date field is set
setTimeout(function() {
var dateVal = $('#received_date').val();
if (dateVal) {
var d = new Date(dateVal + 'T12:00:00');
var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
$('#weekday').val(days[d.getDay()] || '');
}
}, 200);
// Show/hide load info based on pickup dropdown
.on('change', function() {
var val = .val();
if (val) {
.show();
} else {
.hide();
}
});
// Initial state - hide if blank
.trigger('change');
load_customer_list();
$('#received_date').on('change', function() {
var d = new Date($(this).val() + 'T12:00:00');
var days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
$('#weekday').val(days[d.getDay()] || '');
});
$('#intake-form').on('submit', function(e) {
e.preventDefault();
save_customer();
});
$('#btn-cancel').on('click', function() {
show_customer_list();
});
$('#btn-print-labels').on('click', function() {
frappe.msgprint('Label printing coming soon.');
});
$('#btn-generate-cor').on('click', function() {
generate_cor_report();
});
$('#customer-search').on('input', function() {
var q = $(this).val().toLowerCase();
$('#customer-tbody tr').each(function() {
var text = $(this).text().toLowerCase();
$(this).toggle(text.indexOf(q) > -1);
});
});
} catch(e) {
console.error('[INTAKE] FATAL:', e.message, e.stack);
$(wrapper).find('.layout-main-section').html(
'<div style="padding:20px;"><h3>Error Loading Page</h3><pre>' + e.message + '</pre></div>'
);
}
};
// ── Link Controls ──────────────────────────────────────────────
var customer_number_control = null;
var driver_control = null;
var selected_customer_name = null;
function setup_link_controls() {
customer_number_control = frappe.ui.form.make_control({
parent: $('#customer-number-control'),
df: {
fieldtype: 'Link',
fieldname: 'customer_number',
options: 'Customer',
label: 'Customer',
reqd: 1,
placeholder: 'Search customer...',
onchange: function() {
var val = customer_number_control.get_value();
if (val) {
selected_customer_name = val;
fetch_customer_details(val);
} else {
selected_customer_name = null;
clear_customer_fields();
}
}
},
only_input: true,
});
customer_number_control.refresh();
$('#customer-number-control .control-input').css('margin', '0');
$('#customer-number-control .help-box').remove();
driver_control = frappe.ui.form.make_control({
parent: $('#driver-control'),
df: {
fieldtype: 'Link',
fieldname: 'driver',
options: 'Employee',
label: 'Driver',
placeholder: 'Search driver...',
onchange: function() {}
},
only_input: true,
});
driver_control.refresh();
$('#driver-control .control-input').css('margin', '0');
$('#driver-control .help-box').remove();
}
function fetch_customer_details(customer_name) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Customer', name: customer_name},
callback: function(r) {
if (r.message) {
var c = r.message;
$('#company_name').val(c.customer_name || '');
$('#contact_name').val(c.contact_persons || '');
$('#hours_of_operation').val(c.hours_of_operation || '');
$('#legacy_notes').val(c.legacy_notes || '');
// Parse phone and email from contact_persons if not in dedicated fields
var phone = c.mobile_no || '';
var email = c.email_id || '';
var addressLine = '';
// Extract phone from contact_persons text blob
if (!phone && c.contact_persons) {
var phoneMatch = c.contact_persons.match(/\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/);
if (phoneMatch) phone = phoneMatch[0];
}
// Extract email from contact_persons text blob
if (!email && c.contact_persons) {
var emailMatch = c.contact_persons.match(/[\w.+-]+@[\w.-]+\.\w+/);
if (emailMatch) email = emailMatch[0];
}
$('#contact_number').val(phone);
$('#contact_email').val(email);
// Enable CoR button once we have a customer with data
$('#btn-generate-cor').prop('disabled', false);
// Get address — always fetch from Address record for full street+city+state+zip
if (c.customer_primary_address) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Address', name: c.customer_primary_address},
callback: function(r3) {
if (r3.message) {
var addr = r3.message;
var line = (addr.address_line1 || '') + (addr.address_line2 ? ', ' + addr.address_line2 : '');
var full = line + (addr.city ? ', ' + addr.city : '') + (addr.state ? ', ' + addr.state : '') + (addr.pincode ? ' ' + addr.pincode : '');
$('#address_line').val(full);
}
}
});
} else if (c.primary_address) {
// Fallback: parse text blob (less reliable)
var lines = c.primary_address.split('\n');
addressLine = lines.join(', ');
$('#address_line').val(addressLine);
}
// If still missing phone/email, try linked Contact
if ((!phone || !email) && c.customer_primary_contact) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Contact', name: c.customer_primary_contact},
callback: function(r2) {
if (r2.message) {
var ct = r2.message;
if (!phone) $('#contact_number').val(ct.phone || ct.mobile_no || '');
if (!email) $('#contact_email').val(ct.email_id || '');
}
}
});
}
}
}
});
}
function clear_customer_fields() {
$('#company_name').val('');
$('#contact_name').val('');
$('#contact_number').val('');
$('#contact_email').val('');
$('#address_line').val('');
$('#hours_of_operation').val('');
}
function clear_form() {
if (customer_number_control) customer_number_control.set_value('');
if (driver_control) driver_control.set_value('');
$('#company_name').val('');
$('#received_date').val('');
$('#weekday').val('');
$('#pickup').val('');
$('#data_status').val('');
$('#red_r2').val('');
$('#barcode').val('');
$('#total_items').val(0);
$('#num_labels').val(1);
$('#contact_name').val('');
$('#contact_number').val('');
$('#contact_email').val('');
$('#address_line').val('');
$('#hours_of_operation').val('');
$('#legacy_notes').val('');
$('#weights').val('');
$('#invoice_check_request').val('');
$('#amount').val(0);
$('#paid_received').val('');
$('#notes').val('');
$('#btn-print-labels').prop('disabled', true);
$('#btn-generate-cor').prop('disabled', true);
$('#save-status').html('');
}
function set_today_date() {
var today = new Date().toISOString().split('T')[0];
$('#received_date').val(today);
$('#received_date').trigger('change');
}
function show_intake_form() {
$('#intake-form-container').show();
$('#recent-pallets').hide();
}
function show_customer_list() {
$('#intake-form-container').hide();
$('#recent-pallets').show();
clear_form();
load_customer_list();
}
function load_customer_list() {
frappe.call({
method: 'frappe.client.get_list',
args: {
doctype: 'Customer',
fields: ['name', 'customer_name', 'customer_number', 'mobile_no', 'email_id', 'contact_persons', 'primary_address', 'customer_primary_address'],
limit_page_length: 50,
order_by: 'customer_name asc'
},
callback: function(r) {
var tbody = $('#customer-tbody');
tbody.empty();
if (!r.message || r.message.length === 0) {
tbody.append('<tr><td colspan="5" class="text-center">No customers found.</td></tr>');
return;
}
r.message.forEach(function(c) {
var contactName = '';
if (c.contact_persons) {
var parts = c.contact_persons.split('|');
if (parts.length > 0) contactName = parts[0].trim();
}
tbody.append(
'<tr style="cursor:pointer;" onclick="select_customer_from_list(\'' + c.name.replace(/'/g, "\\'") + '\')">' +
'<td><strong>' + (c.customer_name || c.name) + '</strong>' + (c.customer_number ? ' <span class="text-muted">(#' + c.customer_number + ')</span>' : '') + '</td>' +
'<td>' + contactName + '</td>' +
'<td>' + (c.mobile_no || '') + '</td>' +
'<td>' + (c.primary_address ? c.primary_address.replace(/\n/g, ', ') : '') + '</td>' +
'<td><button class="btn btn-xs btn-default"><i class="fa fa-arrow-right"></i></button></td>' +
'</tr>'
);
});
}
});
}
function select_customer_from_list(customer_name) {
if (customer_number_control) customer_number_control.set_value(customer_name);
fetch_customer_details(customer_name);
}
function edit_pallet(name) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Pallet', name: name},
callback: function(r) {
if (r.message) {
show_intake_form();
var d = r.message;
$('#intake-form-container').data('pallet-name', name);
$('#received_date').val(d.received_date || '');
if (customer_number_control) customer_number_control.set_value(d.customer_number || '');
if (driver_control) driver_control.set_value(d.driver || '');
$('#company_name').val(d.company_name || '');
$('#pickup').val(d.pickup || '');
$('#data_status').val(d.data_status || '');
$('#red_r2').val(d.red_r2 || '');
$('#barcode').val(d.barcode || '');
$('#total_items').val(d.total_items || 0);
$('#num_labels').val(d.num_labels || 1);
$('#contact_name').val(d.contact_name || '');
$('#contact_number').val(d.contact_number || '');
$('#contact_email').val(d.contact_email || '');
$('#address_line').val(d.address_line || '');
$('#hours_of_operation').val(d.hours_of_operation || '');
$('#legacy_notes').val(d.legacy_notes || '');
$('#weights').val(d.weights || '');
$('#invoice_check_request').val(d.invoice_check_request || '');
$('#amount').val(d.amount || 0);
$('#paid_received').val(d.paid_received || '');
$('#notes').val(d.notes || '');
$('#received_date').trigger('change');
$('#btn-print-labels').prop('disabled', false);
$('#btn-generate-cor').prop('disabled', false);
}
}
});
}
function save_customer() {
var customer_name = customer_number_control ? customer_number_control.get_value() : null;
if (!customer_name) {
frappe.msgprint("Please select a customer first.");
return;
}
frappe.call({
method: "frappe.client.get",
args: { doctype: "Customer", name: customer_name },
callback: function(r) {
if (r.message) {
var doc = r.message;
doc.contact_persons = .val();
doc.mobile_no = .val();
doc.email_id = .val();
doc.hours_of_operation = .val();
doc.legacy_notes = .val();
frappe.call({
method: "frappe.client.save",
args: { doc: doc },
callback: function(r2) {
if (r2.message) {
frappe.msgprint("Customer updated!");
.html("<span style=\"color:green;\">Saved!</span>");
}
}
});
}
}
});
}
function generate_cor_report() {
var companyName = $('#company_name').val();
if (!companyName) {
frappe.msgprint('Please select a customer first.');
return;
}
var args = {
company_name: companyName,
weights: $('#weights').val() || '',
received_date: $('#received_date').val() || '',
red_r2: $('#red_r2').val() || '',
contact_name: $('#contact_name').val() || '',
contact_number: $('#contact_number').val() || '',
address_line: $('#address_line').val() || '',
pallet_name: $('#intake-form-container').data('pallet-name') || ''
};
window.open('/api/method/westech_r2.api.cor_generator.generate_cor?'
+ '&company_name=' + encodeURIComponent(args.company_name)
+ '&weights=' + encodeURIComponent(args.weights)
+ '&received_date=' + encodeURIComponent(args.received_date)
+ '&red_r2=' + encodeURIComponent(args.red_r2)
+ '&contact_name=' + encodeURIComponent(args.contact_name)
+ '&contact_number=' + encodeURIComponent(args.contact_number)
+ '&address_line=' + encodeURIComponent(args.address_line)
+ '&pallet_name=' + encodeURIComponent(args.pallet_name)
);
}
window.edit_pallet = edit_pallet;
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-09 12:05:32.403207",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-23 01:31:28.579759",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "intake",
"owner": "Administrator",
"page_name": "intake",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Customer Management"
}
@@ -0,0 +1,5 @@
from frappe import _
def get_context(context):
context.no_cache = 1
context.title = _("Intake Station")
@@ -0,0 +1 @@
/* CSS */
@@ -0,0 +1,53 @@
<div class="ld-container" style="padding: 20px;">
<style>
.ld-header { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
.ld-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 13px; }
.ld-table th { background: #37474f; color: white; padding: 8px; text-align: left; }
.ld-table td { padding: 6px 8px; border-bottom: 1px solid #e0e0e0; }
.ld-table .num { text-align: right; font-family: monospace; }
.ld-btn { background: #455a64; color: white; border: none; padding: 8px 20px; border-radius: 4px; cursor: pointer; margin-bottom: 20px; margin-right: 10px; }
.ld-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; margin-right: 4px; }
.bg-rcv { background: #e3f2fd; color: #1565c0; }
.bg-hdr { background: #fff3e0; color: #e65100; }
.bg-tst { background: #e8f5e9; color: #2e7d32; }
.bg-r2 { background: #f3e5f5; color: #7b1fa2; }
.bg-des { background: #ffebee; color: #c62828; }
</style>
<div class="ld-header">
<h2>Load: <span id="ld-name"></span></h2>
<div>In Date: <span id="ld-date"></span> | Customer: <span id="ld-cust"></span> | Devices: <span id="ld-dev"></span> | Weight: <span id="ld-wt"></span> lbs</div>
</div>
<div style="margin-bottom: 15px;">
<span class="ld-badge bg-rcv">Receiving</span>
<span class="ld-badge bg-hdr">HDR / Disassembly</span>
<span class="ld-badge bg-tst">Test</span>
<span class="ld-badge bg-r2">R2 Downstream</span>
<span class="ld-badge bg-des">Destruction</span>
</div>
<button class="ld-btn" onclick="window.print()">Print Data Tracking Worksheet</button>
<button class="ld-btn" style="background: #1976d2;" onclick="window.location.href='/app/load-list'">Back to Loads</button>
<table class="ld-table" id="ld-table">
<thead>
<tr>
<th rowspan="2">Material Type</th>
<th colspan="3" style="text-align:center; background:#1565c0;">Receiving</th>
<th colspan="3" style="text-align:center; background:#e65100;">HDR / Disassembly</th>
<th colspan="3" style="text-align:center; background:#2e7d32;">Test</th>
<th style="text-align:center; background:#7b1fa2;">R2</th>
<th colspan="4" style="text-align:center; background:#c62828;">Destruction</th>
</tr>
<tr>
<th>Count</th><th>Status</th><th>Send To</th>
<th>Recv'd</th><th>HDD Out</th><th>Send To</th>
<th>Sanitized</th><th>Status</th><th>Send To</th>
<th>Sent</th>
<th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>
</tr>
</thead>
<tbody id="ld-body"></tbody>
</table>
</div>
@@ -0,0 +1,65 @@
frappe.pages["load-detail"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: "Load Detail",
single_column: true
});
var loadName = frappe.utils.get_url_arg("load");
if (!loadName) {
$(page.body).html("<div style='padding:40px;text-align:center;'><h2>No load specified</h2><p>Use ?load=MMDDYYYY-XXXX</p></div>");
return;
}
frappe.call({
method: "frappe.client.get",
args: { doctype: "Load", name: loadName },
callback: function(r) {
if (r.message) { showLoad(page, r.message); }
else { $(page.body).html("<div style='padding:40px;text-align:center;'><h2>Load not found</h2></div>"); }
}
});
function showLoad(page, load) {
var h = "<div style='padding:20px;'>";
h += "<div style='background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px;'>";
h += "<h2>Load: " + load.name + "</h2>";
h += "<div>In Date: " + (load.incoming_date || "N/A") + " | Customer: " + (load.customer_name || load.customer_number || "N/A") + "</div>";
h += "<div>Devices: " + (load.total_devices || 0) + " | Weight: " + (load.total_weight || 0) + " lbs</div>";
h += "</div>";
h += "<button class='btn btn-primary' onclick='window.print()' style='margin-bottom:15px;'>Print Data Tracking Worksheet</button> ";
h += "<a href='/app/load/" + encodeURIComponent(load.name) + "' class='btn btn-default' style='margin-bottom:15px;'>Open Form View</a> ";
h += "<a href='/app/load-update?load=" + encodeURIComponent(load.name) + "' class='btn btn-default' style='margin-bottom:15px;'>Edit Load</a>";
h += "<table style='width:100%;border-collapse:collapse;font-size:13px;margin-top:20px;'>";
h += "<thead><tr style='background:#37474f;color:white;'>";
h += "<th>Material Type</th><th>Count</th><th>Rcv Status</th><th>Send To</th>";
h += "<th>Recv'd</th><th>HDD Out</th><th>Dis Send To</th>";
h += "<th>Sanitized</th><th>Test Status</th><th>Test Send</th>";
h += "<th>R2 Sent</th><th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>";
h += "</tr></thead><tbody>";
if (load.material_items && load.material_items.length > 0) {
load.material_items.forEach(function(item) {
h += "<tr style='border-bottom:1px solid #e0e0e0;'>";
h += "<td><strong>" + (item.material_type || "") + "</strong></td>";
h += "<td style='text-align:right;'>" + (item.total_count || 0) + "</td>";
h += "<td>" + (item.initial_data_status || "") + "</td>";
h += "<td>" + (item.send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.devices_received || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_removed || 0) + "</td>";
h += "<td>" + (item.disassembly_send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.units_sanitized_software || 0) + "</td>";
h += "<td>" + (item.test_data_status || "") + "</td>";
h += "<td>" + (item.test_send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.r2_units_sent || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.units_physical_destruction || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_needs_sanitize || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_physical_destruction || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_logical_sanitization || 0) + "</td>";
h += "</tr>";
});
}
h += "</tbody></table></div>";
$(page.body).html(h);
}
};
@@ -0,0 +1,18 @@
{
"doctype": "Page",
"name": "load-detail",
"page_name": "load-detail",
"title": "Load Detail",
"page_type": "Web Page",
"module": "Westech R2",
"standard": "Yes",
"system_page": 0,
"roles": [
{
"role": "All"
}
],
"content": "",
"script": null,
"style": null
}
@@ -0,0 +1,3 @@
import frappe
no_cache = 1
@@ -0,0 +1 @@
/* CSS */
@@ -0,0 +1,53 @@
<div class="ld-container" style="padding: 20px;">
<style>
.ld-header { background: #f8f9fa; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
.ld-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 13px; }
.ld-table th { background: #37474f; color: white; padding: 8px; text-align: left; }
.ld-table td { padding: 6px 8px; border-bottom: 1px solid #e0e0e0; }
.ld-table .num { text-align: right; font-family: monospace; }
.ld-btn { background: #455a64; color: white; border: none; padding: 8px 20px; border-radius: 4px; cursor: pointer; margin-bottom: 20px; margin-right: 10px; }
.ld-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; margin-right: 4px; }
.bg-rcv { background: #e3f2fd; color: #1565c0; }
.bg-hdr { background: #fff3e0; color: #e65100; }
.bg-tst { background: #e8f5e9; color: #2e7d32; }
.bg-r2 { background: #f3e5f5; color: #7b1fa2; }
.bg-des { background: #ffebee; color: #c62828; }
</style>
<div class="ld-header">
<h2>Load: <span id="ld-name"></span></h2>
<div>In Date: <span id="ld-date"></span> | Customer: <span id="ld-cust"></span> | Devices: <span id="ld-dev"></span> | Weight: <span id="ld-wt"></span> lbs</div>
</div>
<div style="margin-bottom: 15px;">
<span class="ld-badge bg-rcv">Receiving</span>
<span class="ld-badge bg-hdr">HDR / Disassembly</span>
<span class="ld-badge bg-tst">Test</span>
<span class="ld-badge bg-r2">R2 Downstream</span>
<span class="ld-badge bg-des">Destruction</span>
</div>
<button class="ld-btn" onclick="window.print()">Print Data Tracking Worksheet</button>
<button class="ld-btn" style="background: #1976d2;" onclick="window.location.href='/app/load-list'">Back to Loads</button>
<table class="ld-table" id="ld-table">
<thead>
<tr>
<th rowspan="2">Material Type</th>
<th colspan="3" style="text-align:center; background:#1565c0;">Receiving</th>
<th colspan="3" style="text-align:center; background:#e65100;">HDR / Disassembly</th>
<th colspan="3" style="text-align:center; background:#2e7d32;">Test</th>
<th style="text-align:center; background:#7b1fa2;">R2</th>
<th colspan="4" style="text-align:center; background:#c62828;">Destruction</th>
</tr>
<tr>
<th>Count</th><th>Status</th><th>Send To</th>
<th>Recv'd</th><th>HDD Out</th><th>Send To</th>
<th>Sanitized</th><th>Status</th><th>Send To</th>
<th>Sent</th>
<th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>
</tr>
</thead>
<tbody id="ld-body"></tbody>
</table>
</div>
@@ -0,0 +1,65 @@
frappe.pages["load-detail"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: "Load Detail",
single_column: true
});
var loadName = frappe.utils.get_url_arg("load");
if (!loadName) {
$(page.body).html("<div style='padding:40px;text-align:center;'><h2>No load specified</h2><p>Use ?load=MMDDYYYY-XXXX</p></div>");
return;
}
frappe.call({
method: "frappe.client.get",
args: { doctype: "Load", name: loadName },
callback: function(r) {
if (r.message) { showLoad(page, r.message); }
else { $(page.body).html("<div style='padding:40px;text-align:center;'><h2>Load not found</h2></div>"); }
}
});
function showLoad(page, load) {
var h = "<div style='padding:20px;'>";
h += "<div style='background:#f8f9fa;padding:15px;border-radius:8px;margin-bottom:20px;'>";
h += "<h2>Load: " + load.name + "</h2>";
h += "<div>In Date: " + (load.incoming_date || "N/A") + " | Customer: " + (load.customer_name || load.customer_number || "N/A") + "</div>";
h += "<div>Devices: " + (load.total_devices || 0) + " | Weight: " + (load.total_weight || 0) + " lbs</div>";
h += "</div>";
h += "<button class='btn btn-primary' onclick='window.print()' style='margin-bottom:15px;'>Print Data Tracking Worksheet</button> ";
h += "<a href='/app/load/" + encodeURIComponent(load.name) + "' class='btn btn-default' style='margin-bottom:15px;'>Open Form View</a> ";
h += "<a href='/app/load-update?load=" + encodeURIComponent(load.name) + "' class='btn btn-default' style='margin-bottom:15px;'>Edit Load</a>";
h += "<table style='width:100%;border-collapse:collapse;font-size:13px;margin-top:20px;'>";
h += "<thead><tr style='background:#37474f;color:white;'>";
h += "<th>Material Type</th><th>Count</th><th>Rcv Status</th><th>Send To</th>";
h += "<th>Recv'd</th><th>HDD Out</th><th>Dis Send To</th>";
h += "<th>Sanitized</th><th>Test Status</th><th>Test Send</th>";
h += "<th>R2 Sent</th><th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>";
h += "</tr></thead><tbody>";
if (load.material_items && load.material_items.length > 0) {
load.material_items.forEach(function(item) {
h += "<tr style='border-bottom:1px solid #e0e0e0;'>";
h += "<td><strong>" + (item.material_type || "") + "</strong></td>";
h += "<td style='text-align:right;'>" + (item.total_count || 0) + "</td>";
h += "<td>" + (item.initial_data_status || "") + "</td>";
h += "<td>" + (item.send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.devices_received || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_removed || 0) + "</td>";
h += "<td>" + (item.disassembly_send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.units_sanitized_software || 0) + "</td>";
h += "<td>" + (item.test_data_status || "") + "</td>";
h += "<td>" + (item.test_send_to || "") + "</td>";
h += "<td style='text-align:right;'>" + (item.r2_units_sent || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.units_physical_destruction || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_needs_sanitize || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_physical_destruction || 0) + "</td>";
h += "<td style='text-align:right;'>" + (item.hdd_logical_sanitization || 0) + "</td>";
h += "</tr>";
});
}
h += "</tbody></table></div>";
$(page.body).html(h);
}
};
@@ -0,0 +1,18 @@
{
"doctype": "Page",
"name": "load-detail",
"page_name": "load-detail",
"title": "Load Detail",
"page_type": "Web Page",
"module": "Westech R2",
"standard": "Yes",
"system_page": 0,
"roles": [
{
"role": "All"
}
],
"content": "",
"script": null,
"style": null
}
@@ -0,0 +1,3 @@
import frappe
no_cache = 1
@@ -0,0 +1 @@
/* Load Update page CSS */
@@ -0,0 +1,4 @@
<div style="padding:20px;">
<h2>Load Update</h2>
<p>Use the form below to update load material items.</p>
</div>
@@ -0,0 +1,109 @@
frappe.pages["load-update"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: "Load Update",
single_column: true
});
var loadName = frappe.utils.get_url_arg("load");
if (!loadName) {
$(page.body).html("<div style='padding:40px;text-align:center;'><h2>No load specified</h2><p>Use ?load=MMDDYYYY-XXXX</p></div>");
return;
}
function loadLoad() {
frappe.call({
method: "frappe.client.get",
args: { doctype: "Load", name: loadName },
callback: function(r) {
if (r.message) { renderForm(page, r.message); }
else { $(page.body).html("<div style='padding:40px;text-align:center;'><h2>Load not found</h2></div>"); }
}
});
}
function renderForm(page, load) {
var h = "<div style='padding:20px;'>";
h += "<h2>Update Load: " + load.name + "</h2>";
h += "<p>Date: " + (load.incoming_date || "N/A") + " | Customer: " + (load.customer_name || load.customer_number || "N/A") + "</p>";
h += "<div style='margin-bottom:15px;'>";
h += "<button id='lu-save' class='btn btn-primary'>Save Changes</button> ";
h += "<button id='lu-print' class='btn btn-default'>Print Worksheet</button> ";
h += "<a href='/app/load/" + encodeURIComponent(load.name) + "' class='btn btn-default'>Open Form View</a>";
h += "</div>";
h += "<table style='width:100%;border-collapse:collapse;font-size:13px;'>";
h += "<thead><tr style='background:#37474f;color:white;'>";
h += "<th>Material Type</th><th>Count</th><th>Recv Status</th><th>Send To</th>";
h += "<th>Recv'd</th><th>HDD Out</th><th>Dis Send To</th>";
h += "<th>Sanitized</th><th>Test Status</th><th>Test Send</th>";
h += "<th>R2 Sent</th><th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>";
h += "</tr></thead><tbody>";
if (load.material_items && load.material_items.length > 0) {
load.material_items.forEach(function(item, idx) {
var prefix = "item_" + idx + "_";
h += "<tr style='border-bottom:1px solid #e0e0e0;'>";
h += "<td><strong>" + (item.material_type || "") + "</strong></td>";
h += "<td><input type='number' id='" + prefix + "total_count' value='" + (item.total_count || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "initial_data_status' value='" + (item.initial_data_status || "") + "' style='width:60px;'></td>";
h += "<td><input type='text' id='" + prefix + "send_to' value='" + (item.send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "devices_received' value='" + (item.devices_received || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_removed' value='" + (item.hdd_removed || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "disassembly_send_to' value='" + (item.disassembly_send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "units_sanitized_software' value='" + (item.units_sanitized_software || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "test_data_status' value='" + (item.test_data_status || "") + "' style='width:60px;'></td>";
h += "<td><input type='text' id='" + prefix + "test_send_to' value='" + (item.test_send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "r2_units_sent' value='" + (item.r2_units_sent || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "units_physical_destruction' value='" + (item.units_physical_destruction || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_needs_sanitize' value='" + (item.hdd_needs_sanitize || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_physical_destruction' value='" + (item.hdd_physical_destruction || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_logical_sanitization' value='" + (item.hdd_logical_sanitization || 0) + "' style='width:50px;'></td>";
h += "</tr>";
});
}
h += "</tbody></table></div>";
$(page.body).html(h);
$("#lu-save").on("click", function() {
var items = [];
load.material_items.forEach(function(item, idx) {
var prefix = "#item_" + idx + "_";
items.push({
name: item.name,
total_count: parseInt($(prefix + "total_count").val()) || 0,
initial_data_status: $(prefix + "initial_data_status").val(),
send_to: $(prefix + "send_to").val(),
devices_received: parseInt($(prefix + "devices_received").val()) || 0,
hdd_removed: parseInt($(prefix + "hdd_removed").val()) || 0,
disassembly_send_to: $(prefix + "disassembly_send_to").val(),
units_sanitized_software: parseInt($(prefix + "units_sanitized_software").val()) || 0,
test_data_status: $(prefix + "test_data_status").val(),
test_send_to: $(prefix + "test_send_to").val(),
r2_units_sent: parseInt($(prefix + "r2_units_sent").val()) || 0,
units_physical_destruction: parseInt($(prefix + "units_physical_destruction").val()) || 0,
hdd_needs_sanitize: parseInt($(prefix + "hdd_needs_sanitize").val()) || 0,
hdd_physical_destruction: parseInt($(prefix + "hdd_physical_destruction").val()) || 0,
hdd_logical_sanitization: parseInt($(prefix + "hdd_logical_sanitization").val()) || 0
});
});
frappe.call({
method: "westech_r2.page.load-update.load-update.save_load_items",
args: { load_name: loadName, items: JSON.stringify(items) },
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.show_alert({message: "Saved", indicator: "green"});
loadLoad();
} else {
frappe.show_alert({message: "Save failed", indicator: "red"});
}
}
});
});
$("#lu-print").on("click", function() {
window.print();
});
}
loadLoad();
};
@@ -0,0 +1,18 @@
{
"doctype": "Page",
"name": "load-update",
"page_name": "load-update",
"title": "Load Update",
"page_type": "Web Page",
"module": "Westech R2",
"standard": "Yes",
"system_page": 0,
"roles": [
{
"role": "All"
}
],
"content": "",
"script": null,
"style": null
}
@@ -0,0 +1,17 @@
import frappe
import json
@frappe.whitelist()
def save_load_items(load_name, items):
items = json.loads(items)
load_doc = frappe.get_doc("Load", load_name)
for item_data in items:
for row in load_doc.material_items:
if row.name == item_data["name"]:
for field, value in item_data.items():
if field != "name":
row.set(field, value)
break
load_doc.save(ignore_permissions=True)
frappe.db.commit()
return {"status": "ok", "message": "Saved " + str(len(items)) + " items"}
@@ -0,0 +1 @@
/* Load Update page CSS */
@@ -0,0 +1,4 @@
<div style="padding:20px;">
<h2>Load Update</h2>
<p>Use the form below to update load material items.</p>
</div>
@@ -0,0 +1,109 @@
frappe.pages["load-update"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: "Load Update",
single_column: true
});
var loadName = frappe.utils.get_url_arg("load");
if (!loadName) {
$(page.body).html("<div style='padding:40px;text-align:center;'><h2>No load specified</h2><p>Use ?load=MMDDYYYY-XXXX</p></div>");
return;
}
function loadLoad() {
frappe.call({
method: "frappe.client.get",
args: { doctype: "Load", name: loadName },
callback: function(r) {
if (r.message) { renderForm(page, r.message); }
else { $(page.body).html("<div style='padding:40px;text-align:center;'><h2>Load not found</h2></div>"); }
}
});
}
function renderForm(page, load) {
var h = "<div style='padding:20px;'>";
h += "<h2>Update Load: " + load.name + "</h2>";
h += "<p>Date: " + (load.incoming_date || "N/A") + " | Customer: " + (load.customer_name || load.customer_number || "N/A") + "</p>";
h += "<div style='margin-bottom:15px;'>";
h += "<button id='lu-save' class='btn btn-primary'>Save Changes</button> ";
h += "<button id='lu-print' class='btn btn-default'>Print Worksheet</button> ";
h += "<a href='/app/load/" + encodeURIComponent(load.name) + "' class='btn btn-default'>Open Form View</a>";
h += "</div>";
h += "<table style='width:100%;border-collapse:collapse;font-size:13px;'>";
h += "<thead><tr style='background:#37474f;color:white;'>";
h += "<th>Material Type</th><th>Count</th><th>Recv Status</th><th>Send To</th>";
h += "<th>Recv'd</th><th>HDD Out</th><th>Dis Send To</th>";
h += "<th>Sanitized</th><th>Test Status</th><th>Test Send</th>";
h += "<th>R2 Sent</th><th>Phys</th><th>HDD Need</th><th>HDD Phys</th><th>HDD Logic</th>";
h += "</tr></thead><tbody>";
if (load.material_items && load.material_items.length > 0) {
load.material_items.forEach(function(item, idx) {
var prefix = "item_" + idx + "_";
h += "<tr style='border-bottom:1px solid #e0e0e0;'>";
h += "<td><strong>" + (item.material_type || "") + "</strong></td>";
h += "<td><input type='number' id='" + prefix + "total_count' value='" + (item.total_count || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "initial_data_status' value='" + (item.initial_data_status || "") + "' style='width:60px;'></td>";
h += "<td><input type='text' id='" + prefix + "send_to' value='" + (item.send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "devices_received' value='" + (item.devices_received || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_removed' value='" + (item.hdd_removed || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "disassembly_send_to' value='" + (item.disassembly_send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "units_sanitized_software' value='" + (item.units_sanitized_software || 0) + "' style='width:50px;'></td>";
h += "<td><input type='text' id='" + prefix + "test_data_status' value='" + (item.test_data_status || "") + "' style='width:60px;'></td>";
h += "<td><input type='text' id='" + prefix + "test_send_to' value='" + (item.test_send_to || "") + "' style='width:80px;'></td>";
h += "<td><input type='number' id='" + prefix + "r2_units_sent' value='" + (item.r2_units_sent || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "units_physical_destruction' value='" + (item.units_physical_destruction || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_needs_sanitize' value='" + (item.hdd_needs_sanitize || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_physical_destruction' value='" + (item.hdd_physical_destruction || 0) + "' style='width:50px;'></td>";
h += "<td><input type='number' id='" + prefix + "hdd_logical_sanitization' value='" + (item.hdd_logical_sanitization || 0) + "' style='width:50px;'></td>";
h += "</tr>";
});
}
h += "</tbody></table></div>";
$(page.body).html(h);
$("#lu-save").on("click", function() {
var items = [];
load.material_items.forEach(function(item, idx) {
var prefix = "#item_" + idx + "_";
items.push({
name: item.name,
total_count: parseInt($(prefix + "total_count").val()) || 0,
initial_data_status: $(prefix + "initial_data_status").val(),
send_to: $(prefix + "send_to").val(),
devices_received: parseInt($(prefix + "devices_received").val()) || 0,
hdd_removed: parseInt($(prefix + "hdd_removed").val()) || 0,
disassembly_send_to: $(prefix + "disassembly_send_to").val(),
units_sanitized_software: parseInt($(prefix + "units_sanitized_software").val()) || 0,
test_data_status: $(prefix + "test_data_status").val(),
test_send_to: $(prefix + "test_send_to").val(),
r2_units_sent: parseInt($(prefix + "r2_units_sent").val()) || 0,
units_physical_destruction: parseInt($(prefix + "units_physical_destruction").val()) || 0,
hdd_needs_sanitize: parseInt($(prefix + "hdd_needs_sanitize").val()) || 0,
hdd_physical_destruction: parseInt($(prefix + "hdd_physical_destruction").val()) || 0,
hdd_logical_sanitization: parseInt($(prefix + "hdd_logical_sanitization").val()) || 0
});
});
frappe.call({
method: "westech_r2.page.load-update.load-update.save_load_items",
args: { load_name: loadName, items: JSON.stringify(items) },
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.show_alert({message: "Saved", indicator: "green"});
loadLoad();
} else {
frappe.show_alert({message: "Save failed", indicator: "red"});
}
}
});
});
$("#lu-print").on("click", function() {
window.print();
});
}
loadLoad();
};
@@ -0,0 +1,18 @@
{
"doctype": "Page",
"name": "load-update",
"page_name": "load-update",
"title": "Load Update",
"page_type": "Web Page",
"module": "Westech R2",
"standard": "Yes",
"system_page": 0,
"roles": [
{
"role": "All"
}
],
"content": "",
"script": null,
"style": null
}
@@ -0,0 +1,17 @@
import frappe
import json
@frappe.whitelist()
def save_load_items(load_name, items):
items = json.loads(items)
load_doc = frappe.get_doc("Load", load_name)
for item_data in items:
for row in load_doc.material_items:
if row.name == item_data["name"]:
for field, value in item_data.items():
if field != "name":
row.set(field, value)
break
load_doc.save(ignore_permissions=True)
frappe.db.commit()
return {"status": "ok", "message": "Saved " + str(len(items)) + " items"}
@@ -0,0 +1 @@
# Pallet List page for Westech R2
@@ -0,0 +1,82 @@
<style>
.pallet-list-page { font-family: Helvetica Neue, Arial, sans-serif; }
.pallet-list-page h2 { color: #6f42c1; margin-bottom: 16px; font-size: 20px; }
.pallet-search-box { margin-bottom: 16px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.pallet-search-box input, .pallet-search-box select {
padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px;
}
.pallet-search-box button {
padding: 6px 14px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;
font-size: 13px; background: white;
}
.pallet-search-box button.btn-primary { background: #6f42c1; color: white; border-color: #6f42c1; }
.pallet-search-box button.btn-success { background: #28a745; color: white; border-color: #28a745; }
.pallet-table-container { padding: 0; overflow-x: auto; background: white; border-radius: 4px; }
.pallet-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.pallet-table th {
background: #6f42c1; color: white; padding: 8px 10px; text-align: left;
cursor: pointer; white-space: nowrap; font-weight: 500;
}
.pallet-table td { padding: 5px 10px; border-bottom: 1px solid #eee; }
.pallet-table tr:hover { background: #f5f5f5; }
.pallet-table tr.new-row { background: #FFF9E6 !important; }
.pallet-table a { color: #6f42c1; text-decoration: none; font-weight: 600; }
.status-received { color: #2196F3; font-weight: 600; }
.status-sorting { color: #FF9800; font-weight: 600; }
.status-processing { color: #9C27B0; font-weight: 600; }
.status-complete { color: #4CAF50; font-weight: 600; }
.status-shipped { color: #607D8B; font-weight: 600; }
.pallet-pagination { margin-top: 12px; display: flex; gap: 5px; align-items: center; }
.pallet-pagination button {
padding: 5px 12px; border: 1px solid #ddd; background: white; cursor: pointer;
border-radius: 4px; font-size: 13px; color: #6f42c1;
}
.pallet-pagination button.active { background: #6f42c1; color: white; border-color: #6f42c1; }
.pallet-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.pallet-count { margin-left: auto; color: #666; font-size: 13px; }
</style>
<div class="pallet-list-page">
<h2>📦 Pallet List</h2>
<div class="pallet-search-box">
<input type="text" id="pallet-search" placeholder="Search pallet #..." style="max-width:200px;">
<select id="status-filter">
<option value="">All Statuses</option>
<option value="Received">Received</option>
<option value="Sorting">Sorting</option>
<option value="Processing">Processing</option>
<option value="Complete">Complete</option>
<option value="Shipped">Shipped</option>
</select>
<button class="btn btn-default" id="btn-clear">✕ Clear</button>
<button class="btn btn-success" id="btn-export">⬇ Export CSV</button>
<span class="pallet-count" id="pallet-count"></span>
</div>
<div class="pallet-table-container">
<table class="pallet-table" id="pallet-table">
<thead>
<tr>
<th data-sort="pallet_number">Pallet # ⇅</th>
<th data-sort="date_reserved">Date Reserved ⇅</th>
<th data-sort="received_date">Rec. Date ⇅</th>
<th data-sort="customer_number">Customer # ⇅</th>
<th data-sort="inbound_weight">Lbs ⇅</th>
<th data-sort="tester">Who ⇅</th>
<th data-sort="description">Items Tested ⇅</th>
<th data-sort="qty_to_sales">QTY Sale ⇅</th>
<th data-sort="weight_to_sales">Lbs Sale ⇅</th>
<th data-sort="finish_date">Finish Date ⇅</th>
<th>Status</th>
<th>Notes</th>
</tr>
</thead>
<tbody id="pallet-tbody">
<tr><td colspan="12" style="text-align:center;padding:40px;color:#666;">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="pallet-pagination" id="pallet-pagination"></div>
</div>
@@ -0,0 +1,128 @@
frappe.pages["pallet-list"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Pallet List"),
single_column: true
});
var content = frappe.render_template("pallet-list", {});
$(page.body).append(content);
var currentPage = 1;
var pageSize = 100;
var currentSort = "pallet_number";
var sortDir = "desc";
var searchTerm = "";
var statusFilter = "";
function loadPallets() {
frappe.call({
method: "westech_r2.page.pallet_list.pallet_list.get_pallets",
args: {
page: currentPage,
page_size: pageSize,
sort_field: currentSort,
sort_dir: sortDir,
status_filter: statusFilter,
search: searchTerm
},
callback: function(r) {
if (r.message) {
renderPallets(r.message.pallets, r.message.total);
}
}
});
}
function renderPallets(pallets, total) {
var tbody = $("#pallet-tbody");
tbody.empty();
if (!pallets || pallets.length === 0) {
tbody.append('<tr><td colspan="13" style="text-align:center;padding:40px;">No pallets found</td></tr>');
return;
}
pallets.forEach(function(p) {
var statusClass = "status-" + (p.status || "").toLowerCase().replace(/\s+/g, "-");
var link = "/app/pallet/" + encodeURIComponent(p.name);
var pn = (p.pallet_number || "").replace(/</g, "&lt;").replace(/>/g, "&gt;");
var row = '<tr>' +
'<td><a href="' + link + '" style="color:#3cc062;text-decoration:none;font-weight:600;">' + pn + '</a></td>' +
'<td>' + fmtDate(p.date_reserved) + '</td>' +
'<td>' + fmtDate(p.received_date) + '</td>' +
'<td>' + (p.customer_number || "") + '</td>' +
'<td>' + (p.company_name || "") + '</td>' +
'<td>' + (p.inbound_weight || "") + '</td>' +
'<td>' + (p.tester || "") + '</td>' +
'<td>' + (p.description || "") + '</td>' +
'<td>' + (p.qty_to_sales || "") + '</td>' +
'<td>' + (p.weight_to_sales || "") + '</td>' +
'<td>' + fmtDate(p.finish_date) + '</td>' +
'<td class="' + statusClass + '">' + (p.status || "") + '</td>' +
'<td>' + (p.notes || "").substring(0, 50) + '</td>' +
'</tr>';
tbody.append(row);
});
renderPagination(total);
}
function renderPagination(total) {
var totalPages = Math.ceil(total / pageSize);
var pagination = $("#pallet-pagination");
pagination.empty();
if (totalPages <= 1) return;
var prevBtn = $('<button>&laquo; Prev</button>').attr("disabled", currentPage === 1)
.css({padding: "5px 12px", border: "1px solid #ddd", background: "white", cursor: "pointer", marginRight: "5px"})
.on("click", function() {
if (currentPage > 1) { currentPage--; loadPallets(); }
});
pagination.append(prevBtn);
var startPage = Math.max(1, currentPage - 2);
var endPage = Math.min(totalPages, startPage + 4);
for (var i = startPage; i <= endPage; i++) {
var btn = $('<button>' + i + '</button>')
.css({padding: "5px 12px", border: "1px solid #ddd", background: i === currentPage ? "#3cc062" : "white",
color: i === currentPage ? "white" : "#333", cursor: "pointer", marginRight: "5px"})
.on("click", function(page) {
return function() { currentPage = page; loadPallets(); };
}(i));
pagination.append(btn);
}
var nextBtn = $('<button>Next &raquo;</button>').attr("disabled", currentPage === totalPages)
.css({padding: "5px 12px", border: "1px solid #ddd", background: "white", cursor: "pointer", marginRight: "5px"})
.on("click", function() {
if (currentPage < totalPages) { currentPage++; loadPallets(); }
});
pagination.append(nextBtn);
}
function fmtDate(v) {
if (!v) return "";
var s = String(v);
if (s.indexOf("T") > -1) s = s.split("T")[0];
if (s.indexOf(" ") > -1) s = s.split(" ")[0];
return s;
}
$("#pallet-search").on("input", function() {
searchTerm = $(this).val();
currentPage = 1;
loadPallets();
});
$("#status-filter").on("change", function() {
statusFilter = $(this).val();
currentPage = 1;
loadPallets();
});
loadPallets();
};
@@ -0,0 +1,19 @@
{
"content": null,
"creation": "2026-05-19 13:00:00.000000",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-19 13:00:00.000000",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "pallet-list",
"owner": "Administrator",
"page_name": "pallet-list",
"roles": [],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Pallet List"
}
@@ -0,0 +1,64 @@
import frappe
@frappe.whitelist()
def get_pallets(page=1, page_size=100, sort_field="pallet_number", sort_dir="desc", status_filter="", search=""):
page = int(page)
page_size = int(page_size)
offset = (page - 1) * page_size
conditions = [
"pallet_number IS NOT NULL",
"pallet_number != ''",
"date_reserved IS NOT NULL",
"customer_number IS NOT NULL",
"customer_number != ''"
]
junk = ["", "0", "0000", "N/A", "TBD", "null", "999990", "999995"]
junk_list = "', '".join(junk)
conditions.append("pallet_number NOT IN ('" + junk_list + "')")
conditions.append("pallet_number NOT LIKE '999%'")
conditions.append("pallet_number REGEXP '^[0-9]'")
if status_filter:
conditions.append("status = '" + frappe.db.escape(status_filter) + "'")
if search:
conditions.append("pallet_number LIKE '%" + frappe.db.escape(search) + "%'")
where_clause = " AND ".join(conditions)
total = frappe.db.sql("SELECT COUNT(*) FROM tabPallet WHERE " + where_clause)[0][0]
pallets = frappe.db.sql("SELECT name, pallet_number, date_reserved, received_date, customer_number, company_name, inbound_weight, tester, description, qty_to_sales, weight_to_sales, finish_date, notes, status FROM tabPallet WHERE " + where_clause + " ORDER BY CAST(pallet_number AS UNSIGNED) " + sort_dir + " LIMIT " + str(page_size) + " OFFSET " + str(offset), as_dict=True)
empty_pallet = frappe.db.sql("SELECT name, pallet_number, date_reserved, received_date, customer_number, company_name, inbound_weight, tester, description, qty_to_sales, weight_to_sales, finish_date, notes, status FROM tabPallet WHERE date_reserved IS NULL AND (customer_number IS NULL OR customer_number = '') AND pallet_number NOT IN ('" + junk_list + "') AND pallet_number NOT LIKE '999%' AND pallet_number REGEXP '^[0-9]' ORDER BY CAST(pallet_number AS UNSIGNED) DESC LIMIT 1", as_dict=True)
result = []
if empty_pallet:
empty_pallet[0]["_is_new"] = True
result.append(empty_pallet[0])
result.extend(pallets)
return {"pallets": result, "total": total, "page": page, "page_size": page_size}
@frappe.whitelist()
def update_pallet(docname, field, value):
pallet = frappe.get_doc("Pallet", docname)
pallet.set(field, value)
pallet.save(ignore_permissions=True)
frappe.db.commit()
return {"status": "ok", "message": "Updated " + field}
@frappe.whitelist()
def create_pallet(data):
data = frappe.parse_json(data)
pallet = frappe.new_doc("Pallet")
pallet.pallet_number = data.get("pallet_number")
pallet.status = data.get("status", "Received")
pallet.date_reserved = data.get("date_reserved")
for field, value in data.items():
if field not in ["pallet_number", "status", "date_reserved"] and value:
pallet.set(field, value)
pallet.insert(ignore_permissions=True)
frappe.db.commit()
return {"status": "ok", "name": pallet.name}
@@ -0,0 +1 @@
@@ -0,0 +1,4 @@
frappe.pages["r2-tracking"].on_page_load = function(wrapper) {
wrapper.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:60vh;font-family:sans-serif;"><div style="text-align:center;"><i class="fa fa-spinner fa-spin" style="font-size:24px;color:#1a6b8a;"></i><p style="margin-top:12px;color:#555;">Redirecting to R2 Data Tracking...</p></div></div>';
setTimeout(function() { window.location.href = "https://eim.diagalon.com/report/data-tracking-form"; }, 500);
};
@@ -0,0 +1,13 @@
{
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 14:00:00",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "r2-tracking",
"owner": "Administrator",
"standard": "Yes",
"title": "R2 Data Tracking"
}

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