feat: CRM integration - customer intake, service invoice generation, COR print format\n\n- Added customer-intake page (search/create customers, create pallets)\n- Added whitelisted generate_service_invoice() API for native Sales Invoice creation\n- Added COR logo image asset\n- Removed stale fixtures/doctype.json (was causing bench migrate failures)

This commit is contained in:
Westech Admin
2026-05-20 22:57:53 +00:00
parent 6a5f2a3ebb
commit 4f4717463e
17 changed files with 469 additions and 916895 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+191
View File
@@ -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}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
<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: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,122 @@
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()
})
},
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 @@
{"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,82 @@
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.phone, c.email_id,
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.phone) 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.phone = 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.status = "Open"
pallet.save()
return {"status": "ok", "pallet": pallet.name}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB