fix: intake page updates, customer_intake fixes, module dir

This commit is contained in:
vagrant
2026-05-25 14:44:40 +00:00
parent 0997de940e
commit d4ed4b1d89
153 changed files with 38708 additions and 68 deletions
+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()
+85
View File
@@ -0,0 +1,85 @@
import frappe
frappe.init(site="erpnext.local")
frappe.connect()
try:
# Get Westech workspace
ws = frappe.get_doc("Workspace", "Westech")
# Create HTML block with template links
html_block = '''
<div style="border: 2px solid #6f42c1; border-radius: 8px; padding: 15px; margin: 10px 0;">
<h4 style="margin-top: 0; color: #6f42c1;">📋 Print Format Templates</h4>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">
<a href="/app/print-format/special-handling-log" target="_blank"
style="display: block; padding: 10px; background: #f0f0f0; border-radius: 5px; text-decoration: none; color: #333;">
<strong>🟣 Purple Sheet</strong><br>
<small>Special Handling Log</small>
</a>
<a href="/app/print-format/r2-special-handling-log" target="_blank"
style="display: block; padding: 10px; background: #e8f5e9; border-radius: 5px; text-decoration: none; color: #333;">
<strong>🟢 Green Sheet</strong><br>
<small>R2 Special Handling Log</small>
</a>
<a href="/app/print-format/pallet-label-4x6" target="_blank"
style="display: block; padding: 10px; background: #fff3cd; border-radius: 5px; text-decoration: none; color: #333;">
<strong>🏷️ Pallet Label</strong><br>
<small>4x6" with Service Level</small>
</a>
<a href="/app/print-format/r2-uw-label" target="_blank"
style="display: block; padding: 10px; background: #e3f2fd; border-radius: 5px; text-decoration: none; color: #333;">
<strong>📦 R2 UW Label</strong><br>
<small>Controlled Stream</small>
</a>
</div>
</div>
'''
# Parse existing content
import json
content = json.loads(ws.content) if ws.content else []
# Check if template links section already exists
existing_idx = None
for i, block in enumerate(content):
if block.get('type') == 'custom_block' and 'Print Format Templates' in block.get('data', {}).get('html', ''):
existing_idx = i
break
if existing_idx is not None:
# Update existing block
content[existing_idx]['data']['html'] = html_block
print(f"✓ Updated existing template links block")
else:
# Add new block after header
template_block = {
"type": "custom_block",
"data": {
"html": html_block
}
}
# Insert after first header block
insert_idx = 1
for i, block in enumerate(content):
if block.get('type') == 'header':
insert_idx = i + 1
break
content.insert(insert_idx, template_block)
print(f"✓ Added new template links block")
# Update workspace
ws.content = json.dumps(content)
# Remove broken shortcuts before saving
if hasattr(ws, 'shortcuts'):
ws.shortcuts = [s for s in ws.shortcuts if frappe.db.exists(s.doctype, s.link_to)]
ws.save(ignore_permissions=True)
frappe.db.commit()
print(f"✓ Workspace 'Westech' updated successfully")
print(f"✓ View at: https://erp.diagalon.com/app/westech")
finally:
frappe.destroy()
+75
View File
@@ -0,0 +1,75 @@
import frappe
import json
frappe.init(site="erpnext.local")
frappe.connect()
try:
# Get Westech workspace
ws = frappe.get_doc("Workspace", "Westech")
# Create HTML block with template links
html_block = '''
<div style="border: 2px solid #6f42c1; border-radius: 8px; padding: 15px; margin: 10px 0; background: #fff;">
<h4 style="margin-top: 0; color: #6f42c1;">📋 Print Format Templates</h4>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">
<div style="padding: 10px; background: #f0f0f0; border-radius: 5px;">
<strong>🟣 Purple Sheet</strong><br>
<small>Special Handling Log</small><br>
<code style="background: #ddd; padding: 2px 5px; border-radius: 3px;">special-handling-log</code>
</div>
<div style="padding: 10px; background: #e8f5e9; border-radius: 5px;">
<strong>🟢 Green Sheet</strong><br>
<small>R2 Special Handling Log</small><br>
<code style="background: #ddd; padding: 2px 5px; border-radius: 3px;">r2-special-handling-log</code>
</div>
<div style="padding: 10px; background: #fff3cd; border-radius: 5px;">
<strong>🏷️ Pallet Label</strong><br>
<small>4x6" with Service Level</small><br>
<code style="background: #ddd; padding: 2px 5px; border-radius: 3px;">pallet-label-4x6</code>
</div>
<div style="padding: 10px; background: #e3f2fd; border-radius: 5px;">
<strong>📦 R2 UW Label</strong><br>
<small>Controlled Stream</small><br>
<code style="background: #ddd; padding: 2px 5px; border-radius: 3px;">r2-uw-label</code>
</div>
</div>
<div style="margin-top: 10px; padding: 10px; background: #fff3cd; border-left: 3px solid #ffc107; font-size: 9pt;">
⚠️ <strong>Note:</strong> These are HTML templates. To use them, create Print Formats in ERPNext with these names and paste the HTML content from <code>/home/puddintaim/.openclaw/workspace/westech-erp/templates/</code>
</div>
</div>
'''
# Parse existing content
content = json.loads(ws.content) if ws.content else []
# Add new block at the top (after first header)
template_block = {
"type": "custom_block",
"data": {
"html": html_block
}
}
# Find first header and insert after it
insert_idx = 0
for i, block in enumerate(content):
if block.get('type') == 'header':
insert_idx = i + 1
break
content.insert(insert_idx, template_block)
# Update workspace content only (don't touch shortcuts)
ws.set("content", json.dumps(content))
ws.flags.ignore_links = True
ws.save(ignore_permissions=True, ignore_links=True)
frappe.db.commit()
print(f"✓ Workspace 'Westech' updated successfully")
print(f"✓ View at: https://erp.diagalon.com/app/westech")
print(f"\nTemplate files location:")
print(f" /home/puddintaim/.openclaw/workspace/westech-erp/templates/")
finally:
frappe.destroy()
+71
View File
@@ -0,0 +1,71 @@
import frappe
import json
frappe.init(site="erpnext.local")
frappe.connect()
try:
# Get current workspace content
current_content = frappe.db.get_value("Workspace", "Westech", "content")
content = json.loads(current_content) if current_content else []
# Create HTML block with template links
html_block = '''
<div style="border: 2px solid #6f42c1; border-radius: 8px; padding: 15px; margin: 10px 0; background: #fff;">
<h4 style="margin-top: 0; color: #6f42c1;">📋 Print Format Templates</h4>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;">
<div style="padding: 10px; background: #f0f0f0; border-radius: 5px;">
<strong>🟣 Purple Sheet</strong><br>
<small>Special Handling Log</small><br>
<code style="background: #ddd; padding: 2px 5px; border-radius: 3px;">special-handling-log</code>
</div>
<div style="padding: 10px; background: #e8f5e9; border-radius: 5px;">
<strong>🟢 Green Sheet</strong><br>
<small>R2 Special Handling Log</small><br>
<code style="background: #ddd; padding: 2px 5px; border-radius: 3px;">r2-special-handling-log</code>
</div>
<div style="padding: 10px; background: #fff3cd; border-radius: 5px;">
<strong>🏷️ Pallet Label</strong><br>
<small>4x6" with Service Level</small><br>
<code style="background: #ddd; padding: 2px 5px; border-radius: 3px;">pallet-label-4x6</code>
</div>
<div style="padding: 10px; background: #e3f2fd; border-radius: 5px;">
<strong>📦 R2 UW Label</strong><br>
<small>Controlled Stream</small><br>
<code style="background: #ddd; padding: 2px 5px; border-radius: 3px;">r2-uw-label</code>
</div>
</div>
<div style="margin-top: 10px; padding: 10px; background: #fff3cd; border-left: 3px solid #ffc107; font-size: 9pt;">
⚠️ <strong>Note:</strong> These are HTML templates. To use them, create Print Formats in ERPNext with these names and paste the HTML content from <code>/home/puddintaim/.openclaw/workspace/westech-erp/templates/</code>
</div>
</div>
'''
# Add new block at the top (after first header)
template_block = {
"type": "custom_block",
"data": {
"html": html_block
}
}
# Find first header and insert after it
insert_idx = 0
for i, block in enumerate(content):
if block.get('type') == 'header':
insert_idx = i + 1
break
content.insert(insert_idx, template_block)
# Update content directly via db.set_value (bypasses validation)
frappe.db.set_value("Workspace", "Westech", "content", json.dumps(content))
frappe.db.commit()
print(f"✓ Workspace 'Westech' updated successfully")
print(f"✓ View at: https://erp.diagalon.com/app/westech")
print(f"\nTemplate files location:")
print(f" /home/puddintaim/.openclaw/workspace/westech-erp/templates/")
finally:
frappe.destroy()
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
import frappe
import os
frappe.init(site="erpnext.local")
frappe.connect()
labels_dir = "/tmp/labels"
labels = [
{"name": "R2 UW Label GA FL AZ", "doctype": "Pallet", "file": "8.1.1-F R2 UW Label GA FL AZ v2.0 - draft.pdf"},
{"name": "Resale Label", "doctype": "Pallet", "file": "8.1.1-F Resale Label Issue 10.0.pdf"},
{"name": "Arizona Processing Label", "doctype": "Pallet", "file": "8.1.1-F Arizona Processing Label 1.0.pdf"},
{"name": "Unrestricted Streams", "doctype": "Pallet", "file": "4.4.6.3-F Unrestricted Streams Issue 2.0.pdf"},
{"name": "Unacceptable Items", "doctype": "Pallet", "file": "8.1.1-F Unacceptible Items 1.0 Fill.pdf"},
]
for label in labels:
try:
# Check if Print Format already exists
existing = frappe.db.exists("Print Format", label["name"])
if existing:
print(f"✓ Print Format '{label['name']}' already exists")
continue
# Read PDF file
pdf_path = os.path.join(labels_dir, label["file"])
if not os.path.exists(pdf_path):
print(f"✗ File not found: {pdf_path}")
continue
with open(pdf_path, "rb") as f:
pdf_content = f.read()
# Create Print Format
pf = frappe.get_doc({
"doctype": "Print Format",
"name": label["name"],
"standard": "No",
"custom_format": 1,
"print_format_type": "PDF",
"doc_type": label["doctype"],
"resource": pdf_content,
"default_print_language": "en",
})
pf.insert()
frappe.db.commit()
print(f"✓ Created Print Format: {label['name']}")
except Exception as e:
print(f"✗ Error importing {label['name']}: {e}")
frappe.db.rollback()
frappe.destroy()
print("\nDone! Labels imported as Print Formats.")
@@ -7,12 +7,12 @@ def search_customers(q=""):
return [] return []
q = q.strip().lower() q = q.strip().lower()
customers = frappe.db.sql(""" customers = frappe.db.sql("""
SELECT c.name, c.customer_name, c.customer_number, c.phone, c.email_id, SELECT c.name, c.customer_name, c.customer_number, c.mobile_no,
a.address_line1, a.city, a.state, a.pincode a.address_line1, a.city, a.state, a.pincode
FROM tabCustomer c FROM tabCustomer c
LEFT JOIN tabDynamic Link dl ON dl.link_doctype = 'Customer' AND dl.link_name = c.name AND dl.parenttype = 'Address' LEFT JOIN `tabDynamic Link` dl ON dl.link_doctype = 'Customer' AND dl.link_name = c.name AND dl.parenttype = 'Address'
LEFT JOIN tabAddress a ON a.name = dl.parent LEFT JOIN tabAddress a ON a.name = dl.parent
WHERE LOWER(c.customer_name) LIKE %s OR LOWER(c.customer_number) LIKE %s OR LOWER(c.phone) LIKE %s WHERE LOWER(c.customer_name) LIKE %s OR LOWER(c.customer_number) LIKE %s OR LOWER(c.mobile_no) LIKE %s
ORDER BY c.customer_name ORDER BY c.customer_name
LIMIT 20 LIMIT 20
""", ("%" + q + "%", "%" + q + "%", "%" + q + "%"), as_dict=True) """, ("%" + q + "%", "%" + q + "%", "%" + q + "%"), as_dict=True)
@@ -27,7 +27,7 @@ def get_customer(name):
addr = frappe.db.sql(""" addr = frappe.db.sql("""
SELECT a.address_line1, a.city, a.state, a.pincode, a.phone SELECT a.address_line1, a.city, a.state, a.pincode, a.phone
FROM tabAddress a FROM tabAddress a
JOIN tabDynamic Link dl ON dl.parent = a.name AND dl.link_doctype = 'Customer' AND dl.link_name = %s JOIN `tabDynamic Link` dl ON dl.parent = a.name AND dl.link_doctype = 'Customer' AND dl.link_name = %s
LIMIT 1 LIMIT 1
""", (name,), as_dict=True) """, (name,), as_dict=True)
if addr: if addr:
@@ -50,7 +50,7 @@ def create_customer_from_intake(data):
customer.customer_group = data.get("customer_group", "IT Recycling") customer.customer_group = data.get("customer_group", "IT Recycling")
customer.customer_type = "Company" customer.customer_type = "Company"
customer.customer_number = data.get("customer_number") customer.customer_number = data.get("customer_number")
customer.phone = data.get("phone") customer.mobile_no = data.get("phone")
customer.email_id = data.get("email_id") customer.email_id = data.get("email_id")
customer.legacy_notes = data.get("legacy_notes") customer.legacy_notes = data.get("legacy_notes")
customer.hours_of_operation = data.get("hours_of_operation") customer.hours_of_operation = data.get("hours_of_operation")
+41 -52
View File
@@ -40,19 +40,19 @@ frappe.pages['intake'].on_page_load = function(wrapper) {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Contact Name</label> <label>Contact Name</label>
<input type="text" id="contact_name" class="form-control" readonly style="background:#f8f9fa;"> <input type="text" id="contact_name" class="form-control">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Contact #</label> <label>Contact #</label>
<input type="tel" id="contact_number" class="form-control" readonly style="background:#f8f9fa;"> <input type="tel" id="contact_number" class="form-control">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Contact Email</label> <label>Contact Email</label>
<input type="email" id="contact_email" class="form-control" readonly style="background:#f8f9fa;"> <input type="email" id="contact_email" class="form-control">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Address</label> <label>Address</label>
<input type="text" id="address_line" class="form-control" readonly style="background:#f8f9fa;"> <input type="text" id="address_line" class="form-control">
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
@@ -104,7 +104,7 @@ frappe.pages['intake'].on_page_load = function(wrapper) {
<textarea id="legacy_notes" class="form-control" rows="2" readonly style="background:#fafafa;" title="Auto-filled from Customer record"></textarea> <textarea id="legacy_notes" class="form-control" rows="2" readonly style="background:#fafafa;" title="Auto-filled from Customer record"></textarea>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4" id="load-info-section" style="display:none;">
<h5 style="color:#6f42c1;">Items & Weight</h5> <h5 style="color:#6f42c1;">Items & Weight</h5>
<div class="form-group"> <div class="form-group">
<label>Barcode</label> <label>Barcode</label>
@@ -145,7 +145,7 @@ frappe.pages['intake'].on_page_load = function(wrapper) {
<div class="row" style="margin-top: 20px;"> <div class="row" style="margin-top: 20px;">
<div class="col-md-12"> <div class="col-md-12">
<button type="submit" class="btn btn-primary btn-lg" style="background: linear-gradient(135deg, #6f42c1, #28a745); border: none;"> <button type="submit" class="btn btn-primary btn-lg" style="background: linear-gradient(135deg, #6f42c1, #28a745); border: none;">
Save Customer Save Contact Info
</button> </button>
<button type="button" class="btn btn-default btn-lg" id="btn-print-labels" disabled> <button type="button" class="btn btn-default btn-lg" id="btn-print-labels" disabled>
Print Labels Print Labels
@@ -204,6 +204,19 @@ frappe.pages['intake'].on_page_load = function(wrapper) {
} }
}, 200); }, 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(); load_customer_list();
$('#received_date').on('change', function() { $('#received_date').on('change', function() {
@@ -214,7 +227,7 @@ frappe.pages['intake'].on_page_load = function(wrapper) {
$('#intake-form').on('submit', function(e) { $('#intake-form').on('submit', function(e) {
e.preventDefault(); e.preventDefault();
save_pallet(); save_customer();
}); });
$('#btn-cancel').on('click', function() { $('#btn-cancel').on('click', function() {
@@ -504,61 +517,37 @@ function edit_pallet(name) {
}); });
} }
function save_pallet() { function save_customer() {
var pallet_name = $('#intake-form-container').data('pallet-name'); var customer_name = customer_number_control ? customer_number_control.get_value() : null;
var doc = { if (!customer_name) {
doctype: 'Pallet', frappe.msgprint("Please select a customer first.");
received_date: $('#received_date').val(), return;
customer_number: customer_number_control ? customer_number_control.get_value() : '', }
driver: driver_control ? driver_control.get_value() : '',
company_name: $('#company_name').val(),
pickup: $('#pickup').val(),
data_status: $('#data_status').val(),
red_r2: $('#red_r2').val(),
barcode: $('#barcode').val(),
total_items: parseInt($('#total_items').val()) || 0,
num_labels: parseInt($('#num_labels').val()) || 1,
contact_name: $('#contact_name').val(),
contact_number: $('#contact_number').val(),
contact_email: $('#contact_email').val(),
address_line: $('#address_line').val(),
weights: $('#weights').val(),
invoice_check_request: $('#invoice_check_request').val(),
amount: parseFloat($('#amount').val()) || 0,
paid_received: $('#paid_received').val(),
notes: $('#notes').val(),
legacy_notes: $('#legacy_notes').val(),
hours_of_operation: $('#hours_of_operation').val(),
};
if (pallet_name) {
doc.name = pallet_name;
frappe.call({ frappe.call({
method: 'frappe.client.update', method: "frappe.client.get",
args: {doc: doc}, args: { doctype: "Customer", name: customer_name },
callback: function(r) { callback: function(r) {
if (r.message) { if (r.message) {
frappe.msgprint('Pallet updated successfully!'); var doc = r.message;
$('#save-status').html('<span style="color:green;">Saved!</span>'); doc.contact_persons = .val();
} doc.mobile_no = .val();
} doc.email_id = .val();
}); doc.hours_of_operation = .val();
} else { doc.legacy_notes = .val();
frappe.call({ frappe.call({
method: 'frappe.client.insert', method: "frappe.client.save",
args: { doc: doc }, args: { doc: doc },
callback: function(r) { callback: function(r2) {
if (r.message) { if (r2.message) {
$('#intake-form-container').data('pallet-name', r.message.name); frappe.msgprint("Customer updated!");
frappe.msgprint('Pallet created: ' + r.message.name); .html("<span style=\"color:green;\">Saved!</span>");
$('#save-status').html('<span style="color:green;">Created!</span>');
$('#btn-print-labels').prop('disabled', false);
$('#btn-generate-cor').prop('disabled', false);
} }
} }
}); });
} }
} }
});
}
function generate_cor_report() { function generate_cor_report() {
var companyName = $('#company_name').val(); var companyName = $('#company_name').val();
+1 -1
View File
@@ -4,7 +4,7 @@
"docstatus": 0, "docstatus": 0,
"doctype": "Page", "doctype": "Page",
"idx": 0, "idx": 0,
"modified": "2026-05-21 18:32:29.966134", "modified": "2026-05-23 01:31:28.579759",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Westech R2", "module": "Westech R2",
"name": "intake", "name": "intake",
+566
View File
@@ -0,0 +1,566 @@
frappe.pages['receiving'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'Receiving',
single_column: true
});
// Inline HTML — same pattern as intake.js
$(wrapper).find('.layout-main-section').html(`
<div class="receiving-station" style="padding: 20px;">
<div class="row">
<div class="col-md-12">
<h3 style="margin-top: 0; color: #2F5496;">🚛 Receiving</h3>
<p class="text-muted">Schedule pickups, manage routes, and check in loads.</p>
</div>
</div>
<ul class="nav nav-tabs" role="tablist" id="receiving-tabs">
<li role="presentation" class="active"><a href="#stage-a" role="tab" data-toggle="tab">📋 Stage A — Schedule Pickup</a></li>
<li role="presentation"><a href="#stage-b" role="tab" data-toggle="tab">🗺️ Stage B — Route & Dispatch</a></li>
<li role="presentation"><a href="#stage-c" role="tab" data-toggle="tab">⚖️ Stage C — Load Check-in</a></li>
</ul>
<div class="tab-content" style="padding-top: 20px;">
<div role="tabpanel" class="tab-pane active" id="stage-a">
<div class="row">
<div class="col-md-3">
<div class="panel panel-primary">
<div class="panel-heading">📅 Pickup Calendar — Next 30 Days</div>
<div class="panel-body" id="pickup-calendar"><div class="text-muted text-center">Loading...</div></div>
</div>
</div>
<div class="col-md-9">
<div class="panel panel-default">
<div class="panel-heading">
<div class="row">
<div class="col-md-6"><strong>Scheduled Pickups</strong><span id="pickup-count-label" class="text-muted" style="margin-left: 8px;"></span></div>
<div class="col-md-6 text-right">
<button class="btn btn-primary btn-sm" id="btn-new-pickup">+ New Pickup</button>
<input type="date" id="pickup-date-filter" class="form-control input-sm" style="display:inline-block;width:auto;vertical-align:middle;margin-left:8px;">
<button class="btn btn-default btn-sm" id="btn-clear-date" style="margin-left:4px;">Clear</button>
</div>
</div>
</div>
<div class="panel-body" style="padding: 0; overflow-x: auto;">
<table class="table table-striped table-hover" id="pickup-table" style="font-size: 13px; margin-bottom: 0;">
<thead><tr><th>Date</th><th>Weekday</th><th>Type</th><th>Customer</th><th>Contact</th><th>Address</th><th>Est. Items</th><th>Data</th><th>RED/R2</th><th>Status</th><th>Notes</th><th>Truck</th><th>AoR</th><th>CoD</th></tr></thead>
<tbody id="pickup-tbody"><tr><td colspan="14" class="text-center text-muted">Loading...</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="new-pickup-form" style="display:none; margin-top: 16px;">
<div class="panel panel-primary">
<div class="panel-heading">+ New Scheduled Pickup</div>
<div class="panel-body">
<form id="pickup-form">
<div class="row">
<div class="col-md-4">
<h5 style="color:#6f42c1;">📅 Pickup Info</h5>
<div class="form-group"><label>Pickup Date <span class="text-danger">*</span></label><input type="date" id="sp-pickup_date" class="form-control" required></div>
<div class="form-group"><label>Type <span class="text-danger">*</span></label><select id="sp-pickup_type" class="form-control" required><option value="Pickup">Pickup</option><option value="Drop-off">Drop-off</option></select></div>
<div class="form-group"><label>Customer <span class="text-danger">*</span></label><div id="sp-customer-control"></div></div>
<div class="form-group"><label>Company Name</label><input type="text" id="sp-company_name" class="form-control" readonly style="background:#f8f9fa;"></div>
<div class="form-group"><label>Contact Name</label><input type="text" id="sp-contact_name" class="form-control"></div>
<div class="form-group"><label>Contact Phone</label><input type="text" id="sp-contact_phone" class="form-control"></div>
<div class="form-group"><label>Contact Email</label><input type="email" id="sp-contact_email" class="form-control"></div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">📍 Address</h5>
<div class="form-group"><label>Street Address</label><input type="text" id="sp-address_line" class="form-control"></div>
<div class="form-group"><label>City</label><input type="text" id="sp-city" class="form-control"></div>
<div class="form-group"><label>State</label><input type="text" id="sp-state" class="form-control" value="AZ"></div>
<div class="form-group"><label>ZIP</label><input type="text" id="sp-zip_code" class="form-control"></div>
<div class="form-group"><label>Hours of Operation</label><input type="text" id="sp-hours_of_operation" class="form-control" placeholder="e.g. Mon-Fri 8am-5pm"></div>
</div>
<div class="col-md-4">
<h5 style="color:#6f42c1;">📦 Load Info</h5>
<div class="form-group"><label>Estimated Items</label><input type="number" id="sp-estimated_items" class="form-control"></div>
<div class="form-group"><label>Estimated Weight</label><input type="text" id="sp-estimated_weight" class="form-control"></div>
<div class="form-group"><label>Load Contents</label><input type="text" id="sp-load_contents" class="form-control" placeholder="Wire, Monitors, Laptops..."></div>
<div class="form-group"><label>Data Status</label><select id="sp-data_status" class="form-control"><option value="">—</option><option value="D0">D0</option><option value="D1">D1</option><option value="ND1">ND1</option><option value="ND2">ND2</option><option value="ND3">ND3</option><option value="ND4">ND4</option></select></div>
<div class="form-group"><label>RED / R2</label><select id="sp-red_r2" class="form-control"><option value="">—</option><option value="RED">RED</option><option value="R2">R2</option><option value="Both">Both</option><option value="Neither">Neither</option></select></div>
<div class="form-group"><div class="checkbox"><label><input type="checkbox" id="sp-needs_aor"> <strong>Needs AoR</strong></label></div><div class="checkbox"><label><input type="checkbox" id="sp-needs_cod"> <strong>Needs CoD</strong></label></div></div>
<div class="form-group"><label>Notes</label><textarea id="sp-notes" class="form-control" rows="2"></textarea></div>
<div class="form-group"><label>Legacy Notes</label><textarea id="sp-legacy_notes" class="form-control" rows="2" style="background:#fafafa;" readonly></textarea></div>
</div>
</div>
<div class="row" style="margin-top: 16px;"><div class="col-md-12"><button type="submit" class="btn btn-primary btn-lg">Save Pickup</button><button type="button" class="btn btn-default btn-lg" id="btn-cancel-pickup">Cancel</button></div></div>
</form>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="stage-b">
<div class="row" style="margin-bottom: 16px;"><div class="col-md-12"><div class="btn-group"><input type="date" id="route-date" class="form-control" style="display:inline-block;width:auto;"><button class="btn btn-primary" id="btn-load-routes">Load Pickups</button><button class="btn btn-primary" id="btn-auto-route">🧮 Auto-Route</button><button class="btn btn-success" id="btn-route-sheet">🖨️ Route Sheet</button><button class="btn btn-success" id="btn-green-sheet">📄 Green Sheet</button><button class="btn btn-success" id="btn-labels">🏷️ Labels</button></div></div></div>
<div class="row" id="route-columns">
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 1"><div class="panel-heading">🚛 Truck 1 <span id="truck1-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck1-stops"></div></div></div>
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 2"><div class="panel-heading">🚛 Truck 2 <span id="truck2-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck2-stops"></div></div></div>
<div class="col-md-4"><div class="panel panel-default truck-column" data-truck="Truck 3"><div class="panel-heading">🚛 Truck 3 <span id="truck3-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="truck3-stops"></div></div></div>
</div>
<div class="row" style="margin-top: 16px;"><div class="col-md-12"><div class="panel panel-default truck-column" data-truck=""><div class="panel-heading">📋 Unassigned <span id="unassigned-count" class="text-muted"></span></div><div class="panel-body truck-stops" id="unassigned-stops"></div></div></div></div>
</div>
<div role="tabpanel" class="tab-pane" id="stage-c">
<div class="row" style="margin-bottom: 16px;"><div class="col-md-12"><button class="btn btn-primary" id="btn-new-checkin">+ Check In Load</button><button class="btn btn-success" id="btn-cor-report">📋 CoR Report</button></div></div>
<div class="panel panel-default">
<div class="panel-heading">Recent Check-ins</div>
<div class="panel-body" style="padding: 0; overflow-x: auto;">
<table class="table table-striped table-hover" id="checkin-table" style="font-size: 13px; margin-bottom: 0;">
<thead><tr><th>Date</th><th>Customer</th><th>Type</th><th class="text-right">Actual Pallets</th><th class="text-right">Actual Weight</th><th>Load Contents</th><th>Data Status</th><th>RED/R2</th><th>Status</th></tr></thead>
<tbody id="checkin-tbody"><tr><td colspan="9" class="text-center text-muted">Loading...</td></tr></tbody>
</table>
</div>
</div>
<div id="checkin-form" style="display:none; margin-top: 16px;">
<div class="panel panel-primary">
<div class="panel-heading">+ Load Check-in</div>
<div class="panel-body">
<form id="checkin-form-inner">
<div class="row">
<div class="col-md-4"><div class="form-group"><label>Scheduled Pickup <span class="text-danger">*</span></label><div id="ci-pickup-control"></div></div></div>
<div class="col-md-4"><div class="form-group"><label>Received Date <span class="text-danger">*</span></label><input type="date" id="ci-received_date" class="form-control" required></div></div>
<div class="col-md-4"><div class="form-group"><label>Actual # of Pallets/Gaylords</label><input type="number" id="ci-actual_pallets" class="form-control"></div></div>
</div>
<div class="row">
<div class="col-md-4"><div class="form-group"><label>Actual Weight (lbs)</label><input type="text" id="ci-actual_weight" class="form-control"></div></div>
<div class="col-md-8"><div class="form-group"><label>Load Contents</label><textarea id="ci-load_contents" class="form-control" rows="2" placeholder="Wire, Monitors, Laptops, MRI machine..."></textarea></div></div>
</div>
<div class="row" style="margin-top: 8px;"><div class="col-md-12"><button type="submit" class="btn btn-primary btn-lg">Check In</button><button type="button" class="btn btn-default btn-lg" id="btn-cancel-checkin">Cancel</button></div></div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.truck-stops { min-height: 60px; }
.stop-card { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 10px 12px; margin: 6px 0; cursor: grab; font-size: 13px; }
.stop-card:hover { border-color: #2F5496; }
.stop-card .stop-co { font-weight: 700; color: #2F5496; }
.stop-card .stop-addr { color: #666; font-size: 12px; margin-top: 2px; }
.stop-card .stop-meta { display: flex; gap: 8px; margin-top: 4px; font-size: 11px; }
.stop-card .stop-meta span { background: #D6E4F0; color: #2F5496; padding: 1px 6px; border-radius: 3px; }
.stop-card.dragging { opacity: 0.5; }
.truck-column { min-height: 200px; }
#pickup-calendar { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; font-size: 12px; }
.cal-day { text-align: center; padding: 6px 4px; border-radius: 6px; }
.cal-day.has-pickups { background: #D6E4F0; cursor: pointer; }
.cal-day.today { background: #2F5496; color: #fff; }
.cal-day .day-num { font-weight: 600; }
.cal-day .day-count { font-size: 10px; font-weight: 700; }
</style>
`);
// ── Stage Tabs ──
$("#receiving-tabs a").on("click", function(e) {
e.preventDefault();
$(this).tab("show");
var stage = $(this).attr("href").replace("#stage-", "");
if (stage === "a") loadPickups();
if (stage === "b") loadRoutes();
if (stage === "c") loadCheckins();
});
// ── Stage A: Link Controls ──
var customer_control = null;
function setupCustomerLink() {
customer_control = frappe.ui.form.make_control({
parent: $("#sp-customer-control"),
df: {
fieldtype: "Link",
fieldname: "customer_number",
options: "Customer",
label: "Customer",
reqd: 1,
placeholder: "Search customer...",
onchange: function() {
var val = customer_control.get_value();
if (val) fetchCustomerDetails(val);
else clearCustomerFields();
}
},
only_input: true,
});
customer_control.refresh();
$("#sp-customer-control .control-input").css("margin", "0");
$("#sp-customer-control .help-box").remove();
}
function fetchCustomerDetails(customer_name) {
frappe.call({
method: "frappe.client.get",
args: { doctype: "Customer", name: customer_name },
callback: function(r) {
if (!r.message) return;
var c = r.message;
$("#sp-company_name").val(c.customer_name || "");
$("#sp-contact_name").val(c.contact_name || "");
$("#sp-contact_phone").val(c.contact_phone || "");
$("#sp-contact_email").val(c.contact_email || "");
$("#sp-legacy_notes").val(c.legacy_notes || "");
$("#sp-hours_of_operation").val(c.hours_of_operation || "");
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Address",
filters: [["Dynamic Link", "link_name", "=", customer_name]],
fields: ["address_line1", "city", "state", "pincode"],
limit_page_length: 1
},
callback: function(ra) {
if (ra.message && ra.message.length) {
var a = ra.message[0];
$("#sp-address_line").val(a.address_line1 || "");
$("#sp-city").val(a.city || "");
$("#sp-state").val(a.state || "AZ");
$("#sp-zip_code").val(a.pincode || "");
}
}
});
frappe.call({
method: "frappe.client.get_list",
args: {
doctype: "Contact",
filters: [["Dynamic Link", "link_name", "=", customer_name]],
fields: ["first_name", "last_name", "email_id", "phone", "mobile_no"],
limit_page_length: 1
},
callback: function(rc) {
if (rc.message && rc.message.length) {
var ct = rc.message[0];
if (!$("#sp-contact_name").val()) {
$("#sp-contact_name").val((ct.first_name || "") + " " + (ct.last_name || ""));
}
if (!$("#sp-contact_phone").val()) {
$("#sp-contact_phone").val(ct.phone || ct.mobile_no || "");
}
if (!$("#sp-contact_email").val()) {
$("#sp-contact_email").val(ct.email_id || "");
}
}
}
});
}
});
}
function clearCustomerFields() {
$("#sp-company_name, #sp-contact_name, #sp-contact_phone, #sp-contact_email, #sp-address_line, #sp-city, #sp-state, #sp-zip_code, #sp-legacy_notes, #sp-hours_of_operation").val("");
$("#sp-state").val("AZ");
}
// ── Stage A: Load Pickups ──
function loadPickups() {
var dateFilter = $("#pickup-date-filter").val();
frappe.call({
method: "westech_r2.api.receiving_api.get_pickups",
args: { date: dateFilter },
callback: function(r) {
if (r.message) {
renderPickupTable(r.message.pickups || []);
renderCalendar(r.message.calendar || []);
// weekly chart removed
$("#pickup-count-label").text((r.message.pickups || []).length + " pickups");
}
}
});
}
function renderPickupTable(pickups) {
var tbody = $("#pickup-tbody");
if (!pickups.length) {
tbody.html('<tr><td colspan="14" class="text-center text-muted">No scheduled pickups</td></tr>');
return;
}
var statusColors = { "Scheduled": "#2196F3", "Routed": "#009688", "In Progress": "#FF9800", "Complete": "#4CAF50", "Cancelled": "#F44336" };
var h = "";
pickups.forEach(function(p) {
var st = p.status || "Scheduled";
var sc = statusColors[st] || "#999";
var weekday = p.pickup_date ? dayName(new Date(p.pickup_date + "T12:00:00")) : "";
var typeBadge = p.pickup_type === "Drop-off"
? '<span class="badge" style="background:#E3F2FD;color:#1565C0">Drop-off</span>'
: '<span class="badge" style="background:#FFF3E0;color:#E65100">Pickup</span>';
h += '<tr style="cursor:pointer" onclick=\"window.open(\'/app/scheduled-pickup/\' + encodeURIComponent(p.name) + \'\', \'_blank\')\">';
h += '<td>' + esc(p.pickup_date || "") + '</td>';
h += '<td class="text-muted">' + weekday + '</td>';
h += '<td>' + typeBadge + '</td>';
h += '<td><strong>' + esc(p.company_name || p.customer_number || "") + '</strong></td>';
h += '<td style="font-size:12px">' + esc((p.contact_name || "") + (p.contact_phone ? " • " + p.contact_phone : "")) + '</td>';
h += '<td style="font-size:12px">' + esc((p.address_line || "") + (p.city ? ", " + p.city : "")) + '</td>';
h += '<td class="text-right">' + (p.estimated_items || "—") + '</td>';
h += '<td>' + esc(p.data_status || "—") + '</td>';
h += '<td>' + esc(p.red_r2 || "—") + '</td>';
h += '<td><span class="badge" style="background:' + sc + '22;color:' + sc + '">' + esc(st) + '</span></td>';
h += '<td style="max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(p.notes || "") + '</td>';
h += '<td>' + esc(p.truck || "—") + '</td>';
h += '<td class="text-center">' + (p.needs_aor ? "✓" : "") + '</td>';
h += '<td class="text-center">' + (p.needs_cod ? "✓" : "") + '</td>';
h += '</tr>';
});
tbody.html(h);
}
function renderCalendar(days) {
var el = $("#pickup-calendar");
if (!days || !days.length) { el.html('<div class="text-muted text-center">No upcoming pickups</div>'); return; }
var today = frappe.datetime.nowdate();
var h = '<div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px;font-size:12px">';
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].forEach(function(d) {
h += '<div style="text-align:center;font-weight:600;color:#666;padding:4px">' + d + '</div>';
});
var first = new Date(days[0].date + "T12:00:00");
for (var i = 0; i < first.getDay(); i++) h += '<div></div>';
days.forEach(function(d) {
var isToday = d.date === today;
var hasCount = d.count > 0;
var bg = isToday ? "cal-day today" : (hasCount ? "cal-day has-pickups" : "cal-day");
var onclick = hasCount ? "onclick=$('#pickup-date-filter').val('" + d.date + "');loadPickups();" : "";
h += '<div class="' + bg + '" ' + onclick + '>';
h += '<div style="font-weight:' + (isToday ? "700" : "400") + '">' + d.date.split("-")[2] + '</div>';
if (hasCount) h += '<div class="day-count">' + d.count + '</div>';
h += '</div>';
});
h += '</div>';
el.html(h);
}
// ── Stage A: New Pickup ──
$("#btn-new-pickup").on("click", function() {
$("#new-pickup-form").show();
$("#sp-pickup_date").val(frappe.datetime.nowdate());
setupCustomerLink();
});
$("#btn-cancel-pickup").on("click", function() {
$("#new-pickup-form").hide();
if (customer_control) customer_control.set_value("");
});
$("#pickup-form").on("submit", function(e) {
e.preventDefault();
var doc = {
doctype: "Scheduled Pickup",
pickup_date: $("#sp-pickup_date").val(),
pickup_type: $("#sp-pickup_type").val(),
customer_number: customer_control ? customer_control.get_value() : "",
company_name: $("#sp-company_name").val(),
contact_name: $("#sp-contact_name").val(),
contact_phone: $("#sp-contact_phone").val(),
contact_email: $("#sp-contact_email").val(),
address_line: $("#sp-address_line").val(),
city: $("#sp-city").val(),
state: $("#sp-state").val(),
zip_code: $("#sp-zip_code").val(),
estimated_items: parseInt($("#sp-estimated_items").val()) || 0,
estimated_weight: $("#sp-estimated_weight").val(),
load_contents: $("#sp-load_contents").val(),
data_status: $("#sp-data_status").val(),
red_r2: $("#sp-red_r2").val(),
needs_aor: $("#sp-needs_aor").is(":checked") ? 1 : 0,
needs_cod: $("#sp-needs_cod").is(":checked") ? 1 : 0,
notes: $("#sp-notes").val(),
legacy_notes: $("#sp-legacy_notes").val(),
status: "Scheduled"
};
frappe.call({
method: "frappe.client.insert",
args: { doc: doc },
callback: function(r) {
if (r.message) {
frappe.show_alert({ message: "Pickup scheduled", indicator: "green" });
$("#new-pickup-form").hide();
loadPickups();
}
}
});
});
$("#pickup-date-filter").on("change", loadPickups);
$("#btn-clear-date").on("click", function() {
$("#pickup-date-filter").val("");
loadPickups();
});
// ── Stage B: Routing ──
function loadRoutes() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
$("#route-date").val(date);
frappe.call({
method: "westech_r2.api.receiving_api.get_pickups",
args: { date: date },
callback: function(r) {
if (r.message) renderRouteColumns(r.message.pickups || []);
}
});
}
function renderRouteColumns(pickups) {
var trucks = { "Truck 1": [], "Truck 2": [], "Truck 3": [], "Unassigned": [] };
pickups.forEach(function(p) {
var t = p.truck || "";
if (t && trucks[t]) trucks[t].push(p);
else trucks["Unassigned"].push(p);
});
["Truck 1", "Truck 2", "Truck 3"].forEach(function(t) {
var key = t.toLowerCase().replace(/ /g, "");
$("#" + key + "-count").text("(" + trucks[t].length + " stops)");
$("#" + key + "-stops").html(trucks[t].map(function(p, i) { return stopCard(p, i + 1); }).join(""));
});
$("#unassigned-count").text("(" + trucks["Unassigned"].length + " stops)");
$("#unassigned-stops").html(trucks["Unassigned"].map(function(p) { return stopCard(p, 0); }).join(""));
}
function stopCard(p, order) {
var h = '<div class="stop-card" data-pickup="' + esc(p.name) + '">';
if (order) h += '<div style="font-size:11px;color:#666;margin-bottom:2px">Stop #' + order + '</div>';
h += '<div class="stop-co">' + esc(p.company_name || p.customer_number || "Unknown") + '</div>';
h += '<div class="stop-addr">' + esc((p.address_line || "") + (p.city ? ", " + p.city : "")) + '</div>';
h += '<div class="stop-meta">';
if (p.estimated_items) h += '<span>' + p.estimated_items + ' items</span>';
if (p.data_status) h += '<span>' + esc(p.data_status) + '</span>';
if (p.red_r2) h += '<span>' + esc(p.red_r2) + '</span>';
if (p.needs_aor) h += '<span>AoR</span>';
if (p.needs_cod) h += '<span>CoD</span>';
h += '</div></div>';
return h;
}
$("#btn-load-routes").on("click", loadRoutes);
$("#btn-auto-route").on("click", function() {
var date = $("#route-date").val();
if (!date) { frappe.msgprint("Select a date first"); return; }
frappe.call({
method: "westech_r2.api.receiving_api.auto_route",
args: { date: date },
callback: function(r) {
if (r.message && r.message.success) {
frappe.show_alert({ message: "Routes optimized", indicator: "green" });
loadRoutes();
}
}
});
});
$("#btn-route-sheet").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_route_sheet?date=" + date, "_blank");
});
$("#btn-green-sheet").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_green_sheet?date=" + date, "_blank");
});
$("#btn-labels").on("click", function() {
var date = $("#route-date").val() || frappe.datetime.nowdate();
window.open("/api/method/westech_r2.api.receiving_api.print_labels?date=" + date, "_blank");
});
// ── Stage C: Check-in ──
var checkin_pickup_control = null;
function loadCheckins() {
frappe.call({
method: "westech_r2.api.receiving_api.get_checkins",
callback: function(r) {
if (r.message) renderCheckinTable(r.message.checkins || []);
}
});
}
function renderCheckinTable(checkins) {
var tbody = $("#checkin-tbody");
if (!checkins.length) {
tbody.html('<tr><td colspan="9" class="text-center text-muted">No check-ins yet</td></tr>');
return;
}
tbody.html(checkins.map(function(c) {
return '<tr><td>' + esc(c.pickup_date || "") + '</td>' +
'<td><strong>' + esc(c.company_name || "") + '</strong></td>' +
'<td>' + esc(c.pickup_type || "") + '</td>' +
'<td class="text-right">' + (c.estimated_items || "—") + '</td>' +
'<td class="text-right">' + (c.estimated_weight || "—") + '</td>' +
'<td style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(c.load_contents || "") + '</td>' +
'<td>' + esc(c.data_status || "—") + '</td>' +
'<td>' + esc(c.red_r2 || "—") + '</td>' +
'<td>' + esc(c.status || "") + '</td></tr>';
}).join(""));
}
$("#btn-new-checkin").on("click", function() {
$("#checkin-form").show();
$("#ci-received_date").val(frappe.datetime.nowdate());
checkin_pickup_control = frappe.ui.form.make_control({
parent: $("#ci-pickup-control"),
df: {
fieldtype: "Link",
fieldname: "pickup_ref",
options: "Scheduled Pickup",
label: "Scheduled Pickup",
reqd: 1,
placeholder: "Search pickup...",
get_query: function() {
return {
filters: [
["Scheduled Pickup", "status", "in", ["Scheduled", "Routed", "In Progress"]]
]
};
}
},
only_input: true,
});
checkin_pickup_control.refresh();
$("#ci-pickup-control .control-input").css("margin", "0");
$("#ci-pickup-control .help-box").remove();
});
$("#btn-cancel-checkin").on("click", function() {
$("#checkin-form").hide();
});
$("#checkin-form-inner").on("submit", function(e) {
e.preventDefault();
var pickupName = checkin_pickup_control ? checkin_pickup_control.get_value() : "";
if (!pickupName) { frappe.msgprint("Select a pickup"); return; }
var update = {};
update.status = "Complete";
if ($("#ci-actual_pallets").val()) update.estimated_items = parseInt($("#ci-actual_pallets").val());
if ($("#ci-actual_weight").val()) update.estimated_weight = $("#ci-actual_weight").val();
if ($("#ci-load_contents").val()) update.load_contents = $("#ci-load_contents").val();
frappe.call({
method: "frappe.client.set_value",
args: {
doctype: "Scheduled Pickup",
name: pickupName,
fieldname: update
},
callback: function(r) {
if (r.message) {
frappe.show_alert({ message: "Load checked in", indicator: "green" });
$("#checkin-form").hide();
loadCheckins();
}
}
});
});
$("#btn-cor-report").on("click", function() {
window.open("/api/method/westech_r2.api.receiving_api.cor_report", "_blank");
});
// ── Helpers ──
function esc(s) { return s ? String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") : ""; }
function dayName(d) { return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()]; }
// ── Init ──
loadPickups();
};
@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Green Sheet - Pallet {{ doc.pallet_number }}</title>
<style>
@page { size: letter; margin: 0.5in; }
body { font-family: Arial, sans-serif; font-size: 11pt; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border: 3px solid #28a745; padding: 15px; }
.logo { max-width: 200px; }
.title { font-size: 28pt; font-weight: bold; color: #28a745; }
.subtitle { font-size: 14pt; color: #666; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #000; padding: 8px; text-align: left; }
th { background-color: #f0f0f0; font-weight: bold; }
.section { margin: 20px 0; }
.section-title { font-size: 14pt; font-weight: bold; background: #28a745; color: white; padding: 5px 10px; margin-bottom: 10px; }
.info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 15px; }
.info-field { border: 1px solid #ccc; padding: 8px; }
.info-label { font-size: 9pt; color: #666; margin-bottom: 3px; }
.info-value { font-size: 11pt; font-weight: bold; }
.footer { margin-top: 30px; border-top: 2px solid #28a745; padding-top: 10px; font-size: 9pt; color: #666; }
.service-level { font-size: 24pt; font-weight: bold; color: #d63384; text-transform: uppercase; border: 3px solid #d63384; padding: 10px; text-align: center; margin: 15px 0; }
.checkbox { display: inline-block; width: 18px; height: 18px; border: 2px solid #000; margin-right: 5px; }
.checked { background: #000; }
.warning { background: #fff3cd; border: 2px solid #ffc107; padding: 10px; margin: 15px 0; font-weight: bold; }
</style>
</head>
<body>
<div class="header">
<div>
<div class="title">GREEN SHEET</div>
<div class="subtitle">Data-Bearing Equipment Tracking</div>
</div>
<div style="text-align: right;">
<div><strong>Pallet #:</strong> {{ doc.pallet_number or '______________' }}</div>
<div><strong>Load #:</strong> {{ doc.load or '______________' }}</div>
<div><strong>Date Received:</strong> {{ doc.received_date or '______________' }}</div>
</div>
</div>
{% if doc.service_level %}
<div class="service-level">
SERVICE LEVEL: {{ doc.service_level }}
</div>
{% endif %}
<div class="section">
<div class="section-title">Customer Information</div>
<div class="info-grid">
<div class="info-field">
<div class="info-label">Customer Name</div>
<div class="info-value">{{ doc.customer_name or '________________' }}</div>
</div>
<div class="info-field">
<div class="info-label">Customer #</div>
<div class="info-value">{{ doc.customer_number or '________________' }}</div>
</div>
<div class="info-field">
<div class="info-label">Address</div>
<div class="info-value">{{ doc.customer_address or '________________' }}</div>
</div>
<div class="info-field">
<div class="info-label">Contact/Phone</div>
<div class="info-value">{{ doc.contact_phone or '________________' }}</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Pallet Details</div>
<div class="info-grid">
<div class="info-field">
<div class="info-label">Status</div>
<div class="info-value">{{ doc.status or 'Received' }}</div>
</div>
<div class="info-field">
<div class="info-label">Data Status</div>
<div class="info-value">{{ doc.data_status or 'D0' }}</div>
</div>
<div class="info-field">
<div class="info-label">Inbound Weight (lbs)</div>
<div class="info-value">{{ doc.inbound_weight or '____' }}</div>
</div>
<div class="info-field">
<div class="info-label">Technician</div>
<div class="info-value">________________</div>
</div>
</div>
<div style="margin-top: 10px; font-size: 12pt;">
<span class="checkbox {% if doc.certificate %}checked{% endif %}"></span> <strong>COR/AoR</strong>
<span style="margin-left: 20px;">
<span class="checkbox"></span> <strong>RED/R2</strong>
</span>
<span style="margin-left: 20px;">
<span class="checkbox"></span> <strong>AoR/COD</strong>
</span>
</div>
</div>
<div class="section">
<div class="section-title">Device List</div>
<table>
<thead>
<tr>
<th style="width: 20%;">Serial #</th>
<th style="width: 15%;">Device Type</th>
<th style="width: 15%;">Manufacturer</th>
<th style="width: 15%;">Model</th>
<th style="width: 15%;">Erasure Method</th>
<th style="width: 10%;">Result</th>
<th style="width: 10%;">Grade</th>
</tr>
</thead>
<tbody>
{% for device in doc.devices %}
<tr>
<td>{{ device.serial_number or '________________' }}</td>
<td>{{ device.device_type or '________' }}</td>
<td>{{ device.manufacturer or '________' }}</td>
<td>{{ device.model or '________' }}</td>
<td>{{ device.erasure_method or '________' }}</td>
<td>{{ device.erasure_result or '____' }}</td>
<td>{{ device.grade or '____' }}</td>
</tr>
{% else %}
<tr><td colspan="7" style="height: 30px;"></td></tr>
<tr><td colspan="7" style="height: 30px;"></td></tr>
<tr><td colspan="7" style="height: 30px;"></td></tr>
<tr><td colspan="7" style="height: 30px;"></td></tr>
<tr><td colspan="7" style="height: 30px;"></td></tr>
<tr><td colspan="7" style="height: 30px;"></td></tr>
<tr><td colspan="7" style="height: 30px;"></td></tr>
<tr><td colspan="7" style="height: 30px;"></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section">
<div class="section-title">Chain of Custody</div>
<table>
<thead>
<tr>
<th style="width: 20%;">Date</th>
<th style="width: 20%;">Step</th>
<th style="width: 30%;">Technician</th>
<th style="width: 30%;">Notes</th>
</tr>
</thead>
<tbody>
<tr><td>________</td><td>Received</td><td>________________</td><td>________________</td></tr>
<tr><td>________</td><td>Testing</td><td>________________</td><td>________________</td></tr>
<tr><td>________</td><td>Erasure</td><td>________________</td><td>________________</td></tr>
<tr><td>________</td><td>Verification (5%)</td><td>________________</td><td>________________</td></tr>
<tr><td>________</td><td>Complete</td><td>________________</td><td>________________</td></tr>
</tbody>
</table>
</div>
<div class="warning">
⚠️ <strong>R2 REQUIREMENT:</strong> This pallet contains data-bearing equipment. All devices must be tracked through erasure with 5% verification audit.
</div>
<div class="footer">
<strong>Westech Electronics</strong> | R2 Certified Recycling | Phone: (555) 123-4567<br>
Printed: {{ doc.modified }} | Page 1 of 1 | <strong>KEEP WITH PALLET AT ALL TIMES</strong>
</div>
</body>
</html>
@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pallet Label - {{ doc.pallet_number or 'LOAD-PENDING' }}</title>
<style>
@page {
size: 6in 4in;
margin: 0.15in;
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
width: 6in;
height: 4in;
overflow: hidden;
}
.label {
width: 100%;
height: 100%;
border: 2px solid #000;
padding: 8px;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.service-level {
font-size: 48pt;
font-weight: 900;
text-align: center;
text-transform: uppercase;
color: #d63384;
border: 4px solid #d63384;
padding: 5px;
margin-bottom: 5px;
line-height: 1;
}
.pallet-number {
font-size: 24pt;
font-weight: bold;
text-align: center;
border-bottom: 2px solid #000;
padding-bottom: 3px;
margin-bottom: 5px;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 11pt;
margin-bottom: 3px;
}
.info-label {
font-weight: bold;
color: #333;
}
.info-value {
flex: 1;
margin-left: 5px;
border-bottom: 1px dotted #999;
}
.checkbox-row {
display: flex;
gap: 15px;
font-size: 10pt;
font-weight: bold;
margin-top: 3px;
padding-top: 3px;
border-top: 2px solid #000;
}
.checkbox {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #000;
margin-right: 4px;
vertical-align: middle;
}
.footer {
margin-top: auto;
font-size: 8pt;
text-align: center;
color: #666;
border-top: 1px solid #ccc;
padding-top: 3px;
}
</style>
</head>
<body>
<div class="label">
<div class="service-level">{{ doc.service_level or 'CLEAR' }}</div>
<div class="pallet-number">
{{ doc.pallet_number or 'PENDING' }}
</div>
<div class="info-row">
<span class="info-label">Load #:</span>
<span class="info-value">{{ doc.load or '________________' }}</span>
</div>
<div class="info-row">
<span class="info-label">Customer:</span>
<span class="info-value" style="font-size: 10pt;">{{ doc.customer_name or '________________________' }}</span>
</div>
<div class="info-row">
<span class="info-label">Weight:</span>
<span class="info-value">{{ doc.inbound_weight or '____' }} lbs</span>
</div>
<div class="info-row">
<span class="info-label">Date:</span>
<span class="info-value">{{ doc.received_date or '________' }}</span>
</div>
<div class="checkbox-row">
<span>
<span class="checkbox {% if doc.certificate %}checked{% endif %}"></span>COR/AoR
</span>
<span>
<span class="checkbox"></span>RED/R2
</span>
<span>
<span class="checkbox"></span>5% Audit
</span>
</div>
<div class="footer">
Westech Electronics | R2 Certified | Keep with pallet
</div>
</div>
</body>
</html>
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Purple Sheet - Load {{ doc.name }}</title>
<style>
@page { size: letter; margin: 0.5in; }
body { font-family: Arial, sans-serif; font-size: 11pt; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.logo { max-width: 200px; }
.title { font-size: 24pt; font-weight: bold; color: #6f42c1; }
.subtitle { font-size: 14pt; color: #666; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th, td { border: 1px solid #000; padding: 8px; text-align: left; }
th { background-color: #f0f0f0; font-weight: bold; }
.section { margin: 20px 0; }
.section-title { font-size: 14pt; font-weight: bold; background: #6f42c1; color: white; padding: 5px 10px; margin-bottom: 10px; }
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 15px; }
.info-field { border: 1px solid #ccc; padding: 8px; }
.info-label { font-size: 9pt; color: #666; margin-bottom: 3px; }
.info-value { font-size: 11pt; font-weight: bold; }
.footer { margin-top: 30px; border-top: 2px solid #6f42c1; padding-top: 10px; font-size: 9pt; color: #666; }
.service-level { font-size: 18pt; font-weight: bold; color: #d63384; text-transform: uppercase; }
.checkbox { display: inline-block; width: 15px; height: 15px; border: 1px solid #000; margin-right: 5px; }
.checked { background: #000; }
</style>
</head>
<body>
<div class="header">
<div>
<div class="title">PURPLE SHEET</div>
<div class="subtitle">Load Tracking Worksheet</div>
</div>
<div style="text-align: right;">
<div><strong>Load #:</strong> {{ doc.name }}</div>
<div><strong>Date:</strong> {{ doc.received_date or doc.modified }}</div>
</div>
</div>
<div class="section">
<div class="section-title">Customer Information</div>
<div class="info-grid">
<div class="info-field">
<div class="info-label">Customer Name</div>
<div class="info-value">{{ doc.customer_name or '________________' }}</div>
</div>
<div class="info-field">
<div class="info-label">Customer #</div>
<div class="info-value">{{ doc.customer_number or '________________' }}</div>
</div>
<div class="info-field">
<div class="info-label">Service Level</div>
<div class="info-value service-level">{{ doc.service_level or '________' }}</div>
</div>
<div class="info-field">
<div class="info-label">Address</div>
<div class="info-value">{{ doc.customer_address or '________________' }}</div>
</div>
<div class="info-field">
<div class="info-label">Contact</div>
<div class="info-value">{{ doc.contact_name or '________________' }}</div>
</div>
<div class="info-field">
<div class="info-label">Phone</div>
<div class="info-value">{{ doc.contact_phone or '________________' }}</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">Load Details</div>
<div class="info-grid">
<div class="info-field">
<div class="info-label">Status</div>
<div class="info-value">{{ doc.status or 'Received' }}</div>
</div>
<div class="info-field">
<div class="info-label">Data Status</div>
<div class="info-value">{{ doc.data_status or 'D0' }}</div>
</div>
<div class="info-field">
<div class="info-label">Inbound Weight (lbs)</div>
<div class="info-value">{{ doc.inbound_weight or '____' }}</div>
</div>
<div class="info-field">
<div class="info-label">Estimated Pallets</div>
<div class="info-value">{{ doc.estimated_pallets or '____' }}</div>
</div>
<div class="info-field">
<div class="info-label">Actual Pallets</div>
<div class="info-value">____</div>
</div>
<div class="info-field">
<div class="info-label">Driver</div>
<div class="info-value">________________</div>
</div>
</div>
<div style="margin-top: 10px;">
<span class="checkbox {% if doc.certificate %}checked{% endif %}"></span> COR/AoR Requested
</div>
</div>
<div class="section">
<div class="section-title">Material Breakdown</div>
<table>
<thead>
<tr>
<th style="width: 25%;">Material Type</th>
<th style="width: 15%;">Count</th>
<th style="width: 15%;">Weight (lbs)</th>
<th style="width: 15%;">Data Status</th>
<th style="width: 15%;">Send-To</th>
<th style="width: 15%;">Disposition</th>
</tr>
</thead>
<tbody>
{% for item in doc.items %}
<tr>
<td>{{ item.material_type or '________________' }}</td>
<td>{{ item.count or '____' }}</td>
<td>{{ item.weight or '____' }}</td>
<td>{{ item.data_status or '____' }}</td>
<td>{{ item.send_to or '________________' }}</td>
<td>{{ item.disposition or '________________' }}</td>
</tr>
{% else %}
<tr><td colspan="6" style="height: 30px;"></td></tr>
<tr><td colspan="6" style="height: 30px;"></td></tr>
<tr><td colspan="6" style="height: 30px;"></td></tr>
<tr><td colspan="6" style="height: 30px;"></td></tr>
<tr><td colspan="6" style="height: 30px;"></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section">
<div class="section-title">Pallet List</div>
<table>
<thead>
<tr>
<th style="width: 20%;">Pallet #</th>
<th style="width: 20%;">Status</th>
<th style="width: 20%;">Weight (lbs)</th>
<th style="width: 20%;">Data Status</th>
<th style="width: 20%;">Notes</th>
</tr>
</thead>
<tbody>
{% for pallet in doc.pallets %}
<tr>
<td>{{ doc.pallet_number or '________________' }}</td>
<td>{{ doc.status or '________' }}</td>
<td>{{ doc.inbound_weight or '____' }}</td>
<td>{{ doc.data_status or '____' }}</td>
<td>{{ doc.notes or '________________' }}</td>
</tr>
{% else %}
<tr><td colspan="5" style="height: 30px;"></td></tr>
<tr><td colspan="5" style="height: 30px;"></td></tr>
<tr><td colspan="5" style="height: 30px;"></td></tr>
<tr><td colspan="5" style="height: 30px;"></td></tr>
<tr><td colspan="5" style="height: 30px;"></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="footer">
<strong>Westech Electronics</strong> | R2 Certified Recycling | Phone: (555) 123-4567<br>
Printed: {{ doc.modified }} | Page 1 of 1
</div>
</body>
</html>
@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>R2 Special Handling Log - {{ doc.name }}</title>
<style>
@page { size: letter; margin: 0.5in; }
body { font-family: Arial, sans-serif; font-size: 10pt; }
.header { border: 3px solid #28a745; padding: 15px; margin-bottom: 15px; }
.title { font-size: 20pt; font-weight: bold; text-align: center; margin-bottom: 5px; color: #28a745; }
.subtitle { font-size: 11pt; text-align: center; margin-bottom: 15px; font-style: italic; }
.info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 15px; }
.info-field { }
.info-label { font-weight: bold; display: inline-block; width: 70px; }
.info-value { border-bottom: 1px solid #000; display: inline-block; min-width: 180px; padding: 2px 5px; }
.client-block { border: 1px solid #000; padding: 10px; margin: 10px 0; background: #f9f9f9; }
.section { border: 1px solid #000; padding: 10px; margin: 10px 0; }
.section-title { font-size: 11pt; font-weight: bold; background: #28a745; color: white; padding: 5px 10px; margin: -10px -10px 10px -10px; border-bottom: 1px solid #000; }
.red-line { border: 2px solid #d63384; }
.red-line .section-title { background: #d63384; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { border: 1px solid #000; padding: 6px; text-align: left; font-size: 9pt; }
th { background: #f0f0f0; font-weight: bold; }
.instructions-list { list-style: none; padding: 0; margin: 0; }
.instructions-list li { margin: 5px 0; padding-left: 15px; position: relative; }
.instructions-list li:before { content: "•"; position: absolute; left: 0; font-weight: bold; }
.checkbox { display: inline-block; width: 14px; height: 14px; border: 1px solid #000; margin-right: 5px; vertical-align: middle; }
.footer { margin-top: 20px; font-size: 8pt; text-align: center; border-top: 2px solid #28a745; padding-top: 10px; }
</style>
</head>
<body>
<div class="header">
<div class="title">R2: SPECIAL HANDLING LOG</div>
<div class="subtitle">COR must include: {{ doc.job_number or 'Job #________' }}</div>
<div class="info-grid">
<div class="info-field">
<span class="info-label">Date:</span>
<span class="info-value">{{ doc.received_date or nowdate() }}</span>
</div>
<div class="info-field">
<span class="info-label">Truck #:</span>
<span class="info-value">{{ doc.truck_number or '____' }}</span>
</div>
<div class="info-field">
<span class="info-label">Driver:</span>
<span class="info-value">{{ doc.driver or '________________' }}</span>
</div>
<div class="info-field">
<span class="info-label">Service Level:</span>
<span class="info-value" style="font-weight:bold; color:#d63384;">{{ doc.service_level or '________' }}</span>
</div>
</div>
<div class="client-block">
<div class="info-field" style="margin-bottom: 8px;">
<span class="info-label">Client:</span>
<span class="info-value" style="min-width: 400px; font-weight:bold;">{{ doc.customer_name or '________________________________' }}</span>
</div>
<div class="info-field" style="margin-bottom: 8px;">
<span class="info-label">Address:</span>
<span class="info-value" style="min-width: 400px;">{{ doc.customer_address or '________________________________________' }}</span>
</div>
<div class="info-grid" style="margin-bottom: 0;">
<div class="info-field">
<span class="info-label">Contact:</span>
<span class="info-value">{{ doc.contact_name or '________________' }}</span>
</div>
<div class="info-field">
<span class="info-label">Email:</span>
<span class="info-value">{{ doc.contact_email or '________________' }}</span>
</div>
<div class="info-field">
<span class="info-label">Phone:</span>
<span class="info-value">{{ doc.contact_phone or '________________' }}</span>
</div>
</div>
</div>
<div class="info-field">
<span class="info-label">Equipment:</span>
<span class="info-value" style="min-width: 400px;">{{ doc.equipment_description or '________________________________________' }}</span>
</div>
<div class="info-field" style="margin-top: 10px;">
<span class="info-label">Driver Notes:</span><br>
<div style="border: 1px solid #000; min-height: 40px; padding: 5px; margin-top: 5px;">
{{ doc.driver_notes or '' }}
</div>
</div>
</div>
<div class="section red-line">
<div class="section-title">RED LINE INSTRUCTIONS</div>
<ul class="instructions-list">
<li>Remove all asset tags/company identifiers from the equipment</li>
<li>Weigh and record weight for COR. Company Name on COR should be <strong>{{ doc.customer_name or '________' }}</strong></li>
<li>Load must be processed within 30 days of retrieval</li>
<li>Forward the COR to David Huff upon completion of Job. David will upload the COR and report to the RecycleGX platform to complete process within 30 days.</li>
<li>COR must include Job #<strong>{{ doc.job_number or '____' }}</strong></li>
<li>Westech is charging $<strong>{{ doc.service_charge or '____' }}</strong> for this equipment. RGX will send payment to Westech when David Huff closes the job on the portal.</li>
</ul>
</div>
<div class="section">
<div class="section-title">Material Weight Log</div>
<table>
<thead>
<tr>
<th style="width: 35%;">MATERIAL</th>
<th style="width: 15%;">% OF MATERIAL</th>
<th style="width: 15%;">WEIGHT</th>
<th style="width: 15%;">SIGN OFF</th>
<th style="width: 20%;">DATE</th>
</tr>
</thead>
<tbody>
{% for item in doc.items %}
<tr>
<td>{{ item.material_type or '________________' }}</td>
<td>{{ item.percentage or '____%' }}</td>
<td>{{ item.weight or '____' }}</td>
<td>{{ item.sign_off or '____' }}</td>
<td>{{ item.date or '________' }}</td>
</tr>
{% else %}
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section">
<div class="section-title">PC/Server Hard Drive Tracking</div>
<table>
<thead>
<tr>
<th style="width: 20%;">Category</th>
<th style="width: 20%;">SCAN HARD DRIVES</th>
<th style="width: 20%;">LIST SENT?</th>
<th style="width: 20%;">SIGN OFF</th>
<th style="width: 20%;">DATE</th>
</tr>
</thead>
<tbody>
<tr>
<td><b># OF PCs</b></td>
<td>{{ doc.pc_hdd_count or '____' }}</td>
<td style="text-align:center;"><span class="checkbox"></span></td>
<td>____</td>
<td>________</td>
</tr>
<tr>
<td><b># OF SERVERS</b></td>
<td>{{ doc.server_hdd_count or '____' }}</td>
<td style="text-align:center;"><span class="checkbox"></span></td>
<td>____</td>
<td>________</td>
</tr>
<tr>
<td><b># OF HARD DRIVES</b></td>
<td>{{ doc.loose_hdd_count or '____' }}</td>
<td style="text-align:center;"><span class="checkbox"></span></td>
<td>____</td>
<td>________</td>
</tr>
</tbody>
</table>
</div>
<div class="section">
<div class="section-title">Laptop Hard Drive Tracking</div>
<table>
<thead>
<tr>
<th style="width: 20%;">Category</th>
<th style="width: 20%;">SCAN HARD DRIVES</th>
<th style="width: 20%;">LIST SENT?</th>
<th style="width: 20%;">SIGN OFF</th>
<th style="width: 20%;">DATE</th>
</tr>
</thead>
<tbody>
<tr>
<td><b># OF LAPTOPS</b></td>
<td>{{ doc.laptop_count or '____' }}</td>
<td style="text-align:center;"><span class="checkbox"></span></td>
<td>____</td>
<td>________</td>
</tr>
<tr>
<td><b># OF HARD DRIVES</b></td>
<td>{{ doc.laptop_hdd_count or '____' }}</td>
<td style="text-align:center;"><span class="checkbox"></span></td>
<td>____</td>
<td>________</td>
</tr>
</tbody>
</table>
</div>
<div class="footer">
<strong>Westech Electronics</strong> | R2 Certified Recycling | RecycleGX Platform<br>
Load: {{ doc.name }} | Printed: {{ doc.modified }} | Page 1 of 1<br>
<em>Forward COR to: David Huff | Job #{{ doc.job_number or '____' }}</em>
</div>
</body>
</html>
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>R2 Controlled Stream Label</title>
<style>
@page { size: 8.5in 11in; margin: 0.5in; }
body { font-family: Arial, sans-serif; font-size: 12pt; }
.label { border: 3px solid #000; padding: 20px; max-width: 7in; margin: 0 auto; }
.title { font-size: 24pt; font-weight: bold; text-align: center; margin-bottom: 20px; }
.section { margin: 15px 0; }
.field { margin: 10px 0; }
.field-label { font-weight: bold; display: inline-block; width: 200px; }
.field-value { border-bottom: 1px solid #000; display: inline-block; min-width: 200px; padding: 3px 10px; }
.checkbox-group { margin: 15px 0; }
.checkbox-row { margin: 8px 0; }
.checkbox { display: inline-block; width: 18px; height: 18px; border: 2px solid #000; margin-right: 8px; vertical-align: middle; }
.checked { background: #000; }
.weight-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin: 20px 0; }
.weight-field { text-align: center; }
.weight-label { font-weight: bold; margin-bottom: 5px; }
.weight-value { border: 2px solid #000; padding: 10px; font-size: 14pt; min-height: 30px; }
.footer { margin-top: 30px; font-size: 9pt; text-align: center; border-top: 1px solid #000; padding-top: 10px; }
</style>
</head>
<body>
<div class="label">
<div class="title">R2 Controlled Stream</div>
<div class="section">
<div class="field">
<span class="field-label">Accumulation Start Date:</span>
<span class="field-value">{{ doc.received_date or '________________________' }}</span>
</div>
</div>
<div class="section">
<div class="field-label">Data Sanitization Status</div>
<div style="font-size: 10pt; color: #666;">(Data: D0-D1 or No Data: ND1-ND2)</div>
<div class="checkbox-group">
<div class="checkbox-row">
<span class="checkbox"></span> Destruction
<span style="margin-left: 30px;">
<span class="checkbox"></span> Wiping
</span>
<span style="margin-left: 30px;">
<span class="checkbox"></span> Digital Overwriting
</span>
</div>
</div>
</div>
<div class="section">
<div class="checkbox-group">
<div class="checkbox-row">
<span class="checkbox"></span> Circuit Boards
<span class="field-value" style="margin-left: 10px; min-width: 150px;">{{ doc.circuit_boards or '______________' }} Type</span>
</div>
<div class="checkbox-row">
<span class="checkbox"></span> CRTs
<span class="field-value" style="margin-left: 10px; min-width: 150px;">{{ doc.crt_qty or '______________' }} Total Qty</span>
</div>
<div class="checkbox-row">
<span class="checkbox"></span> Universal Waste Mercury Containing Device
<div style="margin-left: 20px; font-size: 10pt;">Maintained in closed container unless actively working materials</div>
</div>
<div class="checkbox-row">
<span class="checkbox"></span> Universal Waste Lamps
<div style="margin-left: 20px; font-size: 10pt;">Maintained in closed container unless actively working materials</div>
</div>
<div class="checkbox-row">
<span class="checkbox"></span> Universal Waste Batteries
<div style="margin-left: 20px; font-size: 10pt;">Maintained in closed container unless actively working materials</div>
</div>
<div class="checkbox-row">
<span class="checkbox"></span> Electronic Device
<span class="field-value" style="margin-left: 10px; min-width: 200px;">{{ doc.device_type or '________________________' }}</span>
<div style="margin-left: 20px; font-size: 9pt;">Whole Device (for example: laptop, desktop, modem, IP phone)</div>
</div>
</div>
</div>
<div class="weight-grid">
<div class="weight-field">
<div class="weight-label">Gross Wt</div>
<div class="weight-value">{{ doc.gross_weight or '__________' }}</div>
</div>
<div class="weight-field">
<div class="weight-label">Tare Wt</div>
<div class="weight-value">{{ doc.tare_weight or '__________' }}</div>
</div>
<div class="weight-field">
<div class="weight-label">Net Wt</div>
<div class="weight-value">{{ doc.net_weight or '__________' }}</div>
</div>
</div>
<div class="section">
<div class="field">
<span class="field-label">Accumulation End Date:</span>
<span class="field-value">{{ doc.end_date or '________________________' }}</span>
<span style="font-size: 9pt; color: #666; margin-left: 20px;">Required for data containing items</span>
</div>
</div>
<div class="footer">
<strong>4.4.6.3-F R2 UW Label GA FL AZ</strong> | Effective date: 7.6.23<br>
Pallet: {{ doc.pallet_number or '______________' }} | Load: {{ doc.load or '______________' }}
</div>
</div>
</body>
</html>
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Special Handling Log - {{ load.name }}</title>
<style>
@page { size: letter; margin: 0.5in; }
body { font-family: Arial, sans-serif; font-size: 10pt; }
.header { border: 2px solid #000; padding: 10px; margin-bottom: 15px; }
.title { font-size: 18pt; font-weight: bold; text-align: center; margin-bottom: 5px; }
.subtitle { font-size: 12pt; text-align: center; margin-bottom: 10px; }
.info-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-bottom: 10px; }
.info-field { }
.info-label { font-weight: bold; display: inline-block; width: 80px; }
.info-value { border-bottom: 1px solid #000; display: inline-block; min-width: 200px; }
.section { border: 1px solid #000; padding: 10px; margin: 10px 0; }
.section-title { font-size: 11pt; font-weight: bold; background: #f0f0f0; padding: 5px; margin: -10px -10px 10px -10px; border-bottom: 1px solid #000; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { border: 1px solid #000; padding: 6px; text-align: left; font-size: 9pt; }
th { background: #f0f0f0; font-weight: bold; }
.pricing-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px; font-size: 9pt; }
.pricing-item { margin: 3px 0; }
.footer { margin-top: 20px; font-size: 8pt; text-align: center; border-top: 2px solid #000; padding-top: 10px; }
.red-line { border: 2px solid #d63384; }
.checkbox { display: inline-block; width: 14px; height: 14px; border: 1px solid #000; margin-right: 5px; }
</style>
</head>
<body>
<div class="header">
<div class="title">SPECIAL HANDLING LOG</div>
<div class="subtitle">NIST Wipe for Hard Drives | COD Required (Include hard drive count)</div>
<div class="info-grid">
<div class="info-field">
<span class="info-label">DATE:</span>
<span class="info-value">{{ load.received_date or nowdate() }}</span>
</div>
<div class="info-field">
<span class="info-label">TRUCK:</span>
<span class="info-value">{{ load.truck or '____' }}</span>
</div>
<div class="info-field">
<span class="info-label">DRIVER:</span>
<span class="info-value">{{ load.driver or '________________' }}</span>
</div>
<div class="info-field">
<span class="info-label">Service Level:</span>
<span class="info-value" style="font-weight:bold; color:#d63384;">{{ load.service_level or '________' }}</span>
</div>
</div>
<div class="info-grid">
<div class="info-field" style="grid-column: span 2;">
<span class="info-label">School/Customer:</span>
<span class="info-value" style="min-width: 400px;">{{ load.customer_name or '________________________________' }}</span>
</div>
<div class="info-field" style="grid-column: span 2;">
<span class="info-label">Address:</span>
<span class="info-value" style="min-width: 400px;">{{ load.customer_address or '________________________________________' }}</span>
</div>
<div class="info-field">
<span class="info-label">Contact:</span>
<span class="info-value">{{ load.contact_name or '________________' }}</span>
</div>
<div class="info-field">
<span class="info-label">Phone:</span>
<span class="info-value">{{ load.contact_phone or '________________' }}</span>
</div>
</div>
<div style="margin-top: 10px;">
<div class="info-label">Pickup Instructions:</div>
<div style="border: 1px solid #000; padding: 8px; min-height: 40px;">
{{ load.pickup_instructions or '________________________________________________________________' }}
</div>
</div>
</div>
<div class="section red-line">
<div class="section-title" style="background: #d63384; color: white;">RED LINE ENTRY ITEMS</div>
<div class="pricing-grid">
<div class="pricing-item"><b>Laptops</b> (Working): $40 each | (Non-working): $.15/lb</div>
<div class="pricing-item"><b>Desktops</b> (Working): $10 each | (Non-working): $.15/lb</div>
<div class="pricing-item"><b>Tablets</b> (Working): $3 each | (Non-working): $.15/lb</div>
<div class="pricing-item"><b>Servers</b> (Working): $40 each | (Non-working): $.15/lb</div>
<div class="pricing-item"><b>Main Frames</b> (Working): $75 each | (Non-working): $.15/lb</div>
<div class="pricing-item"><b>High Grade</b>: Chromebooks, projectors, cell phones, switches ($.15/lb)</div>
<div class="pricing-item"><b>Wire/Boards</b>: Power supplies, circuits, cameras ($.05/lb)</div>
<div class="pricing-item"><b>No Value</b>: Printers, monitors, TVs, keyboards, mice, batteries ($.00/lb)</div>
</div>
<div style="margin-top: 10px; padding: 8px; border: 1px solid #d63384;">
<b>Hard Drives:</b> Please give all PCs, Laptops, Servers, Loose Hard Drives to HDR<br>
☐ NIST Wipe (No charge) &nbsp;&nbsp;&nbsp; ☐ COD: Include Hard Drive Count
</div>
</div>
<div class="section">
<div class="section-title">Materials / Weight Tracking</div>
<table>
<thead>
<tr>
<th style="width: 35%;">MATERIAL</th>
<th style="width: 15%;">% OF MATERIAL</th>
<th style="width: 15%;">WEIGHT</th>
<th style="width: 15%;">SIGN OFF</th>
<th style="width: 20%;">DATE</th>
</tr>
</thead>
<tbody>
{% for item in load.items %}
<tr>
<td>{{ item.material_type or '________________' }}</td>
<td>{{ item.percentage or '100%' }}</td>
<td>{{ item.weight or '____' }}</td>
<td>{{ item.sign_off or '____' }}</td>
<td>{{ item.date or '________' }}</td>
</tr>
{% else %}
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
<tr><td colspan="5" style="height: 25px;"></td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section">
<div class="section-title">Hard Drive Tracking</div>
<table>
<thead>
<tr>
<th style="width: 20%;">Category</th>
<th style="width: 20%;">SCAN HARD DRIVES</th>
<th style="width: 20%;">LIST SENT?</th>
<th style="width: 20%;">SIGN OFF</th>
<th style="width: 20%;">DATE</th>
</tr>
</thead>
<tbody>
<tr>
<td><b># OF PCs</b></td>
<td>{{ load.pc_count or '____' }}</td>
<td></td>
<td>____</td>
<td>________</td>
</tr>
<tr>
<td><b># OF SERVERS</b></td>
<td>{{ load.server_count or '____' }}</td>
<td></td>
<td>____</td>
<td>________</td>
</tr>
<tr>
<td><b># OF HARD DRIVES</b></td>
<td>{{ load.hd_count or '____' }}</td>
<td></td>
<td>____</td>
<td>________</td>
</tr>
<tr>
<td><b># OF LAPTOPS</b></td>
<td>{{ load.laptop_count or '____' }}</td>
<td></td>
<td>____</td>
<td>________</td>
</tr>
<tr>
<td><b>HARD DRIVES (loose)</b></td>
<td>{{ load.loose_hd_count or '____' }}</td>
<td></td>
<td>____</td>
<td>________</td>
</tr>
</tbody>
</table>
</div>
<div class="footer">
<strong>Westech Electronics</strong> | R2 Certified Recycling<br>
Load: {{ load.name }} | Printed: {{ doc.modified }} | Page 1 of 1
</div>
</body>
</html>
-1
View File
@@ -1 +0,0 @@
.
+1
View File
@@ -0,0 +1 @@
__version__ = "0.0.1"
+3
View File
@@ -0,0 +1,3 @@
from westech_r2.api import sales
from westech_r2.api import receiving_api
+166
View File
@@ -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 &nbsp;&nbsp; '
'<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'
+546
View File
@@ -0,0 +1,546 @@
import sys
sys.path.insert(0, '/home/frappe/erpnext-bench/apps/frappe')
import frappe
from frappe.utils import now, now_datetime, flt
from frappe import _
import json
import re
import time
import urllib.parse
OXYLABS_API = "https://realtime.oxylabs.io/v1/queries"
ACTOR_ID = "caffein.dev~ebay-sold-listings"
API_BASE = "https://api.apify.com/v2"
MFR_CLEANUP = {
"Dell Inc": "Dell", "HP HP": "HP", "HP": "HP",
"LENOVO": "Lenovo", "Lenovo": "Lenovo",
"Microsoft Corporation": "Microsoft", "Apple Inc": "Apple",
"ASUSTeK COMPUTER INC.": "ASUS", "Acer": "Acer",
"Panasonic": "Panasonic", "Samsung": "Samsung",
"Toshiba": "Toshiba", "Fujitsu": "Fujitsu",
"Hewlett-Packard": "HP", "HUAWEI": "Huawei",
}
def _get_settings():
if not frappe.db.exists("eBay Pricing Settings", "eBay Pricing Settings"):
return None
return frappe.get_doc("eBay Pricing Settings", "eBay Pricing Settings")
def _get_oxylabs_creds():
settings = _get_settings()
user = password = ""
if settings:
user = settings.get("oxylabs_user") or ""
try:
password = settings.get_password("oxylabs_password") or ""
except Exception:
pass
if not user:
user = frappe.conf.get("oxylabs_user", "")
if not password:
password = frappe.conf.get("oxylabs_password", "")
return (user, password)
def _get_apify_token():
settings = _get_settings()
token = ""
if settings:
try:
token = settings.get_password("apify_token") or ""
except Exception:
pass
if not token:
token = frappe.conf.get("apify_token", "")
return token
def clean_manufacturer(mfr):
return MFR_CLEANUP.get(mfr, mfr)
@frappe.whitelist()
def search_model(query=None, manufacturer=None, model=None, source="auto"):
if not query and not (manufacturer and model):
return {"error": "Provide query or manufacturer + model"}
if not manufacturer or not model:
parts = (query or "").split(None, 1)
if len(parts) >= 2:
manufacturer, model = parts[0], parts[1]
else:
manufacturer, model = query, ""
items, used_source = _search_ebay_sold(model, manufacturer, source=source)
pricing = _parse_prices(items, manufacturer, model, source=used_source or "unknown")
if pricing:
_upsert_system_pricing(manufacturer, model, pricing)
_update_item_market_data(manufacturer, model, pricing)
_log_api_call(manufacturer, model, query, used_source,
len(items) if items else 0, "Success")
return {"results": items or [], "pricing": pricing}
else:
_log_api_call(manufacturer, model, query, used_source, 0, "Failed")
return {"results": items or [], "pricing": None,
"message": "No pricing data found"}
def _search_ebay_sold_oxylabs(model, manufacturer):
user, password = _get_oxylabs_creds()
if not user or not password:
return None
import requests as req_module
clean_mfr = clean_manufacturer(manufacturer)
query = f"{clean_mfr} {model}"
payloads = [
{
"source": "universal",
"url": f"https://www.ebay.com/sch/i.html?_nkw={urllib.parse.quote(query)}&LH_Sold=1&_ipg=240",
"render": "html",
},
{
"source": "ebay_search",
"query": query,
"domain": "com",
"render": "html",
},
]
for payload in payloads:
try:
resp = req_module.post(OXYLABS_API, auth=(user, password),
json=payload, timeout=120)
if resp.status_code != 200:
continue
data = resp.json()
if "results" not in data or not data["results"]:
continue
content = data["results"][0].get("content", "")
if not isinstance(content, str) or len(content) < 100000:
continue
listings = _parse_ebay_html(content)
if listings and len(listings) >= 3:
return listings
except Exception:
continue
return None
def _parse_ebay_html(content):
listings = []
card_blocks = re.split(r'class="s-card\s', content)
for block in card_blocks[1:]:
listing = {}
price_m = re.search(
r's-card__price[^>]*>\$([\d,.]+(?:\.\d{2})?)(?:\s*to\s*\$[\d,.]+(?:\.\d{2})?)?</span>',
block,
)
if price_m:
listing["price"] = float(price_m.group(1).replace(",", ""))
title_m = re.search(r's-card__title[^>]*><span[^>]*>([^<]+)</span>', block)
if title_m:
listing["title"] = title_m.group(1).strip()
if listing["title"].lower() in ("shop on ebay", ""):
continue
else:
heading_m = re.search(r'role=heading[^>]*>(.*?)</(?:div|span|h\d)>',
block, re.DOTALL)
if heading_m:
title_text = re.sub(r'<[^>]+>', '', heading_m.group(1)).strip()
if title_text.lower() != "shop on ebay":
listing["title"] = title_text
sold_m = re.search(r'(\d[\d,]*)\s+sold', block, re.IGNORECASE)
if sold_m:
listing["sold"] = int(sold_m.group(1).replace(",", ""))
if re.search(r'Free (?:shipping|delivery|Standard Shipping)',
block, re.IGNORECASE):
listing["shipping"] = "Free"
else:
ship_m = re.search(
r'\+\$?([\d,.]+)\s+(?:shipping|delivery|Standard Shipping)',
block, re.IGNORECASE,
)
if ship_m:
listing["shipping"] = float(ship_m.group(1).replace(",", ""))
else:
ship_alt = re.search(r'\$([\d,.]+)\s+(?:shipping|delivery)',
block, re.IGNORECASE)
if ship_alt:
listing["shipping"] = float(ship_alt.group(1).replace(",", ""))
cond_m = re.search(
r'(Pre-Owned|Used|Brand New|New \(Other\)|Refurbished|Open Box|'
r'For parts or not working|Seller refurbished|New with defects|'
r'New with box|New without box|New with tags)',
block, re.IGNORECASE,
)
if cond_m:
listing["condition"] = cond_m.group(1)
if listing.get("price") or listing.get("title"):
listings.append(listing)
return listings if listings else None
def _search_ebay_sold_apify(model, manufacturer):
token = _get_apify_token()
if not token:
return None
import requests as req_module
clean_mfr = clean_manufacturer(manufacturer)
query = f"{clean_mfr} {model}"
run_input = {
"keywords": [query],
"daysToScrape": 60,
"count": 30,
"categoryId": "0",
"ebaySite": "ebay.com",
"sortOrder": "endedRecently",
"itemCondition": "any",
"itemLocation": "domestic",
}
url = f"{API_BASE}/acts/{ACTOR_ID}/runs?token={token}"
try:
resp = req_module.post(url, json=run_input, timeout=30)
result = resp.json()
run_id = result["data"]["id"]
dataset_id = result["data"].get("defaultDatasetId")
except Exception:
return None
max_wait = 120
start = time.time()
while time.time() - start < max_wait:
time.sleep(8)
try:
status_url = f"{API_BASE}/actor-runs/{run_id}?token={token}"
status_resp = req_module.get(status_url, timeout=10)
status = status_resp.json()
run_status = status["data"]["status"]
if run_status == "SUCCEEDED":
break
elif run_status in ("FAILED", "ABORTED", "TIMED-OUT"):
return None
except Exception:
continue
try:
results_url = f"{API_BASE}/datasets/{dataset_id}/items?token={token}&limit=30&clean=true"
results_resp = req_module.get(results_url, timeout=15)
return results_resp.json()
except Exception:
return None
def _search_ebay_sold(model, manufacturer, source="auto"):
if source in ("auto", "oxylabs"):
result = _search_ebay_sold_oxylabs(model, manufacturer)
if result is not None:
return result, "oxylabs"
if source in ("auto", "apify"):
result = _search_ebay_sold_apify(model, manufacturer)
if result is not None:
return result, "apify"
return None, None
def _parse_prices(items, manufacturer, model, source="oxylabs"):
if not items:
return None
prices = []
for item in items:
if item.get("error"):
continue
if source == "oxylabs":
price_val = item.get("price")
if isinstance(price_val, str):
price_str = price_val.replace("$", "").replace(",", "").strip()
if " to " in price_str:
price_str = price_str.split(" to ")[0]
try:
price_val = float(price_str)
except (ValueError, TypeError):
continue
if not isinstance(price_val, (int, float)):
continue
p = float(price_val)
if 5 < p < 10000:
title = item.get("title", "").upper()
model_upper = model.upper()
model_words = model_upper.split()
if len(items) > 5:
short_model_words = [w for w in model_words if len(w) > 2]
if short_model_words and not any(w in title for w in short_model_words):
continue
prices.append(p)
else:
price_str = item.get("totalPrice") or item.get("soldPrice")
if not price_str:
continue
try:
p = float(str(price_str).replace(",", "").replace("$", "").strip())
if 5 < p < 10000:
prices.append(p)
except (ValueError, TypeError):
continue
if not prices:
return None
prices.sort()
if len(prices) >= 5:
trim = max(1, int(len(prices) * 0.1))
trimmed = prices[trim : len(prices) - trim]
if trimmed:
prices = trimmed
if not prices:
return None
avg = sum(prices) / len(prices)
median = prices[len(prices) // 2]
return {
"price_high": round(max(prices), 2),
"price_low": round(min(prices), 2),
"price_average": round(avg, 2),
"price_auction": round(median, 2),
"sample_count": len(prices),
"source": source or "unknown",
"scraped_at": now(),
}
def _upsert_system_pricing(manufacturer, model, pricing):
existing = frappe.db.get_value(
"System Pricing",
{"manufacturer": manufacturer, "model": model},
"name",
)
doc = None
if existing:
doc = frappe.get_doc("System Pricing", existing)
else:
doc = frappe.new_doc("System Pricing")
doc.manufacturer = manufacturer
doc.model = model
for key in ("price_high", "price_low", "price_average", "price_auction",
"sample_count", "scraped_at"):
if key in pricing:
setattr(doc, key, pricing[key])
# Fix source to match allowed values
if "source" in pricing:
raw_source = pricing["source"]
if raw_source.startswith("ebay_"):
raw_source = raw_source.replace("ebay_", "")
if raw_source not in ("oxylabs", "apify"):
raw_source = "unknown"
setattr(doc, "source", raw_source)
if doc.scraped_at:
scraped = frappe.utils.get_datetime(doc.scraped_at)
now = now_datetime()
doc.days_since_pricing = (now - scraped).days
else:
doc.days_since_pricing = 0
doc.pricing_status = "Priced"
doc.save()
frappe.db.commit()
def _update_item_market_data(manufacturer, model, pricing):
clean_mfr = clean_manufacturer(manufacturer)
model_lower = model.lower()
# Match by brand (exact) + item_name (contains model)
items = frappe.get_all(
"Item",
filters={
"item_group": ["in", ["Laptop", "Desktop", "Tablet", "Phone", "Workstation"]],
"disabled": 0,
"brand": clean_mfr,
},
fields=["name", "item_name"],
)
matched = None
for item in items:
item_name_lower = (item.item_name or "").lower()
if model_lower in item_name_lower:
matched = item.name
break
if not matched:
return
item_doc = frappe.get_doc("Item", matched)
item_doc.base_market_price = pricing.get("price_average", 0)
item_doc.market_high = pricing.get("price_high", 0)
item_doc.market_low = pricing.get("price_low", 0)
item_doc.market_median = pricing.get("price_auction", 0)
item_doc.market_average = pricing.get("price_average", 0)
item_doc.market_samples = pricing.get("sample_count", 0)
item_doc.market_last_priced = pricing.get("scraped_at")
item_doc.save()
frappe.db.commit()
return {"item": matched, "updated": True}
def _log_api_call(manufacturer, model, search_query, source, results_count, status):
try:
log = frappe.new_doc("eBay Pricing Log")
log.manufacturer = manufacturer
log.model = model
log.search_query = search_query
log.source = source or "unknown"
log.timestamp = now()
log.results_count = results_count or 0
log.status = status
log.save()
frappe.db.commit()
except Exception:
pass
@frappe.whitelist()
def run_batch(batch_size=10, source="auto", force=False):
batch_size = int(batch_size) if batch_size != "all" else 999999
force = bool(force)
# Get unique models from Items that have serials
models = frappe.db.sql(
"""
SELECT DISTINCT i.brand as manufacturer, i.item_name as model
FROM `tabItem` i
INNER JOIN `tabSerial No` sn ON sn.item_code = i.name
WHERE i.brand IS NOT NULL AND i.brand != ''
AND i.disabled = 0
ORDER BY i.modified DESC
LIMIT %s
""",
(batch_size,),
as_dict=True,
)
priced = failed = skipped = 0
for row in models:
mfr = row.manufacturer
mdl = row.model
if not force:
exists = frappe.db.exists("System Pricing", {"manufacturer": mfr, "model": mdl})
if exists:
skipped += 1
continue
items, used_source = _search_ebay_sold(mdl, mfr, source=source)
pricing = _parse_prices(items, mfr, mdl, source=used_source or "unknown")
if pricing:
_upsert_system_pricing(mfr, mdl, pricing)
_update_item_market_data(mfr, mdl, pricing)
_log_api_call(mfr, mdl, f"{mfr} {mdl}", used_source,
len(items) if items else 0, "Success")
priced += 1
else:
_log_api_call(mfr, mdl, f"{mfr} {mdl}", used_source, 0, "Failed")
failed += 1
if used_source == "oxylabs":
time.sleep(2)
else:
time.sleep(3)
return {"priced": priced, "failed": failed, "skipped": skipped, "total": len(models)}
@frappe.whitelist()
def get_recent_pricing(limit=50, status_filter=None):
filters = {}
if status_filter:
filters["pricing_status"] = status_filter
records = frappe.get_all(
"System Pricing",
filters=filters,
fields=[
"name", "manufacturer", "model", "pricing_status",
"scraped_at", "days_since_pricing",
"price_high", "price_low", "price_average",
"sample_count", "source",
],
order_by="scraped_at desc",
limit=int(limit),
)
for r in records:
r["days_since_pricing"] = r.get("days_since_pricing") or 0
for key in ("price_high", "price_low", "price_average"):
if r.get(key) is not None:
r[key] = round(r[key], 2)
return records
@frappe.whitelist()
def apply_serial_pricing(serial_no):
serial = frappe.get_doc("Serial No", serial_no)
if not serial.item_code:
return {"error": "No item linked"}
item = frappe.get_doc("Item", serial.item_code)
grade = serial.grade
price_point = serial.price_point
if not grade:
serial.pricing_status = "Needs Grading"
serial.save()
return {"status": "needs_grading"}
if grade in ("C", "D", "F"):
serial.assigned_price = item.commodity_flat_price or 0
serial.commodity_value = item.commodity_flat_price or 0
serial.pricing_status = "Commodity"
serial.pricing_source = item.name
serial.save()
frappe.db.commit()
return {"status": "commodity", "price": serial.assigned_price}
if not price_point:
serial.pricing_status = "Needs Pricing"
serial.save()
return {"status": "needs_price_point"}
base_price = 0
if price_point == "Low":
base_price = item.market_low or item.base_market_price or 0
elif price_point == "Median":
base_price = item.market_median or item.base_market_price or 0
elif price_point == "Average":
base_price = item.market_average or item.base_market_price or 0
elif price_point == "High":
base_price = item.market_high or item.base_market_price or 0
elif price_point == "Manual":
base_price = serial.manual_price or 0
multiplier = 1.0
if grade == "A":
multiplier = item.grade_a_multiplier or 1.0
elif grade == "B":
multiplier = item.grade_b_multiplier or 0.8
final_price = flt(base_price) * flt(multiplier)
serial.assigned_price = round(final_price, 2)
serial.pricing_status = "Priced" if price_point != "Manual" else "Manual Override"
serial.pricing_source = item.name
serial.save()
frappe.db.commit()
return {
"status": serial.pricing_status,
"price": serial.assigned_price,
"base": base_price,
"multiplier": multiplier,
}
@frappe.whitelist()
def batch_apply_pricing(item_code=None, batch_size=50):
filters = {"pricing_status": ["in", ["Needs Grading", "Needs Pricing", "Priced"]]}
if item_code:
filters["item_code"] = item_code
serials = frappe.get_all(
"Serial No",
filters=filters,
fields=["name", "item_code", "grade", "price_point"],
limit=int(batch_size),
)
results = {"priced": 0, "commodity": 0, "needs_grading": 0, "needs_price_point": 0, "errors": 0}
for s in serials:
try:
result = apply_serial_pricing(s.name)
status = result.get("status", "")
if status == "commodity":
results["commodity"] += 1
elif status == "needs_grading":
results["needs_grading"] += 1
elif status == "needs_price_point":
results["needs_price_point"] += 1
elif status in ("Priced", "Manual Override"):
results["priced"] += 1
else:
results["errors"] += 1
except Exception as e:
frappe.log_error(f"Pricing error for {s.name}: {e}", "eBay Pricing")
results["errors"] += 1
return results
+6
View File
@@ -0,0 +1,6 @@
import frappe
@frappe.whitelist()
def install_ssh():
"""Placeholder for SSH install functionality."""
return {"success": True, "message": "SSH install endpoint ready"}
@@ -0,0 +1,127 @@
import json
import re
import frappe
@frappe.whitelist()
def optimize_routes(pickup_date=None):
"""Optimize routes for all trucks on a given date."""
if not pickup_date:
pickup_date = frappe.utils.today()
trucks = frappe.get_list("Truck Profile",
filters={"active": 1},
fields=["name", "truck_name", "total_slots", "weight_capacity"]
)
pickups = frappe.get_list("Scheduled Pickup",
filters={
"pickup_date": pickup_date,
"shipment_type": "Truck",
"truck_profile": ["is", "not set"]
},
fields=["name", "customer_number", "company_name", "estimated_items",
"estimated_weight", "gaylord_count", "gaylord_sizes", "slots_needed",
"latitude", "longitude"],
order_by="stop_order asc"
)
for pickup in pickups:
if not pickup.slots_needed and pickup.gaylord_sizes:
pickup.slots_needed = _calculate_slots(pickup.gaylord_sizes)
elif not pickup.slots_needed:
pickup.slots_needed = pickup.gaylord_count or 1
pickups.sort(key=lambda x: x.slots_needed or 0, reverse=True)
routes = {}
for truck in trucks:
routes[truck.name] = {
"truck": truck,
"pickups": [],
"used_slots": 0,
"used_weight": 0
}
unassigned = []
for pickup in pickups:
assigned = False
for truck_name, route in routes.items():
truck = route["truck"]
slots = pickup.slots_needed or 0
weight = float(pickup.estimated_weight or 0)
if route["used_slots"] + slots <= truck.total_slots:
if truck.weight_capacity and route["used_weight"] + weight <= truck.weight_capacity:
route["pickups"].append(pickup)
route["used_slots"] += slots
route["used_weight"] += weight
assigned = True
frappe.db.set_value("Scheduled Pickup", pickup.name, "truck_profile", truck_name)
break
elif not truck.weight_capacity:
route["pickups"].append(pickup)
route["used_slots"] += slots
route["used_weight"] += weight
assigned = True
frappe.db.set_value("Scheduled Pickup", pickup.name, "truck_profile", truck_name)
break
if not assigned:
unassigned.append(pickup)
for truck_name, route in routes.items():
if route["pickups"]:
route["pickups"].sort(key=lambda p: (float(p.latitude or 0), float(p.longitude or 0)))
for i, pickup in enumerate(route["pickups"], 1):
frappe.db.set_value("Scheduled Pickup", pickup.name, "stop_order", i)
frappe.db.commit()
return {
"success": True,
"date": pickup_date,
"trucks_assigned": len([r for r in routes.values() if r["pickups"]]),
"total_pickups": len(pickups),
"unassigned": len(unassigned),
"routes": {
truck_name: {
"truck_name": route["truck"].truck_name,
"slots_used": route["used_slots"],
"slots_total": route["truck"].total_slots,
"weight_used": route["used_weight"],
"weight_capacity": route["truck"].weight_capacity,
"stops": len(route["pickups"]),
"pickups": [{"name": p.name, "company": p.company_name, "slots": p.slots_needed} for p in route["pickups"]]
}
for truck_name, route in routes.items() if route["pickups"]
}
}
def _calculate_slots(gaylord_sizes_text):
if not gaylord_sizes_text:
return 0
size_map = {"small": 1, "medium": 2, "large": 3}
total = 0
matches = re.findall(r'(\d+)\s*(\w+)', gaylord_sizes_text.lower())
for count, size in matches:
slots = size_map.get(size, 1)
total += int(count) * slots
return total or 1
@frappe.whitelist()
def get_scheduled_pickups(pickup_date=None):
"""Get scheduled pickups for a given date."""
if not pickup_date:
pickup_date = frappe.utils.today()
pickups = frappe.get_list("Scheduled Pickup",
filters={"pickup_date": pickup_date},
fields=["name", "customer_number", "company_name", "estimated_items",
"estimated_weight", "gaylord_count", "gaylord_sizes", "slots_needed",
"latitude", "longitude", "stop_order", "truck_profile", "status",
"contact_name", "contact_phone", "address_line", "city", "state", "zip_code"],
order_by="stop_order asc"
)
return pickups
+68
View File
@@ -0,0 +1,68 @@
import frappe
from frappe import _
@frappe.whitelist()
def get_qa_ready_serials(limit=50):
"""Get Serial Nos ready for QA."""
return frappe.get_all('Serial No',
filters={'r2_status': 'Needs QA'},
fields=['name', 'item_code', 'item_name', 'pallet', 'cosmetic_grade'],
limit=limit,
order_by='creation asc'
)
@frappe.whitelist()
def create_qa_from_serial(serial_no):
"""Create QA inspection for a Serial No."""
if not frappe.db.exists('Serial No', serial_no):
return {'error': 'Serial No not found'}
serial = frappe.get_doc('Serial No', serial_no)
existing = frappe.db.get_value('R2 Device Inspection',
{'serial_no': serial_no, 'docstatus': ['!=', 2]}, 'name')
if existing:
return {'error': f'Inspection {existing} already exists'}
insp = frappe.get_doc({
'doctype': 'R2 Device Inspection',
'serial_no': serial_no,
'item_code': serial.item_code,
'inspection_date': frappe.utils.today(),
'inspector': frappe.session.user,
'cosmetic_grade': serial.cosmetic_grade,
'screen_condition': serial.screen_condition,
'keyboard_condition': serial.keyboard_condition,
'case_condition': serial.case_condition
})
insp.insert(ignore_permissions=True)
return {'success': True, 'inspection': insp.name}
@frappe.whitelist()
def auto_grade(serial_no, grade='C5'):
"""Auto-grade a device and move to Priced state."""
serial = frappe.get_doc('Serial No', serial_no)
serial.cosmetic_grade = grade
serial.r2_status = 'Processed'
serial.save(ignore_permissions=True)
# Apply flat pricing
item = frappe.db.get_value('Item', serial.item_code, 'item_group')
flat_prices = {
'Laptops': {'c3': 250, 'c4': 200, 'c5': 150, 'c6': 100, 'c7': 60, 'c8': 30, 'c9': 15},
'Desktops': {'c3': 180, 'c4': 150, 'c5': 120, 'c6': 80, 'c7': 50, 'c8': 25, 'c9': 10},
'Tablets': {'c3': 120, 'c4': 100, 'c5': 80, 'c6': 50, 'c7': 30, 'c8': 15, 'c9': 8},
'Phones': {'c3': 100, 'c4': 80, 'c5': 60, 'c6': 40, 'c7': 25, 'c8': 12, 'c9': 5}
}
prices = flat_prices.get(item, flat_prices['Laptops'])
price = prices.get(grade.lower(), 100)
serial.suggested_price = price
serial.assigned_price = price
serial.pricing_status = 'Priced'
serial.price_point = grade
serial.r2_status = 'Priced'
serial.save(ignore_permissions=True)
return {'success': True, 'price': price}
+322
View File
@@ -0,0 +1,322 @@
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."""
checkins = frappe.get_list("Scheduled Pickup",
filters={"status": ["in", ["Complete", "In Progress"]]},
fields=["name", "pickup_date", "pickup_type", "status",
"company_name", "customer_number",
"estimated_items", "estimated_weight", "load_contents",
"data_status", "red_r2", "notes"],
order_by="pickup_date desc",
limit_page_length=100,
)
return {"checkins": checkins}
@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.
⚠️ TODO: Green Sheet template needs to be filled in with the actual form layout."""
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", "company_name",
"contact_name", "contact_phone", "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,
)
html = "<!DOCTYPE html><html><head><title>Green Sheet</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:30px;}"
html += "h1{color:#2E7D32;font-size:20px;border-bottom:2px solid #2E7D32;padding-bottom:4px;}"
html += ".field-row{display:flex;margin:4px 0;}.field-label{font-weight:700;width:180px;}"
html += ".field-value{flex:1;border-bottom:1px dotted #ccc;min-height:20px;}"
html += ".pickup-block{border:1px solid #2E7D32;border-radius:6px;padding:12px;margin:12px 0;}"
html += ".placeholder{color:#999;font-style:italic;font-size:11px;}"
html += "@media print{body{margin:10px;}}</style></head><body>"
html += "<h1>🟢 Green Sheet — " + str(date or "Today") + "</h1>"
html += '<p class="placeholder">⚠️ This is a PLACEHOLDER template. Replace with actual Green Sheet layout once the form spec is provided.</p>'
for p in pickups:
html += '<div class="pickup-block">'
fields = [
("Customer", p.get("company_name", "")),
("Contact", p.get("contact_name", "")),
("Phone", p.get("contact_phone", "")),
("Address", str(p.get("address_line", "")) + ", " + str(p.get("city", "")) + ", " + str(p.get("state", "")) + " " + str(p.get("zip_code", ""))),
("Est. Items", p.get("estimated_items", "")),
("Est. Weight", p.get("estimated_weight", "")),
("Load Contents", p.get("load_contents", "")),
("Data Status", p.get("data_status", "")),
("RED/R2", p.get("red_r2", "")),
("Needs AoR", "" if p.get("needs_aor") else ""),
("Needs CoD", "" if p.get("needs_cod") else ""),
("Notes", p.get("notes", "")),
]
for label, val in fields:
html += '<div class="field-row"><div class="field-label">' + label + ':</div><div class="field-value">' + str(val) + '</div></div>'
html += '</div>'
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
@frappe.whitelist()
def print_labels(date=None):
"""Generate printable labels."""
if not date:
date = today()
filters = {"pickup_date": date} if date else {}
pickups = frappe.get_list("Scheduled Pickup",
filters=filters,
fields=["name", "company_name", "pickup_date", "num_labels", "data_status", "red_r2"],
limit_page_length=200,
)
html = "<!DOCTYPE html><html><head><title>Labels</title>"
html += "<style>body{font-family:Arial,sans-serif;margin:10px;}"
html += ".label{border:2px solid #000;width:3in;height:1.5in;display:inline-block;margin:4px;padding:6px;font-size:11px;page-break-inside:avoid;}"
html += ".label-co{font-weight:700;font-size:14px;}.label-date{font-size:10px;color:#666;}"
html += ".label-status{font-size:10px;margin-top:2px;}"
html += "@media print{body{margin:0;}}</style></head><body>"
for p in pickups:
n = p.get("num_labels") or 1
for _ in range(n):
html += '<div class="label">'
html += '<div class="label-co">' + str(p.get("company_name", "")) + '</div>'
html += '<div class="label-date">' + str(p.get("pickup_date", "")) + '</div>'
html += '<div class="label-status">' + str(p.get("data_status", "")) + " | " + str(p.get("red_r2", "")) + '</div>'
html += '</div>'
html += "</body></html>"
frappe.local.response["type"] = "html"
frappe.local.response["page_content"] = html
+92
View File
@@ -0,0 +1,92 @@
import frappe
from frappe import _
@frappe.whitelist()
def quick_sell(serial_no, customer=None, payment_method='Cash'):
"""Create Sales Invoice for quick cash sale."""
try:
serial = frappe.get_doc('Serial No', serial_no)
if serial.r2_status != 'Ready for Sale':
return {'error': 'Device must be Ready for Sale'}
# Use default customer if none provided
if not customer:
customer = frappe.db.get_value('Customer', {}, 'name', order_by='creation asc')
if not customer:
# Create walk-in customer
customer = frappe.get_doc({
'doctype': 'Customer',
'customer_name': 'Walk-in Customer',
'customer_type': 'Individual'
}).insert(ignore_permissions=True).name
# Create Sales Invoice
price = serial.assigned_price or serial.suggested_price or 0
invoice = frappe.get_doc({
'doctype': 'Sales Invoice',
'customer': customer,
'serial_no': serial_no,
'device_condition': f"Cosmetic: {serial.cosmetic_grade or 'N/A'}",
'posting_date': frappe.utils.today(),
'due_date': frappe.utils.today(),
'items': [{
'item_code': serial.item_code,
'qty': 1,
'rate': price,
'amount': price,
'serial_no': serial_no
}],
'payments': [{
'mode_of_payment': payment_method,
'amount': price
}]
})
invoice.insert(ignore_permissions=True)
invoice.submit()
# Update Serial No
serial.r2_status = 'Sold'
serial.status = 'Delivered'
serial.customer = customer
serial.save(ignore_permissions=True)
return {
'success': True,
'invoice': invoice.name,
'customer': customer,
'amount': price
}
except Exception as e:
frappe.log_error(f"Quick sell failed: {str(e)}", "Sales")
return {'error': str(e)}
@frappe.whitelist()
def create_sales_order(quotation_name):
"""Create Sales Order from Quotation."""
try:
from erpnext.selling.doctype.quotation.quotation import make_sales_order
so = make_sales_order(quotation_name)
so.insert(ignore_permissions=True)
so.submit()
return {'success': True, 'sales_order': so.name}
except Exception as e:
return {'error': str(e)}
@frappe.whitelist()
def create_delivery_note(sales_order_name):
"""Create Delivery Note from Sales Order."""
try:
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
dn = make_delivery_note(sales_order_name)
dn.insert(ignore_permissions=True)
dn.submit()
return {'success': True, 'delivery_note': dn.name}
except Exception as e:
return {'error': str(e)}
+244
View File
@@ -0,0 +1,244 @@
import frappe
import re
from frappe.utils import now, now_datetime
def get_pricing_config():
if not frappe.db.exists("Pricing Score Config", "Pricing Score Config"):
return None
return frappe.get_doc("Pricing Score Config", "Pricing Score Config")
def parse_ram(ram_str):
if not ram_str:
return 0
m = re.search(r'(\d+(?:\.\d+)?)', str(ram_str))
if m:
return int(float(m.group(1)))
return 0
def parse_cpu(cpu_str):
if not cpu_str:
return {"brand": "", "series": "", "gen": 0}
cpu = str(cpu_str).lower()
result = {"brand": "", "series": "", "gen": 0}
if "intel" in cpu or "core" in cpu or "i3" in cpu or "i5" in cpu or "i7" in cpu or "i9" in cpu or "xeon" in cpu:
result["brand"] = "intel"
elif "ryzen" in cpu or "athlon" in cpu or "amd" in cpu:
result["brand"] = "amd"
elif "apple" in cpu or "m1" in cpu or "m2" in cpu or "m3" in cpu:
result["brand"] = "apple"
series_match = re.search(r'(i[3579])', cpu)
if series_match:
result["series"] = series_match.group(1)
elif "xeon" in cpu:
result["series"] = "xeon"
elif "ryzen" in cpu:
ryz_match = re.search(r'ryzen\s+(\d+)', cpu)
if ryz_match:
result["series"] = f"r{ryz_match.group(1)}"
elif "m1" in cpu:
result["series"] = "m1"
elif "m2" in cpu:
result["series"] = "m2"
elif "m3" in cpu:
result["series"] = "m3"
gen_match = re.search(r'(?:i[3579]-|core\s+i[3579]\s+)(\d)', cpu)
if gen_match:
first_digit = int(gen_match.group(1))
if first_digit == 1:
gen_match2 = re.search(r'(?:i[3579]-|core\s+i[3579]\s+)1(\d)', cpu)
if gen_match2:
result["gen"] = 10 + int(gen_match2.group(1))
else:
result["gen"] = 1
else:
result["gen"] = first_digit
gen_match3 = re.search(r'(?:i[3579]-)(\d{4})', cpu)
if gen_match3:
model = gen_match3.group(1)
first_digit = int(model[0])
if first_digit == 1:
result["gen"] = 10 + int(model[1])
else:
result["gen"] = first_digit
return result
@frappe.whitelist()
def calculate_serial_score(serial_no):
serial = frappe.get_doc("Serial No", serial_no)
config = get_pricing_config()
if not config:
return {"error": "No Pricing Score Config found"}
score = 0.0
details = []
cos_grade = serial.get("cosmetic_grade", "")
if cos_grade:
grade_match = re.search(r'[Cc](\d+)', str(cos_grade))
if grade_match:
g = int(grade_match.group(1))
if g <= 2:
return {"status": "scrap", "reason": f"Cosmetic C{g} - below minimum"}
base = getattr(config, f"c{g}_base", 0)
score += base
details.append(f"Cosmetic C{g} = +{base}")
cpu_info = parse_cpu(serial.get("processor", ""))
if cpu_info["series"] in ("i7", "r7"):
score += config.i7_bonus
details.append(f"CPU {cpu_info['series'].upper()} = +{config.i7_bonus}")
elif cpu_info["series"] in ("i9", "r9"):
score += config.i9_bonus
details.append(f"CPU {cpu_info['series'].upper()} = +{config.i9_bonus}")
gen = cpu_info["gen"]
gen_bonus = 0
if gen == 10: gen_bonus = config.gen_10_bonus
elif gen == 11: gen_bonus = config.gen_11_bonus
elif gen == 12: gen_bonus = config.gen_12_bonus
elif gen == 13: gen_bonus = config.gen_13_bonus
elif gen == 14: gen_bonus = config.gen_14_bonus
if gen_bonus > 0:
score += gen_bonus
details.append(f"Gen {gen} = +{gen_bonus}")
ram_gb = parse_ram(serial.get("ram", ""))
if ram_gb >= 16:
score += config.ram_16_bonus
details.append(f"RAM {ram_gb}GB = +{config.ram_16_bonus}")
if ram_gb >= 32:
score += config.ram_32_bonus
details.append(f"RAM {ram_gb}GB = +{config.ram_32_bonus}")
if ram_gb > 32:
details.append(f"WARNING: {ram_gb}GB exceeds laptop/desktop norm")
if score >= config.medium_threshold:
tier = "High"
elif score >= config.low_threshold:
tier = "Medium"
else:
tier = "Low"
market = {"low": 0, "median": 0, "high": 0}
age_days = 0
age_status = "unknown"
if serial.item_code:
item = frappe.get_doc("Item", serial.item_code)
market = {
"low": item.market_low or item.base_market_price or 0,
"median": item.market_median or item.base_market_price or 0,
"high": item.market_high or item.base_market_price or 0,
}
if item.market_last_priced:
age_days = (now_datetime() - item.market_last_priced).days
if age_days <= 30: age_status = "current"
elif age_days <= 60: age_status = "stale"
elif age_days <= 90: age_status = "aging"
else: age_status = "expired"
suggested_price = market["high"] if tier == "High" else market["median"] if tier == "Medium" else market["low"]
serial.desirability_score = round(score, 1)
serial.suggested_tier = tier
serial.suggested_price = suggested_price
serial.save()
frappe.db.commit()
return {
"status": "ok",
"serial_no": serial_no,
"score": round(score, 1),
"tier": tier,
"details": details,
"market_prices": market,
"suggested_price": suggested_price,
"age_days": age_days,
"age_status": age_status,
}
@frappe.whitelist()
def batch_calculate_scores(batch_size=100):
serials = frappe.get_all("Serial No",
filters={"cosmetic_grade": ["is", "set"]},
fields=["name"],
limit=int(batch_size)
)
results = {"updated": 0, "scrap": 0, "errors": 0}
for s in serials:
try:
result = calculate_serial_score(s.name)
if result.get("status") == "scrap":
results["scrap"] += 1
else:
results["updated"] += 1
except Exception as e:
results["errors"] += 1
frappe.log_error(f"Score calc error for {s.name}: {e}")
return results
@frappe.whitelist()
def get_sales_pricing_data(limit=50):
"""Get pricing data for Sales Manager page."""
config = get_pricing_config()
if not config:
return {"error": "No Pricing Score Config"}
serials = frappe.get_all("Serial No",
filters={"cosmetic_grade": ["is", "set"]},
fields=["name", "serial_no", "item_code", "item_name", "cosmetic_grade", "processor", "ram", "desirability_score", "suggested_tier", "suggested_price", "assigned_price", "pricing_status"],
limit=int(limit),
order_by="modified desc"
)
results = []
for s in serials:
age = {"days": 0, "status": "unknown", "color": "gray"}
market = {"low": 0, "median": 0, "high": 0}
if s.item_code:
item = frappe.get_doc("Item", s.item_code)
market = {
"low": item.market_low or 0,
"median": item.market_median or 0,
"high": item.market_high or 0,
}
if item.market_last_priced:
age_days = (now_datetime() - item.market_last_priced).days
age["days"] = age_days
if age_days <= 30:
age["status"] = "current"
age["color"] = "green"
elif age_days <= 60:
age["status"] = "stale"
age["color"] = "yellow"
elif age_days <= 90:
age["status"] = "aging"
age["color"] = "orange"
else:
age["status"] = "expired"
age["color"] = "red"
results.append({
"serial_no": s.name,
"item_code": s.item_code,
"item_name": s.item_name,
"cosmetic_grade": s.cosmetic_grade,
"processor": s.processor,
"ram": s.ram,
"score": s.desirability_score,
"tier": s.suggested_tier,
"market": market,
"suggested_price": s.suggested_price,
"assigned_price": s.assigned_price,
"pricing_status": s.pricing_status,
"age": age,
})
return {
"serials": results,
"config": {
"low_threshold": config.low_threshold,
"medium_threshold": config.medium_threshold,
}
}
+37
View File
@@ -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}
+4
View File
@@ -0,0 +1,4 @@
from frappe import _
def get_data():
return []
@@ -0,0 +1,8 @@
// Copyright (c) 2026, Westech and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Customer Interaction", {
// refresh(frm) {
// },
// });
@@ -0,0 +1,147 @@
{
"actions": [],
"autoname": "autoincrement",
"creation": "2026-05-22 11:58:31.649154",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer",
"customer_number",
"contact_name",
"phone_1",
"phone_2",
"email_1",
"email_2",
"address",
"city",
"zip",
"hours",
"notes",
"red_r2",
"dnc",
"raw_name",
"raw_phone1",
"raw_phone2",
"raw_email"
],
"fields": [
{
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Customer",
"options": "Customer",
"reqd": 1
},
{
"fieldname": "customer_number",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Customer Number"
},
{
"fieldname": "contact_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Contact Name"
},
{
"fieldname": "phone_1",
"fieldtype": "Data",
"label": "Phone 1"
},
{
"fieldname": "phone_2",
"fieldtype": "Data",
"label": "Phone 2"
},
{
"fieldname": "email_1",
"fieldtype": "Data",
"label": "Email 1"
},
{
"fieldname": "email_2",
"fieldtype": "Data",
"label": "Email 2"
},
{
"fieldname": "address",
"fieldtype": "Text",
"label": "Address"
},
{
"fieldname": "city",
"fieldtype": "Data",
"label": "City"
},
{
"fieldname": "zip",
"fieldtype": "Data",
"label": "Zip"
},
{
"fieldname": "hours",
"fieldtype": "Data",
"label": "Hours"
},
{
"fieldname": "notes",
"fieldtype": "Text",
"label": "Notes"
},
{
"fieldname": "red_r2",
"fieldtype": "Data",
"in_standard_filter": 1,
"label": "Red R2"
},
{
"default": "0",
"fieldname": "dnc",
"fieldtype": "Check",
"label": "DNC"
},
{
"fieldname": "raw_name",
"fieldtype": "Text",
"label": "Raw Name"
},
{
"fieldname": "raw_phone1",
"fieldtype": "Data",
"label": "Raw Phone 1"
},
{
"fieldname": "raw_phone2",
"fieldtype": "Data",
"label": "Raw Phone 2"
},
{
"fieldname": "raw_email",
"fieldtype": "Data",
"label": "Raw Email"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-05-22 11:58:31.649154",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "Customer Interaction",
"naming_rule": "Autoincrement",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "contact_name",
"track_changes": 1,
"track_seen": 1
}
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Westech and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CustomerInteraction(Document):
pass
@@ -0,0 +1,9 @@
# Copyright (c) 2026, Westech and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCustomerInteraction(FrappeTestCase):
pass
@@ -0,0 +1,7 @@
frappe.ui.form.on('Load', {
refresh: function(frm) {
frm.add_custom_button(__('View Pallets'), function() {
frappe.set_route('List', 'Pallet', {'load': frm.doc.name});
}, __('Actions'));
}
});
@@ -0,0 +1,25 @@
import frappe
def calculate_totals(doc, method):
"""Auto-calculate Load totals from child tables."""
total_devices = 0
total_weight = 0.0
for item in doc.get("material_items", []):
total_devices += item.total_count or 0
total_weight += item.weight or 0.0
doc.total_devices = total_devices
doc.total_weight = total_weight
total_hdd_wiped = 0
total_hdd_degaussed = 0
for hdd in doc.get("hdd_serials", []):
if hdd.wiped:
total_hdd_wiped += 1
if hdd.degaussed or hdd.shredded:
total_hdd_degaussed += 1
doc.total_hdd_wiped = total_hdd_wiped
doc.total_hdd_degaussed = total_hdd_degaussed
@@ -0,0 +1,80 @@
frappe.ui.form.on('Pallet', {
refresh: function(frm) {
frm.add_custom_button(__('View Serials'), function() {
frappe.set_route('List', 'Serial No', {
'pallet': frm.doc.pallet_number || frm.doc.name
});
}, __('View'));
frm.add_custom_button(__('Serials Spreadsheet'), function() {
frappe.set_route('query-report', 'Serial Nos by Pallet', {
'pallet': frm.doc.pallet_number || frm.doc.name
});
}, __('View'));
frm.add_custom_button(__('Generate COR'), function() {
if (!frm.doc.pallet_number) {
frappe.msgprint('Please save the Pallet first');
return;
}
var url = '/api/method/westech_r2.api.cor_generator.generate_cor?pallet_number=' + encodeURIComponent(frm.doc.pallet_number);
window.open(url, '_blank');
}, __('Actions'));
},
customer_number: function(frm) {
var customer = frm.doc.customer_number;
if (!customer) {
clear_customer_fields(frm);
return;
}
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Supplier', name: customer},
callback: function(r) {
if (!r.message) return;
var s = r.message;
if (!frm.doc.supplier) {
frm.set_value('supplier', s.name);
}
if (!frm.doc.company_name && s.supplier_name) {
frm.set_value('company_name', s.supplier_name);
}
if (s.supplier_primary_contact) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Contact', name: s.supplier_primary_contact},
callback: function(cr) {
if (!cr.message) return;
var ct = cr.message;
var full_name = [ct.first_name, ct.last_name].filter(Boolean).join(' ');
if (!frm.doc.contact_name) frm.set_value('contact_name', full_name);
if (!frm.doc.contact_number) frm.set_value('contact_number', ct.phone || ct.mobile_no || '');
if (!frm.doc.contact_email) frm.set_value('contact_email', ct.email_id || '');
}
});
}
if (s.supplier_primary_address) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Address', name: s.supplier_primary_address},
callback: function(ar) {
if (!ar.message) return;
var a = ar.message;
if (!frm.doc.address_line) frm.set_value('address_line', a.address_line1 || '');
}
});
}
}
});
}
});
function clear_customer_fields(frm) {
frm.set_value('supplier', '');
frm.set_value('company_name', '');
frm.set_value('contact_name', '');
frm.set_value('contact_number', '');
frm.set_value('contact_email', '');
frm.set_value('address_line', '');
}
@@ -0,0 +1,7 @@
import frappe
def update_serial_nos(doc, method):
"""Update serial nos linked to this pallet."""
if doc.pallet_number:
serials = frappe.get_all("Serial No", filters={"pallet": doc.pallet_number}, fields=["name"])
doc.db_set("serial_count", len(serials))
@@ -0,0 +1,67 @@
frappe.ui.form.on('Scheduled Pickup', {
customer_number: function(frm) {
var customer = frm.doc.customer_number;
if (!customer) {
clear_customer_fields(frm);
return;
}
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Supplier', name: customer},
callback: function(r) {
if (!r.message) return;
var s = r.message;
if (!frm.doc.company_name && s.supplier_name) {
frm.set_value('company_name', s.supplier_name);
}
if (s.supplier_primary_contact) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Contact', name: s.supplier_primary_contact},
callback: function(cr) {
if (!cr.message) return;
var ct = cr.message;
var full_name = [ct.first_name, ct.last_name].filter(Boolean).join(' ');
if (!frm.doc.contact_name) frm.set_value('contact_name', full_name);
if (!frm.doc.contact_phone) frm.set_value('contact_phone', ct.phone || '');
if (!frm.doc.contact_email) frm.set_value('contact_email', ct.email_id || '');
}
});
}
if (s.supplier_primary_address) {
frappe.call({
method: 'frappe.client.get',
args: {doctype: 'Address', name: s.supplier_primary_address},
callback: function(ar) {
if (!ar.message) return;
var a = ar.message;
if (!frm.doc.address_line) frm.set_value('address_line', a.address_line1 || '');
if (!frm.doc.city) frm.set_value('city', a.city || '');
if (!frm.doc.state) frm.set_value('state', a.state || '');
if (!frm.doc.zip_code) frm.set_value('zip_code', a.pincode || '');
}
});
}
if (s.geocoded && s.latitude && s.longitude) {
frm.set_value('latitude', s.latitude);
frm.set_value('longitude', s.longitude);
frm.set_value('geocoded', 1);
}
}
});
}
});
function clear_customer_fields(frm) {
frm.set_value('company_name', '');
frm.set_value('contact_name', '');
frm.set_value('contact_phone', '');
frm.set_value('contact_email', '');
frm.set_value('address_line', '');
frm.set_value('city', '');
frm.set_value('state', '');
frm.set_value('zip_code', '');
frm.set_value('latitude', '');
frm.set_value('longitude', '');
frm.set_value('geocoded', 0);
}
@@ -0,0 +1,8 @@
import frappe
def set_title(doc, method):
"""Set title from company name and pickup date."""
if doc.company_name and doc.pickup_date:
doc.title = f"{doc.company_name} - {doc.pickup_date}"
elif doc.company_name:
doc.title = doc.company_name
@@ -0,0 +1,68 @@
[
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Serial No",
"enabled": 1,
"modified": "2026-05-19 09:16:00.000000",
"module": null,
"name": "Serial No - Price Calculator",
"script": "frappe.ui.form.on('Serial No', {\n grade: function(frm) {\n calculate_recommended_price(frm);\n },\n cpu_test: function(frm) {\n check_hardware_failures(frm);\n },\n ram_test: function(frm) {\n check_hardware_failures(frm);\n },\n refresh: function(frm) {\n // Label change via JS (since fixture label updates need migrate)\n var price_field = frm.get_field('assigned_price');\n if (price_field && price_field.$wrapper) {\n price_field.$wrapper.find('.control-label').text('Recommended Price');\n }\n check_hardware_failures(frm);\n }\n});\n\nfunction check_hardware_failures(frm) {\n var cpu_fail = frm.doc.cpu_test === 'Fail';\n var ram_fail = frm.doc.ram_test === 'Fail';\n \n if (cpu_fail || ram_fail) {\n // Force Flagged grade and dismantle routing (but allow reviewer override)\n if (!frm.doc.grade || frm.doc.grade !== 'Flagged') {\n frm.set_value('grade', 'Flagged');\n }\n frm.set_value('assigned_price', null);\n frm.set_value('pricing_status', 'Dismantle');\n \n var reason = [];\n if (cpu_fail) reason.push('CPU test failed');\n if (ram_fail) reason.push('RAM test failed');\n \n // Show warning banner instead of locking the field\n frm.set_intro(\n '<i class=\"fa fa-exclamation-triangle\"></i> <strong>Hardware Failure:</strong> ' + \n reason.join(', ') + ' \u2014 routed to Dismantle. Change tests to Pass to re-enable pricing.',\n 'red'\n );\n } else {\n // Clear the warning banner\n frm.clear_intro();\n // If grade was Flagged but tests now pass, let user manually change grade\n }\n}\n\nfunction calculate_recommended_price(frm) {\n var grade = frm.doc.grade;\n \n // Flagged = no price, show FLAGGED text\n if (!grade || grade === 'Flagged') {\n frm.set_value('assigned_price', null);\n frm.set_value('pricing_status', grade === 'Flagged' ? 'Flagged' : 'Needs Pricing');\n return;\n }\n \n // Need item reference to get market prices\n if (!frm.doc.item_code) {\n return;\n }\n \n frappe.call({\n method: 'frappe.client.get',\n args: {\n doctype: 'Item',\n name: frm.doc.item_code\n },\n callback: function(r) {\n if (!r.message) return;\n var item = r.message;\n var base_price = 0;\n var price_source = '';\n \n switch(grade) {\n case 'High':\n base_price = item.market_high || item.base_market_price || 0;\n price_source = 'market_high';\n break;\n case 'Med':\n base_price = item.market_median || item.base_market_price || 0;\n price_source = 'market_median';\n break;\n case 'Low':\n base_price = item.market_low || item.base_market_price || 0;\n price_source = 'market_low';\n break;\n }\n \n if (base_price > 0) {\n frm.set_value('assigned_price', Math.round(base_price * 100) / 100);\n frm.set_value('pricing_status', 'Priced');\n frm.set_value('pricing_source', price_source);\n } else {\n frm.set_value('assigned_price', null);\n frm.set_value('pricing_status', 'Needs Pricing');\n }\n }\n });\n}\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Pallet",
"enabled": 1,
"modified": "2026-05-17 05:39:15.497479",
"module": null,
"name": "Pallet - View Serials",
"script": "frappe.ui.form.on('Pallet', {\n refresh: function(frm) {\n frm.add_custom_button(__('View Serials'), function() {\n frappe.set_route('List', 'Serial No', {\n 'pallet': frm.doc.pallet_number || frm.doc.name\n });\n }, __('View'));\n \n frm.add_custom_button(__('Serials Spreadsheet'), function() {\n frappe.set_route('query-report', 'Serial Nos by Pallet', {\n 'pallet': frm.doc.pallet_number || frm.doc.name\n });\n }, __('View'));\n }\n});\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Load",
"enabled": 1,
"modified": "2026-05-17 05:39:15.577871",
"module": null,
"name": "Load - View Pallets Button",
"script": "frappe.ui.form.on('Load', {\n refresh: function(frm) {\n frm.add_custom_button(__('View Pallets'), function() {\n frappe.set_route('List', 'Pallet', {'load': frm.doc.name});\n }, __('Actions'));\n }\n});\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "Scheduled Pickup",
"enabled": 1,
"modified": "2026-05-17 05:39:15.588395",
"module": null,
"name": "Scheduled Pickup - Auto Fill",
"script": "frappe.ui.form.on('Scheduled Pickup', {\n customer_number: function(frm) {\n var customer = frm.doc.customer_number;\n if (!customer) {\n clear_supplier_fields(frm);\n return;\n }\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Supplier', name: customer},\n callback: function(r) {\n if (!r.message) return;\n var s = r.message;\n if (!frm.doc.company_name && s.supplier_name) {\n frm.set_value('company_name', s.supplier_name);\n }\n if (s.supplier_primary_contact) {\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Contact', name: s.supplier_primary_contact},\n callback: function(cr) {\n if (!cr.message) return;\n var ct = cr.message;\n var full_name = [ct.first_name, ct.last_name].filter(Boolean).join(' ');\n if (!frm.doc.contact_name) frm.set_value('contact_name', full_name);\n if (!frm.doc.contact_phone) frm.set_value('contact_phone', ct.phone || '');\n if (!frm.doc.contact_email) frm.set_value('contact_email', ct.email_id || '');\n }\n });\n }\n if (s.supplier_primary_address) {\n frappe.call({\n method: 'frappe.client.get',\n args: {doctype: 'Address', name: s.supplier_primary_address},\n callback: function(ar) {\n if (!ar.message) return;\n var a = ar.message;\n if (!frm.doc.address_line) frm.set_value('address_line', a.address_line1 || '');\n if (!frm.doc.city) frm.set_value('city', a.city || '');\n if (!frm.doc.state) frm.set_value('state', a.state || '');\n if (!frm.doc.zip_code) frm.set_value('zip_code', a.pincode || '');\n }\n });\n }\n if (s.geocoded && s.latitude && s.longitude) {\n frm.set_value('latitude', s.latitude);\n frm.set_value('longitude', s.longitude);\n frm.set_value('geocoded', 1);\n }\n }\n });\n }\n});\n\nfunction clear_supplier_fields(frm) {\n frm.set_value('company_name', '');\n frm.set_value('contact_name', '');\n frm.set_value('contact_phone', '');\n frm.set_value('contact_email', '');\n frm.set_value('address_line', '');\n frm.set_value('city', '');\n frm.set_value('state', '');\n frm.set_value('zip_code', '');\n frm.set_value('latitude', '');\n frm.set_value('longitude', '');\n frm.set_value('geocoded', 0);\n}\n",
"view": "Form"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "System Pricing",
"enabled": 1,
"modified": "2026-05-17 05:41:31.790905",
"module": null,
"name": "System Pricing - Age Indicators",
"script": "frappe.listview_settings['System Pricing'] = {\n add_fields: [\"scraped_at\", \"days_since_pricing\", \"pricing_status\"],\n get_indicator: function(doc) {\n if (doc.pricing_status === \"Needs Pricing\" || doc.pricing_status === \"Error\") {\n return [__(\"Needs Pricing\"), \"orange\", \"pricing_status,=,Needs Pricing\"];\n }\n if (!doc.scraped_at) {\n return [__(\"No Date\"), \"gray\", \"scraped_at,is,not set\"];\n }\n var days = doc.days_since_pricing || 0;\n if (days < 90) {\n return [__(\"Fresh\"), \"green\", \"days_since_pricing,<,90\"];\n } else if (days < 120) {\n return [__(\"Aging\"), \"yellow\", \"days_since_pricing,<,120\"];\n } else {\n return [__(\"Expired\"), \"red\", \"days_since_pricing,>=,120\"];\n }\n }\n};",
"view": "List"
},
{
"docstatus": 0,
"doctype": "Client Script",
"dt": "System Pricing",
"enabled": 1,
"modified": "2026-05-17 05:41:31.954814",
"module": null,
"name": "System Pricing - Auto Calculate Days",
"script": "frappe.ui.form.on('System Pricing', {\n refresh: function(frm) {\n calculate_days(frm);\n },\n scraped_at: function(frm) {\n calculate_days(frm);\n }\n});\n\nfunction calculate_days(frm) {\n if (frm.doc.scraped_at) {\n var scraped = new Date(frm.doc.scraped_at);\n var now = new Date();\n var diffTime = Math.abs(now - scraped);\n var diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));\n frm.set_value('days_since_pricing', diffDays);\n } else {\n frm.set_value('days_since_pricing', 0);\n }\n}",
"view": "Form"
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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.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,97 @@
"""
Data Migration: Populate new Serial No fields from Device Condition Report data.
Run this IMMEDIATELY after `bench migrate` on production.
Requires: all new fields already exist (grade, cpu_test, ram_test, etc.)
Maps cosmetic_grade C# values from latest Device Condition Report → new grade field:
C5-C9 → High
C4 → Med
C3 → Low
C0-C2, blank, anything else → Flagged
"""
import frappe
from frappe.utils import now
def run():
print(f"[{now()}] Starting Serial No grade migration...")
# Get all Serial Nos with their latest Device Condition Report
serials = frappe.get_all("Serial No",
fields=["name"],
limit_page_length=0
)
stats = {"high": 0, "med": 0, "low": 0, "flagged": 0, "skipped": 0, "errors": 0, "no_report": 0}
for i, s in enumerate(serials):
try:
# Find latest Device Condition Report for this serial
reports = frappe.get_all("Device Condition Report",
filters={"serial_no": s.name},
fields=["cosmetic_grade"],
order_by="creation DESC",
limit=1
)
if not reports:
# No report found - flag it
new_grade = "Flagged"
stats["no_report"] += 1
else:
cosmetic = (reports[0].cosmetic_grade or "").strip().upper()
# Map cosmetic_grade → new grade
if cosmetic in ("C5", "C6", "C7", "C8", "C9"):
new_grade = "High"
stats["high"] += 1
elif cosmetic == "C4":
new_grade = "Med"
stats["med"] += 1
elif cosmetic == "C3":
new_grade = "Low"
stats["low"] += 1
else:
new_grade = "Flagged"
stats["flagged"] += 1
# Only update if grade is blank
current_grade = frappe.db.get_value("Serial No", s.name, "grade")
if current_grade and current_grade in ("High", "Med", "Low", "Flagged"):
stats["skipped"] += 1
continue
# Determine pricing_status
if new_grade == "Flagged":
pricing_status = "Flagged"
else:
pricing_status = "Needs Pricing"
frappe.db.set_value("Serial No", s.name, {
"grade": new_grade,
"pricing_status": pricing_status
})
if (i + 1) % 1000 == 0:
frappe.db.commit()
print(f" Processed {i + 1}/{len(serials)}...")
except Exception as e:
stats["errors"] += 1
frappe.log_error(f"Migration error for {s.name}: {e}", "Serial Grade Migration")
frappe.db.commit()
print(f"\n[{now()}] Migration complete!")
print(f" Total Serial Nos: {len(serials)}")
print(f" High: {stats['high']}")
print(f" Med: {stats['med']}")
print(f" Low: {stats['low']}")
print(f" Flagged (from missing/bad grade): {stats['flagged']}")
print(f" Flagged (no report found): {stats['no_report']}")
print(f" Skipped (already migrated): {stats['skipped']}")
print(f" Errors: {stats['errors']}")
if __name__ == "__main__":
run()
+1
View File
@@ -0,0 +1 @@
Westech R2
@@ -0,0 +1 @@
{"creation":"2026-05-20 15:00:00.000000","docstatus":0,"doctype":"Page","idx":0,"module":"westech_r2","name":"customer-intake","page_name":"customer-intake","roles":[],"script":null,"standard":"Yes","style":null,"system_page":0,"title":"Customer Intake"}
@@ -0,0 +1,96 @@
<div class="container" style="padding:20px;">
<div class="row">
<div class="col-md-12">
<h3>Customer Intake</h3>
</div>
</div>
<div class="row">
<div class="col-md-6">
<label>Search Customer</label>
<input type="text" id="intake-search" class="form-control" placeholder="Type company name, number, or phone...">
<div id="search-results" class="list-group" style="margin-top:8px; max-height:250px; overflow-y:auto;"></div>
</div>
<div class="col-md-6">
<div id="no-match" style="display:none;">
<button id="btn-add-new" class="btn btn-primary">Add New Customer</button>
</div>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-4">
<label>Customer Name</label>
<input type="text" id="cust-name" class="form-control">
</div>
<div class="col-md-4">
<label>Customer Number</label>
<input type="text" id="cust-number" class="form-control">
</div>
<div class="col-md-4">
<label>Phone</label>
<input type="text" id="cust-phone" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-6">
<label>Address Line 1</label>
<input type="text" id="cust-address" class="form-control">
</div>
<div class="col-md-2">
<label>City</label>
<input type="text" id="cust-city" class="form-control">
</div>
<div class="col-md-2">
<label>State</label>
<input type="text" id="cust-state" class="form-control">
</div>
<div class="col-md-2">
<label>Zip</label>
<input type="text" id="cust-zip" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-4">
<label>Contact Persons</label>
<input type="text" id="cust-contacts" class="form-control">
</div>
<div class="col-md-4">
<label>Email</label>
<input type="text" id="cust-email" class="form-control">
</div>
<div class="col-md-4">
<label>Hours of Operation</label>
<input type="text" id="cust-hours" class="form-control">
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="col-md-4">
<label>Data Status</label>
<select id="pallet-data-status" class="form-control">
<option value="D0">D0 - Unknown</option>
<option value="D1">D1 - Contains Data</option>
</select>
</div>
<div class="col-md-4">
<label>Status</label>
<select id="pallet-status" class="form-control">
<option value="Received">Received</option>
<option value="Sorting">Sorting</option>
<option value="Processing">Processing</option>
<option value="Complete">Complete</option>
<option value="Shipped">Shipped</option>
</select>
</div>
<div class="col-md-4">
<label>Inbound Weight (lbs)</label>
<input type="text" id="pallet-weight" class="form-control">
</div>
</div>
<div class="row" style="margin-top:15px;">
<div class="col-md-12">
<button id="btn-save-cust" class="btn btn-success">Save Customer</button>
<button id="btn-create-pallet" class="btn btn-warning" style="margin-left:10px;">Create Pallet</button>
<span id="cust-status" style="margin-left:15px;"></span>
</div>
</div>
</div>
@@ -0,0 +1,125 @@
frappe.pages["customer-intake"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({parent: wrapper, title: __("Customer Intake"), single_column: true});
var content = frappe.render_template("customer-intake", {});
$(page.body).append(content);
var currentCustomer = null;
var searchTimer = null;
function clearForm() {
currentCustomer = null;
$("#cust-name, #cust-number, #cust-phone, #cust-address, #cust-city, #cust-state, #cust-zip, #cust-contacts, #cust-email, #cust-hours").val("");
$("#search-results").empty();
$("#no-match").hide();
$("#cust-status").text("");
}
function fillForm(c) {
currentCustomer = c;
$("#cust-name").val(c.customer_name || "");
$("#cust-number").val(c.customer_number || "");
$("#cust-phone").val(c.phone || c.address_phone || "");
$("#cust-address").val(c.address_line1 || "");
$("#cust-city").val(c.city || "");
$("#cust-state").val(c.state || "");
$("#cust-zip").val(c.pincode || "");
$("#cust-contacts").val(c.contact_persons || "");
$("#cust-email").val(c.email_id || "");
$("#cust-hours").val(c.hours_of_operation || "");
$("#cust-status").text("Selected: " + (c.customer_name || c.name));
$("#no-match").hide();
}
function doSearch() {
var q = $("#intake-search").val().trim();
if (q.length < 2) { $("#search-results").empty(); return; }
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.search_customers",
args: {q: q},
callback: function(r) {
var list = $("#search-results").empty();
if (r.message && r.message.length) {
r.message.forEach(function(c) {
var item = $('<div class="list-group-item" style="cursor:pointer;">')
.html('<b>' + frappe.utils.escape_html(c.customer_name || c.name) + '</b> ' +
(c.address_line1 ? '<br>' + frappe.utils.escape_html(c.address_line1) : '') +
(c.city ? ', ' + c.city : '') +
(c.phone ? ' <br><small>' + c.phone + '</small>' : ''));
item.on("click", function() { fillForm(c); });
list.append(item);
});
$("#no-match").hide();
} else {
$("#no-match").show();
}
}
});
}
$("#intake-search").on("input", function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(doSearch, 300);
});
$("#btn-add-new").click(function() {
clearForm();
$("#cust-name").val($("#intake-search").val());
$("#cust-status").text("Adding new customer...");
});
$("#btn-save-cust").click(function() {
var data = {
customer_name: $("#cust-name").val(),
customer_number: $("#cust-number").val(),
phone: $("#cust-phone").val(),
address_line1: $("#cust-address").val(),
city: $("#cust-city").val(),
state: $("#cust-state").val(),
pincode: $("#cust-zip").val(),
contact_persons: $("#cust-contacts").val(),
email_id: $("#cust-email").val(),
hours_of_operation: $("#cust-hours").val()
};
if (currentCustomer && currentCustomer.name) {
data.name = currentCustomer.name;
}
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.create_customer_from_intake",
args: {data: JSON.stringify(data)},
callback: function(r) {
if (r.message && r.message.status === "ok") {
currentCustomer = {name: r.message.customer, customer_name: data.customer_name};
$("#cust-status").text("Saved: " + r.message.customer);
frappe.show_alert("Customer saved", 3);
} else {
frappe.show_alert("Error saving customer", 5);
}
}
});
});
$("#btn-create-pallet").click(function() {
if (!currentCustomer || !currentCustomer.name) {
frappe.msgprint("Please select or save a customer first.");
return;
}
frappe.call({
method: "westech_r2.page.customer-intake.customer-intake.create_pallet",
args: {
data: JSON.stringify({
customer: currentCustomer.name,
customer_number: $("#cust-number").val(),
data_status: $("#pallet-data-status").val(),
status: $("#pallet-status").val(),
inbound_weight: $("#pallet-weight").val()
})
},
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.msgprint("Pallet created: " + r.message.pallet);
$("#cust-status").text("Pallet: " + r.message.pallet);
}
}
});
});
};
@@ -0,0 +1,84 @@
import frappe
from frappe import _
@frappe.whitelist()
def search_customers(q=""):
if not q or len(q) < 2:
return []
q = q.strip().lower()
customers = frappe.db.sql("""
SELECT c.name, c.customer_name, c.customer_number, c.mobile_no,
a.address_line1, a.city, a.state, a.pincode
FROM tabCustomer c
LEFT JOIN `tabDynamic Link` dl ON dl.link_doctype = 'Customer' AND dl.link_name = c.name AND dl.parenttype = 'Address'
LEFT JOIN tabAddress a ON a.name = dl.parent
WHERE LOWER(c.customer_name) LIKE %s OR LOWER(c.customer_number) LIKE %s OR LOWER(c.mobile_no) LIKE %s
ORDER BY c.customer_name
LIMIT 20
""", ("%" + q + "%", "%" + q + "%", "%" + q + "%"), as_dict=True)
return customers
@frappe.whitelist()
def get_customer(name):
if not name:
return {}
cust = frappe.get_doc("Customer", name)
result = cust.as_dict()
addr = frappe.db.sql("""
SELECT a.address_line1, a.city, a.state, a.pincode, a.phone
FROM tabAddress a
JOIN `tabDynamic Link` dl ON dl.parent = a.name AND dl.link_doctype = 'Customer' AND dl.link_name = %s
LIMIT 1
""", (name,), as_dict=True)
if addr:
result.update({
"address_line1": addr[0].address_line1,
"city": addr[0].city,
"state": addr[0].state,
"pincode": addr[0].pincode,
"address_phone": addr[0].phone
})
return result
@frappe.whitelist()
def create_customer_from_intake(data):
data = frappe.parse_json(data)
if not data.get("customer_name"):
frappe.throw(_("Customer name required"))
customer = frappe.new_doc("Customer")
customer.customer_name = data.get("customer_name")
customer.customer_group = data.get("customer_group", "IT Recycling")
customer.customer_type = "Company"
customer.customer_number = data.get("customer_number")
customer.mobile_no = data.get("phone")
customer.email_id = data.get("email_id")
customer.legacy_notes = data.get("legacy_notes")
customer.hours_of_operation = data.get("hours_of_operation")
customer.contact_persons = data.get("contact_persons")
customer.save()
if data.get("address_line1") or data.get("city"):
addr = frappe.new_doc("Address")
addr.address_title = customer.customer_name
addr.address_type = "Billing"
addr.address_line1 = data.get("address_line1", "Unknown")
addr.city = data.get("city", "Unknown")
addr.state = data.get("state", "")
addr.pincode = data.get("pincode", "")
addr.country = "United States"
addr.append("links", {"link_doctype": "Customer", "link_name": customer.name})
addr.save()
return {"status": "ok", "customer": customer.name}
@frappe.whitelist()
def create_pallet(data):
data = frappe.parse_json(data)
if not data.get("customer"):
frappe.throw(_("Customer required"))
pallet = frappe.new_doc("Pallet")
pallet.customer = data.get("customer")
pallet.customer_number = data.get("customer_number")
pallet.data_status = data.get("data_status", "D0")
pallet.status = data.get("status", "Received")
pallet.inbound_weight = data.get("inbound_weight", "")
pallet.save()
return {"status": "ok", "pallet": pallet.name}
@@ -0,0 +1 @@
# Customer Records page
@@ -0,0 +1 @@
{"content": null,"creation": "2026-05-20 22:00:00.000000","docstatus": 0,"doctype": "Page","idx": 0,"modified": "2026-05-20 22:00:00.000000","modified_by": "Administrator","module": "Westech R2","name": "customer-records","owner": "Administrator","page_name": "customer-records","roles": [{"doctype": "Has Role","idx": 1,"name": "a80mopj93i","parent": "customer-records","parentfield": "roles","parenttype": "Page","role": "All"}],"script": null,"standard": "Yes","style": null,"system_page": 0,"title": "Customer Records"}
@@ -0,0 +1,227 @@
<style>
.customer-records-page {
font-family: Segoe UI, Arial, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: white;
}
.customer-records-page h1 {
font-size: 36px;
font-weight: 900;
text-align: center;
margin-bottom: 20px;
letter-spacing: 2px;
text-transform: uppercase;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.toolbar button {
padding: 8px 20px;
border: 2px solid #333;
background: #f0f0f0;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
border-radius: 3px;
}
.toolbar button:hover { background: #e0e0e0; }
.toolbar .record-counter {
font-size: 18px;
font-weight: 600;
margin: 0 10px;
}
.toolbar .nav-btn {
padding: 6px 14px;
font-size: 18px;
min-width: 40px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 40px;
margin-bottom: 20px;
}
.form-group {
display: flex;
align-items: center;
gap: 10px;
}
.form-group label {
font-weight: 600;
font-size: 13px;
min-width: 140px;
text-align: right;
white-space: nowrap;
}
.form-group input,
.form-group textarea {
flex: 1;
padding: 6px 10px;
border: 1px solid #ccc;
font-size: 13px;
background: #f8f8f8;
}
.form-group textarea {
min-height: 60px;
resize: vertical;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 20px;
padding-top: 20px;
border-top: 2px solid #333;
}
.search-bar label {
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
}
.search-bar select,
.search-bar input {
padding: 6px 10px;
border: 1px solid #ccc;
font-size: 13px;
}
.search-bar .search-label {
font-weight: 700;
font-size: 16px;
text-transform: uppercase;
margin: 0 10px;
}
.search-bar button {
padding: 6px 20px;
border: 2px solid #333;
background: #f0f0f0;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
}
.search-bar button:hover { background: #e0e0e0; }
</style>
<div class="customer-records-page">
<h1>Modify Records</h1>
<div class="toolbar">
<button id="btn-save">Save</button>
<span class="record-counter">
<span id="current-index">0</span> of <span id="total-count">0</span>
</span>
<button class="nav-btn" id="btn-prev">&lt;</button>
<button class="nav-btn" id="btn-next">&gt;</button>
<button class="nav-btn" id="btn-first">&lt;&lt;</button>
<button class="nav-btn" id="btn-last">&gt;&gt;</button>
<button id="btn-delete">Delete</button>
<button id="btn-print">Print</button>
</div>
<div class="form-grid">
<div class="form-group">
<label>Record #</label>
<input type="text" id="record-number" readonly>
</div>
<div class="form-group">
<label>Additional Numbers:</label>
<input type="text" id="additional-numbers">
</div>
<div class="form-group">
<label>*</label>
<input type="text" id="field-star">
</div>
<div class="form-group">
<label>Customer Address:</label>
<input type="text" id="customer-address">
</div>
<div class="form-group">
<label>Any Letter:</label>
<input type="text" id="any-letter">
</div>
<div class="form-group">
<label>City:</label>
<input type="text" id="city">
</div>
<div class="form-group">
<label>E</label>
<input type="text" id="field-e">
</div>
<div class="form-group">
<label>Zip:</label>
<input type="text" id="zip">
</div>
<div class="form-group">
<label>Company Name:</label>
<input type="text" id="company-name">
</div>
<div class="form-group">
<label>Contacted date:</label>
<input type="date" id="contacted-date">
</div>
<div class="form-group full-width">
<label>Contact Person(s):</label>
<textarea id="contact-persons"></textarea>
</div>
<div class="form-group">
<label>Follow up date:</label>
<input type="text" id="follow-up-date">
</div>
<div class="form-group">
<label>E-Mail Address:</label>
<input type="email" id="email-address">
</div>
<div class="form-group">
<label>Last P/U date:</label>
<input type="text" id="last-pu-date">
</div>
<div class="form-group full-width">
<label>Contact Numbers:</label>
<textarea id="contact-numbers"></textarea>
</div>
<div class="form-group full-width">
<label>Hours of Operation:</label>
<input type="text" id="hours-operation">
</div>
<div class="form-group full-width">
<label>Comments:</label>
<textarea id="comments"></textarea>
</div>
</div>
<div class="search-bar">
<label>SEARCH:</label>
<select id="search-field">
<option value="address">Address</option>
<option value="company_name">Company Name</option>
<option value="contact_person">Contact Person</option>
<option value="phone">Phone</option>
<option value="email">Email</option>
<option value="city">City</option>
<option value="zip">Zip</option>
<option value="record_number">Record #</option>
</select>
<span class="search-label">FOR</span>
<input type="text" id="search-value" style="min-width:200px;">
<button id="btn-search">Search!</button>
<button id="btn-reset">Reset</button>
</div>
</div>
@@ -0,0 +1,148 @@
frappe.pages["customer-records"].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Customer Records"),
single_column: true
});
var content = frappe.render_template("customer-records", {});
$(page.body).append(content);
var records = [];
var currentIdx = -1;
var searchResults = [];
function loadRecord(idx) {
if (idx < 0 || idx >= records.length) return;
currentIdx = idx;
var r = records[idx];
$("#current-index").text(idx + 1);
$("#total-count").text(records.length);
$("#record-number").val(r.name || "");
$("#additional-numbers").val(r.additional_numbers || "");
$("#field-star").val(r.field_star || "");
$("#customer-address").val(r.customer_address || "");
$("#any-letter").val(r.any_letter || "");
$("#city").val(r.city || "");
$("#field-e").val(r.field_e || "");
$("#zip").val(r.zip || "");
$("#company-name").val(r.company_name || "");
$("#contacted-date").val(r.contacted_date || "");
$("#contact-persons").val(r.contact_persons || "");
$("#follow-up-date").val(r.follow_up_date || "");
$("#email-address").val(r.email_address || "");
$("#last-pu-date").val(r.last_pu_date || "");
$("#contact-numbers").val(r.contact_numbers || "");
$("#hours-operation").val(r.hours_operation || "");
$("#comments").val(r.comments || "");
}
function fetchRecords() {
frappe.call({
method: "westech_r2.page.customer-records.customer-records.get_records",
callback: function(r) {
if (r.message) {
records = r.message;
if (records.length > 0) loadRecord(0);
else $("#current-index").text(0);
$("#total-count").text(records.length);
}
}
});
}
function saveRecord() {
if (currentIdx < 0) return;
var data = {
name: $("#record-number").val(),
additional_numbers: $("#additional-numbers").val(),
field_star: $("#field-star").val(),
customer_address: $("#customer-address").val(),
any_letter: $("#any-letter").val(),
city: $("#city").val(),
field_e: $("#field-e").val(),
zip: $("#zip").val(),
company_name: $("#company-name").val(),
contacted_date: $("#contacted-date").val(),
contact_persons: $("#contact-persons").val(),
follow_up_date: $("#follow-up-date").val(),
email_address: $("#email-address").val(),
last_pu_date: $("#last-pu-date").val(),
contact_numbers: $("#contact-numbers").val(),
hours_operation: $("#hours-operation").val(),
comments: $("#comments").val()
};
frappe.call({
method: "westech_r2.page.customer-records.customer-records.save_record",
args: { data: JSON.stringify(data) },
callback: function(r) {
if (r.message && r.message.status === "ok") {
frappe.show_alert("Saved successfully");
records[currentIdx] = data;
}
}
});
}
function deleteRecord() {
if (currentIdx < 0) return;
frappe.confirm("Delete record #" + $("#record-number").val() + "?", function() {
frappe.call({
method: "westech_r2.page.customer-records.customer-records.delete_record",
args: { name: $("#record-number").val() },
callback: function(r) {
if (r.message && r.message.status === "ok") {
records.splice(currentIdx, 1);
if (records.length > 0) {
currentIdx = Math.min(currentIdx, records.length - 1);
loadRecord(currentIdx);
} else {
$("#current-index").text(0);
$("#total-count").text(0);
$("input, textarea").val("");
}
frappe.show_alert("Deleted");
}
}
});
});
}
function searchRecords() {
var field = $("#search-field").val();
var value = $("#search-value").val().toLowerCase();
if (!value) {
searchResults = [];
fetchRecords();
return;
}
frappe.call({
method: "westech_r2.page.customer-records.customer-records.search_records",
args: { field: field, value: value },
callback: function(r) {
if (r.message) {
searchResults = r.message;
records = searchResults;
if (records.length > 0) loadRecord(0);
else {
$("#current-index").text(0);
$("#total-count").text(records.length);
$("input, textarea").val("");
}
}
}
});
}
$("#btn-save").click(saveRecord);
$("#btn-delete").click(deleteRecord);
$("#btn-prev").click(function() { if (currentIdx > 0) loadRecord(currentIdx - 1); });
$("#btn-next").click(function() { if (currentIdx < records.length - 1) loadRecord(currentIdx + 1); });
$("#btn-first").click(function() { if (records.length > 0) loadRecord(0); });
$("#btn-last").click(function() { if (records.length > 0) loadRecord(records.length - 1); });
$("#btn-search").click(searchRecords);
$("#btn-reset").click(function() { $("#search-value").val(""); searchResults = []; fetchRecords(); });
$("#btn-print").click(function() { window.print(); });
fetchRecords();
};
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-20 15:03:29.017530",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-20 15:03:29.017530",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "customer-records",
"owner": "Administrator",
"page_name": "customer-records",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Customer Records"
}
@@ -0,0 +1,85 @@
import frappe
@frappe.whitelist()
def get_records():
# For now return Lead records mapped to form fields
leads = frappe.db.sql("""
SELECT name, company_name, email_id, mobile_no, phone, address_line1, city, state, pincode,
title, status, industry, source
FROM tabLead
ORDER BY creation DESC
LIMIT 1000
""", as_dict=True)
result = []
for l in leads:
result.append({
"name": l.name,
"additional_numbers": "",
"field_star": l.status or "",
"customer_address": l.address_line1 or "",
"any_letter": l.title or "",
"city": l.city or "",
"field_e": l.email_id or "",
"zip": l.pincode or "",
"company_name": l.company_name or "",
"contacted_date": "",
"contact_persons": l.title or "",
"follow_up_date": "",
"email_address": l.email_id or "",
"last_pu_date": "",
"contact_numbers": (l.phone or "") + "\n" + (l.mobile_no or ""),
"hours_operation": l.industry or "",
"comments": l.source or ""
})
return result
@frappe.whitelist()
def save_record(data):
data = frappe.parse_json(data)
# For now just return OK - Lead update can be wired later
return {"status": "ok", "message": "Saved " + (data.get("name") or "")}
@frappe.whitelist()
def delete_record(name):
# For now return OK
return {"status": "ok", "message": "Deleted " + name}
@frappe.whitelist()
def search_records(field, value):
leads = frappe.db.sql("""
SELECT name, company_name, email_id, mobile_no, phone, address_line1, city, state, pincode,
title, status, industry, source
FROM tabLead
WHERE LOWER(company_name) LIKE %s
OR LOWER(title) LIKE %s
OR LOWER(address_line1) LIKE %s
OR LOWER(city) LIKE %s
OR LOWER(pincode) LIKE %s
OR LOWER(email_id) LIKE %s
OR LOWER(phone) LIKE %s
OR LOWER(mobile_no) LIKE %s
ORDER BY creation DESC
LIMIT 100
""", tuple(["%" + value + "%"] * 8), as_dict=True)
result = []
for l in leads:
result.append({
"name": l.name,
"additional_numbers": "",
"field_star": l.status or "",
"customer_address": l.address_line1 or "",
"any_letter": l.title or "",
"city": l.city or "",
"field_e": l.email_id or "",
"zip": l.pincode or "",
"company_name": l.company_name or "",
"contacted_date": "",
"contact_persons": l.title or "",
"follow_up_date": "",
"email_address": l.email_id or "",
"last_pu_date": "",
"contact_numbers": (l.phone or "") + "\n" + (l.mobile_no or ""),
"hours_operation": l.industry or "",
"comments": l.source or ""
})
return result
@@ -0,0 +1,5 @@
.badge-fresh { background-color: #28a745; }
.badge-aging { background-color: #ffc107; color: #212529; }
.badge-expired { background-color: #dc3545; }
.badge-needs { background-color: #fd7e14; }
.badge-error { background-color: #6c757d; }
@@ -0,0 +1,8 @@
<style>
.badge.badge-fresh { background-color: #28a745; }
.badge.badge-aging { background-color: #ffc107; color: #212529; }
.badge.badge-expired { background-color: #dc3545; }
.badge.badge-needs { background-color: #fd7e14; }
.badge.badge-error { background-color: #6c757d; }
</style>
<div id="ebay-pricing-page"></div>
@@ -0,0 +1,323 @@
frappe.pages['ebay-pricing'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: __('eBay Pricing'),
single_column: true
});
let $container = $(`
<div style="padding: 1rem;">
<div class="row">
<div class="col-md-8">
<div class="form-group">
<label>Search Model</label>
<div class="input-group">
<input type="text" class="form-control" id="ebay-search-input"
placeholder="Dell Latitude 5410..." autocomplete="off">
<span class="input-group-btn">
<button class="btn btn-primary" id="ebay-search-btn">
<i class="fa fa-search"></i> Search
</button>
</span>
</div>
</div>
</div>
<div class="col-md-4 text-right">
<div class="form-group">
<label>Batch Size</label>
<select class="form-control" id="ebay-batch-size" style="display:inline-block; width:auto;">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">All</option>
</select>
<button class="btn btn-warning" id="ebay-batch-btn">
<i class="fa fa-play"></i> Price Batch
</button>
</div>
</div>
</div>
<hr>
<div class="row">
<div class="col-md-6">
<h4>Apply Pricing to Inventory</h4>
<div class="form-group">
<select class="form-control" id="ebay-apply-item" style="width: 100%;">
<option value="">Select Item to apply pricing...</option>
</select>
</div>
<button class="btn btn-success" id="ebay-apply-btn">
<i class="fa fa-check"></i> Apply Pricing
</button>
<button class="btn btn-info" id="ebay-apply-all-btn" style="margin-left: 0.5rem;">
<i class="fa fa-check-double"></i> Apply All
</button>
</div>
<div class="col-md-6 text-right">
<div class="well" style="display: inline-block; text-align: left;">
<h5>Pricing Status</h5>
<div id="pricing-stats">
<p class="text-muted">Click Apply All to see stats</p>
</div>
</div>
</div>
</div>
<hr>
<div id="ebay-results-area">
<div class="text-muted text-center" style="padding: 4rem;">
<i class="fa fa-search" style="font-size: 3rem; opacity: 0.3;"></i>
<p>Search for a model or run batch pricing</p>
</div>
</div>
</div>`).appendTo(page.main);
// Load item dropdown
load_item_dropdown();
// Event handlers
$container.find('#ebay-search-btn').on('click', function() {
let query = $container.find('#ebay-search-input').val().trim();
if (!query) {
frappe.msgprint(__('Enter a model to search'));
return;
}
search_ebay(query);
});
$container.find('#ebay-search-input').on('keypress', function(e) {
if (e.which === 13) {
$container.find('#ebay-search-btn').trigger('click');
}
});
$container.find('#ebay-batch-btn').on('click', function() {
let size = $container.find('#ebay-batch-size').val();
run_batch(size);
});
$container.find('#ebay-apply-btn').on('click', function() {
let item = $container.find('#ebay-apply-item').val();
if (!item) {
frappe.msgprint(__('Select an Item first'));
return;
}
apply_pricing(item);
});
$container.find('#ebay-apply-all-btn').on('click', function() {
apply_pricing_all();
});
function load_item_dropdown() {
frappe.call({
method: 'frappe.client.get_list',
args: {
doctype: 'Item',
filters: {
'disabled': 0,
'item_group': ['in', ['Laptop', 'Desktop', 'Tablet', 'Phone', 'Workstation']]
},
fields: ['name', 'item_name'],
limit: 1000
},
callback: function(r) {
if (r.message) {
let $select = $container.find('#ebay-apply-item');
r.message.forEach(item => {
$select.append(`<option value="${item.name}">${item.item_name || item.name}</option>`);
});
}
}
});
}
function search_ebay(query) {
frappe.call({
method: 'westech_r2.api.ebay_pricing.search_model',
args: { query: query },
freeze: true,
freeze_message: __('Searching eBay sold listings...'),
callback: function(r) {
if (r.message && r.message.results) {
render_results(r.message);
} else {
let msg = (r.message && r.message.message) || __('No results found');
frappe.msgprint(msg);
}
}
});
}
function run_batch(size) {
frappe.call({
method: 'westech_r2.api.ebay_pricing.run_batch',
args: { batch_size: size },
freeze: true,
freeze_message: __('Running batch pricing...'),
callback: function(r) {
if (r.message) {
frappe.msgprint(__('Batch complete: {0} priced, {1} failed, {2} skipped',
[r.message.priced, r.message.failed, r.message.skipped]));
load_recent_pricing();
}
}
});
}
function apply_pricing(item_code) {
frappe.call({
method: 'westech_r2.api.ebay_pricing.batch_apply_pricing',
args: { item_code: item_code },
freeze: true,
freeze_message: __('Applying pricing to Serial Nos...'),
callback: function(r) {
if (r.message) {
render_pricing_stats(r.message);
frappe.msgprint(__('Pricing applied: {0} priced, {1} commodity, {2} needs grading',
[r.message.priced, r.message.commodity, r.message.needs_grading]));
}
}
});
}
function apply_pricing_all() {
frappe.call({
method: 'westech_r2.api.ebay_pricing.batch_apply_pricing',
args: { batch_size: 1000 },
freeze: true,
freeze_message: __('Applying pricing to all Serial Nos...'),
callback: function(r) {
if (r.message) {
render_pricing_stats(r.message);
frappe.msgprint(__('Batch pricing applied: {0} priced, {1} commodity, {2} needs grading, {3} errors',
[r.message.priced, r.message.commodity, r.message.needs_grading, r.message.errors]));
}
}
});
}
function render_pricing_stats(stats) {
let html = `
<table class="table table-condensed" style="margin-bottom: 0;">
<tr><td>Priced</td><td><span class="badge badge-success">${stats.priced || 0}</span></td></tr>
<tr><td>Commodity</td><td><span class="badge badge-warning">${stats.commodity || 0}</span></td></tr>
<tr><td>Needs Grading</td><td><span class="badge badge-info">${stats.needs_grading || 0}</span></td></tr>
<tr><td>Needs Price Point</td><td><span class="badge badge-primary">${stats.needs_price_point || 0}</span></td></tr>
<tr><td>Errors</td><td><span class="badge badge-danger">${stats.errors || 0}</span></td></tr>
</table>
`;
$container.find('#pricing-stats').html(html);
}
function render_results(data) {
let $area = $container.find('#ebay-results-area').empty();
if (!data.results || !data.results.length) {
$area.html(`<div class="text-muted text-center" style="padding: 2rem;">No results</div>`);
return;
}
let html = `<table class="table table-bordered">
<thead><tr>
<th>Title</th>
<th>Price</th>
<th>Condition</th>
<th>Sold</th>
<th>Shipping</th>
</tr></thead>
<tbody>`;
data.results.forEach(item => {
html += `<tr>
<td>${frappe.utils.escape_html(item.title || '')}</td>
<td>$${(item.price || 0).toFixed(2)}</td>
<td>${frappe.utils.escape_html(item.condition || '')}</td>
<td>${item.sold || ''}</td>
<td>${item.shipping || ''}</td>
</tr>`;
});
html += `</tbody></table>`;
if (data.pricing) {
html += `<div class="well">
<h4>Pricing Summary</h4>
<div class="row">
<div class="col-md-3"><strong>Low:</strong> $${data.pricing.price_low}</div>
<div class="col-md-3"><strong>High:</strong> $${data.pricing.price_high}</div>
<div class="col-md-3"><strong>Average:</strong> $${data.pricing.price_average}</div>
<div class="col-md-3"><strong>Median:</strong> $${data.pricing.price_auction}</div>
</div>
<div class="row" style="margin-top: 1rem;">
<div class="col-md-6"><strong>Source:</strong> ${data.pricing.source}</div>
<div class="col-md-6"><strong>Samples:</strong> ${data.pricing.sample_count}</div>
</div>
</div>`;
}
$area.html(html);
}
function load_recent_pricing() {
frappe.call({
method: 'westech_r2.api.ebay_pricing.get_recent_pricing',
args: { limit: 50 },
callback: function(r) {
if (r.message) {
render_pricing_grid(r.message);
}
}
});
}
function render_pricing_grid(items) {
let $area = $container.find('#ebay-results-area');
if (!items || !items.length) {
$area.html(`<div class="text-muted text-center" style="padding: 2rem;">No pricing data yet</div>`);
return;
}
let html = `<h4>Recent Pricing Results</h4>
<table class="table table-bordered table-hover">
<thead><tr>
<th>Manufacturer</th>
<th>Model</th>
<th>Status</th>
<th>Age</th>
<th>Low</th>
<th>High</th>
<th>Avg</th>
<th>Samples</th>
<th>Source</th>
<th>Last Priced</th>
</tr></thead>
<tbody>`;
items.forEach(row => {
let status_class = 'badge-needs';
if (row.pricing_status === 'Priced') status_class = 'badge-fresh';
else if (row.pricing_status === 'Manual Override') status_class = 'badge-fresh';
else if (row.pricing_status === 'Expired') status_class = 'badge-expired';
else if (row.pricing_status === 'Error') status_class = 'badge-error';
let age = row.days_since_pricing || 0;
let age_badge = age < 90 ? 'badge-fresh' : (age < 120 ? 'badge-aging' : 'badge-expired');
html += `<tr>
<td>${frappe.utils.escape_html(row.manufacturer || '')}</td>
<td>${frappe.utils.escape_html(row.model || '')}</td>
<td><span class="badge ${status_class}">${row.pricing_status}</span></td>
<td><span class="badge ${age_badge}">${age} days</span></td>
<td>$${row.price_low || ''}</td>
<td>$${row.price_high || ''}</td>
<td>$${row.price_average || ''}</td>
<td>${row.sample_count || ''}</td>
<td>${row.source || ''}</td>
<td>${frappe.datetime.str_to_user(row.scraped_at) || ''}</td>
</tr>`;
});
html += `</tbody></table>`;
$area.html(html);
}
load_recent_pricing();
};
@@ -0,0 +1,26 @@
{
"creation": "2026-05-17 05:30:00.000000",
"docstatus": 0,
"doctype": "Page",
"icon": "fa fa-tags",
"modified": "2026-05-17 05:30:00.000000",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "ebay-pricing",
"owner": "Administrator",
"page_name": "ebay-pricing",
"roles": [
{
"role": "System Manager"
},
{
"role": "Stock User"
},
{
"role": "Sales User"
}
],
"standard": "Yes",
"system_page": 0,
"title": "eBay Pricing"
}
@@ -0,0 +1 @@
# eBay Pricing desk page
@@ -0,0 +1,5 @@
.badge-fresh { background-color: #28a745; }
.badge-aging { background-color: #ffc107; color: #212529; }
.badge-expired { background-color: #dc3545; }
.badge-needs { background-color: #fd7e14; }
.badge-error { background-color: #6c757d; }
@@ -0,0 +1,8 @@
<style>
.badge.badge-fresh { background-color: #28a745; }
.badge.badge-aging { background-color: #ffc107; color: #212529; }
.badge.badge-expired { background-color: #dc3545; }
.badge.badge-needs { background-color: #fd7e14; }
.badge.badge-error { background-color: #6c757d; }
</style>
<div id="ebay-pricing-page"></div>
@@ -0,0 +1 @@
ebay-pricing.js
@@ -0,0 +1 @@
ebay-pricing.json
@@ -0,0 +1 @@
# eBay Pricing desk page
@@ -0,0 +1 @@
@@ -0,0 +1,4 @@
frappe.pages["eim-portal"].on_page_load = function(wrapper) {
wrapper.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:60vh;font-family:sans-serif;"><div style="text-align:center;"><i class="fa fa-spinner fa-spin" style="font-size:24px;color:#2d7d46;"></i><p style="margin-top:12px;color:#555;">Redirecting to EIM Device Portal...</p></div></div>';
setTimeout(function() { window.location.href = "https://eim.diagalon.com"; }, 500);
};
@@ -0,0 +1,13 @@
{
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 14:00:00",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "eim-portal",
"owner": "Administrator",
"standard": "Yes",
"title": "EIM Device Portal"
}
@@ -0,0 +1,5 @@
import frappe
def get_context(context):
frappe.local.flags.redirect_location = "https://eim.diagalon.com"
raise frappe.Redirect
@@ -0,0 +1,7 @@
frappe.pages['eim-portal'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'EIM Device Portal',
single_column: true
});
}
@@ -0,0 +1,23 @@
{
"content": null,
"creation": "2026-05-09 14:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-05-09 15:09:48.653878",
"modified_by": "Administrator",
"module": "Westech R2",
"name": "eim-portal",
"owner": "Administrator",
"page_name": "eim-portal",
"roles": [
{
"role": "All"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "EIM Device Portal"
}
@@ -0,0 +1,11 @@
.intake-station .card { border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; }
.intake-station .card-header { padding: 15px; }
.intake-station .card-body { padding: 20px; }
.intake-station .form-group { margin-bottom: 15px; }
.intake-station .form-control { border-radius: 4px; padding: 8px 12px; font-size: 16px; }
.intake-station .form-control:focus { border-color: #6f42c1; box-shadow: 0 0 0 0.2rem rgba(111,66,193,0.25); }
.intake-station label { font-weight: 600; margin-bottom: 4px; }
.intake-station h5 { margin-bottom: 15px; padding-bottom: 8px; border-bottom: 2px solid #e0e0e0; }
.intake-station .table th { background: #f8f9fa; }
.intake-station .btn-primary { background: linear-gradient(135deg, #6f42c1, #28a745) !important; border: none !important; }
.intake-station .label { font-size: 0.85em; }
+580
View File
@@ -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
}

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