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
@@ -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}