diff --git a/custom_ui/api/db/clients.py b/custom_ui/api/db/clients.py
index 39d5add..0edb984 100644
--- a/custom_ui/api/db/clients.py
+++ b/custom_ui/api/db/clients.py
@@ -310,7 +310,6 @@ def upsert_client(data):
"phone": primary_contact.get("phone_number"),
"custom_customer_name": customer_name,
"customer_type": customer_type,
- "address_type": "Billing",
"companies": [{ "company": data.get("company_name")
}]
}
@@ -338,7 +337,7 @@ def upsert_client(data):
"last_name": contact_data.get("last_name"),
"role": contact_data.get("contact_role", "Other"),
"custom_email": contact_data.get("email"),
- "is_primary_contact":1 if contact_data.get("is_primary", False) else 0,
+ "is_primary_contact": 1 if contact_data.get("is_primary", False) else 0,
"customer_type": "Lead",
"customer_name": client_doc.name,
"email_ids": [{
@@ -370,11 +369,13 @@ def upsert_client(data):
# Handle address creation
address_docs = []
for address in addresses:
+ is_billing = True if address.get("is_billing_address") else False
print("#####DEBUG: Creating address with data:", address)
address_doc = AddressService.create_address({
- "address_title": build_address_title(customer_name, address),
+ "address_title": AddressService.build_address_title(customer_name, address),
"address_line1": address.get("address_line1"),
"address_line2": address.get("address_line2"),
+ "address_type": "Billing" if is_billing else "Service",
"city": address.get("city"),
"state": address.get("state"),
"country": "United States",
@@ -385,6 +386,9 @@ def upsert_client(data):
})
AddressService.link_address_to_customer(address_doc, "Lead", client_doc.name)
address_doc.reload()
+ if is_billing:
+ client_doc.custom_billing_address = address_doc.name
+ client_doc.save(ignore_permissions=True)
for contact_to_link_idx in address.get("contacts", []):
contact_doc = contact_docs[contact_to_link_idx]
AddressService.link_address_to_contact(address_doc, contact_doc.name)
diff --git a/custom_ui/api/db/estimates.py b/custom_ui/api/db/estimates.py
index 82d2f36..bf7e260 100644
--- a/custom_ui/api/db/estimates.py
+++ b/custom_ui/api/db/estimates.py
@@ -37,7 +37,7 @@ def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
tableRows = []
for estimate in estimates:
- full_address = frappe.db.get_value("Address", estimate.get("custom_installation_address"), "full_address")
+ full_address = frappe.db.get_value("Address", estimate.get("custom_job_address"), "full_address")
tableRow = {}
tableRow["id"] = estimate["name"]
tableRow["address"] = full_address
@@ -69,7 +69,7 @@ def get_estimate(estimate_name):
estimate = frappe.get_doc("Quotation", estimate_name)
est_dict = estimate.as_dict()
- address_name = estimate.custom_installation_address or estimate.customer_address
+ address_name = estimate.custom_job_address or estimate.customer_address
if address_name:
# Fetch Address Doc
address_doc = frappe.get_doc("Address", address_name).as_dict()
@@ -386,8 +386,6 @@ def upsert_estimate(data):
print("DEBUG: Upsert estimate data:", data)
address_doc = AddressService.get_or_throw(data.get("address_name"))
estimate_name = data.get("estimate_name")
- client_doctype = ClientService.get_client_doctype(address_doc.customer_name)
- print("DEBUG: Retrieved client doctype:", client_doctype)
project_template = data.get("project_template", None)
# If estimate_name exists, update existing estimate
@@ -432,6 +430,12 @@ def upsert_estimate(data):
else:
print("DEBUG: Creating new estimate")
print("DEBUG: Retrieved address name:", data.get("address_name"))
+ client_doc = ClientService.get_client_or_throw(address_doc.customer_name)
+ # billing_address = next((addr for addr in address_doc if addr.address_type == "Billing"), None)
+ # if billing_address:
+ # print("DEBUG: Found billing address:", billing_address.name)
+ # else:
+ # print("DEBUG: No billing address found for client:", client_doc.name)
new_estimate = frappe.get_doc({
"doctype": "Quotation",
"custom_requires_half_payment": data.get("requires_half_payment", 0),
@@ -441,9 +445,9 @@ def upsert_estimate(data):
"party_name": data.get("contact_name"),
"quotation_to": "Contact",
"company": data.get("company"),
- "actual_customer_name": address_doc.customer_name,
- "customer_type": client_doctype,
- "customer_address": data.get("address_name"),
+ "actual_customer_name": client_doc.name,
+ "customer_type": address_doc.customer_type,
+ "customer_address": client_doc.custom_billing_address,
"contact_person": data.get("contact_name"),
"letter_head": data.get("company"),
"custom_project_template": data.get("project_template", None),
diff --git a/custom_ui/api/db/jobs.py b/custom_ui/api/db/jobs.py
index d27e700..9b7b419 100644
--- a/custom_ui/api/db/jobs.py
+++ b/custom_ui/api/db/jobs.py
@@ -1,5 +1,6 @@
import frappe, json
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
+from custom_ui.services import AddressService, ClientService
# ===============================================================================
# JOB MANAGEMENT API METHODS
@@ -45,6 +46,10 @@ def get_job(job_id=""):
print("DEBUG: Loading Job from database:", job_id)
try:
project = frappe.get_doc("Project", job_id)
+ address_doc = AddressService.get_or_throw(project.job_address)
+ project = project.as_dict()
+ project["job_address"] = address_doc
+ project["client"] = ClientService.get_client_or_throw(project.customer)
return build_success_response(project)
except Exception as e:
return build_error_response(str(e), 500)
diff --git a/custom_ui/api/db/tasks.py b/custom_ui/api/db/tasks.py
index adf282c..e53dd37 100644
--- a/custom_ui/api/db/tasks.py
+++ b/custom_ui/api/db/tasks.py
@@ -1,6 +1,18 @@
import frappe
from custom_ui.db_utils import process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
+from custom_ui.services import DbService
+@frappe.whitelist()
+def set_task_status(task_name, new_status):
+ """Set the status of a specific task."""
+ try:
+ task = DbService.get_or_throw("Task", task_name)
+ task.status = new_status
+ task.save()
+ return build_success_response(f"Task {task_name} status updated to {new_status}.")
+ except Exception as e:
+ return build_error_response(str(e), 500)
+
@frappe.whitelist()
def get_job_task_list(job_id=""):
diff --git a/custom_ui/events/estimate.py b/custom_ui/events/estimate.py
index 40df9ae..f7d212b 100644
--- a/custom_ui/events/estimate.py
+++ b/custom_ui/events/estimate.py
@@ -7,6 +7,9 @@ def after_insert(doc, method):
AddressService.append_link_v2(
doc.custom_job_address, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template}
)
+ AddressService.append_link_v2(
+ doc.custom_job_address, "links", {"link_doctype": "Quotation", "link_name": doc.name}
+ )
ClientService.append_link_v2(
doc.actual_customer_name, "quotations", {"quotation": doc.name, "project_template": doc.custom_project_template}
)
@@ -74,11 +77,11 @@ def on_update_after_submit(doc, method):
new_sales_order.set_payment_schedule()
print("DEBUG: Inserting Sales Order:", new_sales_order.as_dict())
new_sales_order.delivery_date = new_sales_order.transaction_date
- backup = new_sales_order.customer_address
- new_sales_order.customer_address = None
+ # backup = new_sales_order.customer_address
+ # new_sales_order.customer_address = None
new_sales_order.insert()
print("DEBUG: Submitting Sales Order")
- new_sales_order.customer_address = backup
+ # new_sales_order.customer_address = backup
new_sales_order.submit()
frappe.db.commit()
print("DEBUG: Sales Order created successfully:", new_sales_order.name)
diff --git a/custom_ui/events/jobs.py b/custom_ui/events/jobs.py
index f05774c..aa80dc0 100644
--- a/custom_ui/events/jobs.py
+++ b/custom_ui/events/jobs.py
@@ -1,5 +1,28 @@
import frappe
+from custom_ui.services import AddressService, ClientService
+
+def after_insert(doc, method):
+ print("DEBUG: After Insert Triggered for Project")
+ print("DEBUG: Linking Project to address and Customer")
+ AddressService.append_link_v2(
+ doc.job_address, "projects", {"project": doc.name, "project_template": doc.project_template}
+ )
+ AddressService.append_link_v2(
+ doc.job_address, "links", {"link_doctype": "Project", "link_name": doc.name}
+ )
+ ClientService.append_link_v2(
+ doc.customer, "projects", {"project": doc.name, "project_template": doc.project_template}
+ )
+ if doc.project_template == "SNW Install":
+ print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
+ AddressService.update_value(
+ doc.job_address,
+ "job_status",
+ "In Progress"
+ )
+
+
def before_insert(doc, method):
# This is where we will add logic to set tasks and other properties of a job based on it's project_template
pass
\ No newline at end of file
diff --git a/custom_ui/events/onsite_meeting.py b/custom_ui/events/onsite_meeting.py
index 334a514..32a0262 100644
--- a/custom_ui/events/onsite_meeting.py
+++ b/custom_ui/events/onsite_meeting.py
@@ -14,6 +14,7 @@ def after_insert(doc, method):
print("DEBUG: After Insert Triggered for On-Site Meeting")
print("DEBUG: Linking bid meeting to customer and address")
AddressService.append_link_v2(doc.address, "onsite_meetings", {"onsite_meeting": doc.name, "project_template": doc.project_template})
+ AddressService.append_link_v2(doc.address, "links", {"link_doctype": "On-Site Meeting", "link_name": doc.name})
ClientService.append_link(doc.party_name, "onsite_meetings", "onsite_meeting", doc.name)
if doc.project_template == "SNW Install":
print("DEBUG: Project template is SNW Install, updating Address status to In Progress")
diff --git a/custom_ui/events/sales_order.py b/custom_ui/events/sales_order.py
index 1d35125..9c4499c 100644
--- a/custom_ui/events/sales_order.py
+++ b/custom_ui/events/sales_order.py
@@ -1,5 +1,13 @@
import frappe
-from custom_ui.services import DbService
+from custom_ui.services import DbService, AddressService, ClientService
+
+def before_insert(doc, method):
+ print("DEBUG: before_insert hook triggered for Sales Order:", doc.name)
+ if doc.custom_project_template == "SNW Install":
+ print("DEBUG: Sales Order uses SNW Install template, checking for duplicate linked sales orders.")
+ address_doc = AddressService.get_or_throw(doc.custom_job_address)
+ if "SNW Install" in [link.project_template for link in address_doc.sales_orders]:
+ raise frappe.ValidationError("A Sales Order with project template 'SNW Install' is already linked to this address.")
def on_submit(doc, method):
print("DEBUG: Info from Sales Order")
@@ -7,27 +15,39 @@ def on_submit(doc, method):
print(doc.company)
print(doc.transaction_date)
print(doc.customer)
+ print(doc.custom_job_address)
+ print(doc.custom_project_template)
# Create Invoice and Project from Sales Order
try:
print("Creating Project from Sales Order", doc.name)
- sales_order = frappe.get_doc("Sales Order", doc.name)
- if not sales_order.custom_project_template:
- return
- project_template = DbService.get("Project Template", sales_order.custom_project_template)
- new_job = frappe.get_doc({
- "doctype": "Project",
- "custom_job_address": sales_order.custom_job_address,
- "project_name": f"{sales_order.custom_project_template} - {sales_order.custom_job_address}",
- "project_template": project_template.name,
- "custom_warranty_duration_days": 90,
- "sales_order": sales_order.name
- })
- # attatch the job to the sales_order links
- new_job.insert()
- frappe.db.commit()
+ if doc.custom_project_template or doc.project_template:
+ new_job = frappe.get_doc({
+ "doctype": "Project",
+ "custom_job_address": doc.custom_job_address,
+ "project_name": f"{doc.custom_project_template} - {doc.custom_job_address}",
+ "project_template": doc.custom_project_template,
+ "custom_warranty_duration_days": 90,
+ "customer": doc.customer,
+ "job_address": doc.custom_job_address,
+ "sales_order": doc.name
+ })
+ # attatch the job to the sales_order links
+ new_job.insert()
+ frappe.db.commit()
except Exception as e:
print("ERROR creating Project from Sales Order:", str(e))
+def after_insert(doc, method):
+ print("DEBUG: after_insert hook triggered for Sales Order:", doc.name)
+ AddressService.append_link_v2(
+ doc.custom_job_address, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
+ )
+ AddressService.append_link_v2(
+ doc.custom_job_address, "links", {"link_doctype": "Sales Order", "link_name": doc.name}
+ )
+ ClientService.append_link_v2(
+ doc.customer, "sales_orders", {"sales_order": doc.name, "project_template": doc.custom_project_template}
+ )
def create_sales_invoice_from_sales_order(doc, method):
try:
diff --git a/custom_ui/fixtures/doctype.json b/custom_ui/fixtures/doctype.json
index 5455fe4..1571089 100644
--- a/custom_ui/fixtures/doctype.json
+++ b/custom_ui/fixtures/doctype.json
@@ -114,8 +114,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.065211",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.476708",
"module": "Custom",
"name": "Lead Companies Link",
"naming_rule": "",
@@ -332,8 +332,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.117683",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.527399",
"module": "Custom",
"name": "Address Project Link",
"naming_rule": "",
@@ -550,8 +550,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.173958",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.579759",
"module": "Custom",
"name": "Address Quotation Link",
"naming_rule": "",
@@ -768,8 +768,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.229451",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.630680",
"module": "Custom",
"name": "Address On-Site Meeting Link",
"naming_rule": "",
@@ -901,6 +901,70 @@
"trigger": null,
"unique": 0,
"width": null
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "documentation_url": null,
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "project_template",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "is_virtual": 0,
+ "label": "Project Template",
+ "length": 0,
+ "link_filters": null,
+ "make_attachment_public": 0,
+ "mandatory_depends_on": null,
+ "max_height": null,
+ "no_copy": 0,
+ "non_negative": 0,
+ "oldfieldname": null,
+ "oldfieldtype": null,
+ "options": "Project Template",
+ "parent": "Address Sales Order Link",
+ "parentfield": "fields",
+ "parenttype": "DocType",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "show_dashboard": 0,
+ "show_on_timeline": 0,
+ "show_preview_popup": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "trigger": null,
+ "unique": 0,
+ "width": null
}
],
"force_re_route_to_default_view": 0,
@@ -922,8 +986,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.280251",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:19:33.624850",
"module": "Custom",
"name": "Address Sales Order Link",
"naming_rule": "",
@@ -1076,8 +1140,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.332720",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.729324",
"module": "Custom",
"name": "Contact Address Link",
"naming_rule": "",
@@ -1230,8 +1294,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.385343",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.780526",
"module": "Custom",
"name": "Lead On-Site Meeting Link",
"naming_rule": "",
@@ -1832,8 +1896,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.457103",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.847539",
"module": "Selling",
"name": "Quotation Template",
"naming_rule": "",
@@ -2330,8 +2394,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.529451",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.916692",
"module": "Selling",
"name": "Quotation Template Item",
"naming_rule": "",
@@ -2484,8 +2548,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.580626",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:17.965428",
"module": "Custom UI",
"name": "Customer Company Link",
"naming_rule": "",
@@ -2638,8 +2702,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.630918",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.018887",
"module": "Custom UI",
"name": "Customer Address Link",
"naming_rule": "",
@@ -2792,8 +2856,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.681698",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.066488",
"module": "Custom UI",
"name": "Customer Contact Link",
"naming_rule": "",
@@ -2946,8 +3010,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.731378",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.115936",
"module": "Custom",
"name": "Address Contact Link",
"naming_rule": "",
@@ -3100,8 +3164,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.783154",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.164485",
"module": "Custom",
"name": "Customer On-Site Meeting Link",
"naming_rule": "",
@@ -3254,8 +3318,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.832605",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.212622",
"module": "Custom",
"name": "Customer Project Link",
"naming_rule": "",
@@ -3408,8 +3472,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.882511",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.262231",
"module": "Custom",
"name": "Customer Quotation Link",
"naming_rule": "",
@@ -3562,8 +3626,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.934072",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.311056",
"module": "Custom",
"name": "Customer Sales Order Link",
"naming_rule": "",
@@ -3716,8 +3780,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:57.984126",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.359782",
"module": "Custom",
"name": "Lead Address Link",
"naming_rule": "",
@@ -3870,8 +3934,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:58.034758",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.408094",
"module": "Custom",
"name": "Lead Contact Link",
"naming_rule": "",
@@ -4024,8 +4088,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:58.086245",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.457175",
"module": "Custom",
"name": "Lead Quotation Link",
"naming_rule": "",
@@ -4178,8 +4242,8 @@
"make_attachments_public": 0,
"max_attachments": 0,
"menu_index": null,
- "migration_hash": "717d2b4e3d605ae371d8c05bb650b364",
- "modified": "2026-01-15 10:05:58.139052",
+ "migration_hash": "633d6c4c53baf7641fc8ffe06fc9e3f2",
+ "modified": "2026-01-16 03:16:18.506817",
"module": "Custom",
"name": "Address Company Link",
"naming_rule": "",
diff --git a/custom_ui/hooks.py b/custom_ui/hooks.py
index c583d46..1759776 100644
--- a/custom_ui/hooks.py
+++ b/custom_ui/hooks.py
@@ -175,8 +175,14 @@ doc_events = {
"on_update_after_submit": "custom_ui.events.estimate.on_update_after_submit"
},
"Sales Order": {
+ "before_insert": "custom_ui.events.sales_order.before_insert",
+ "after_insert": "custom_ui.events.sales_order.after_insert",
"on_submit": "custom_ui.events.sales_order.on_submit"
},
+ "Project": {
+ "before_insert": "custom_ui.events.jobs.before_insert",
+ "after_insert": "custom_ui.events.jobs.after_insert"
+ },
"Task": {
"before_insert": "custom_ui.events.task.before_insert"
}
diff --git a/custom_ui/install.py b/custom_ui/install.py
index b409559..4c89c1b 100644
--- a/custom_ui/install.py
+++ b/custom_ui/install.py
@@ -30,7 +30,7 @@ def after_migrate():
frappe.clear_cache(doctype=doctype)
frappe.reload_doctype(doctype)
- update_address_fields()
+ # update_address_fields()
# build_frontend()
@@ -70,6 +70,17 @@ def add_custom_fields():
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
print("\n🔧 Adding custom fields to doctypes...")
+
+ try:
+ address_meta = frappe.get_meta("Address")
+ address_type_params = address_meta.get_field("address_type")
+ if address_type_params and "Service" not in (address_type_params.options or ""):
+ print(" Adding 'Service' to Address type options...")
+ from frappe.custom.doctype.property_setter.property_setter import make_property_setter
+ make_property_setter("Address", "address_type", "options", (address_type_params.options or "") + "\nService", "DocField")
+ print(" ✅ Added 'Service' to Address address_type options.")
+ except Exception as e:
+ print(f" ⚠️ Failed to update Address address_type: {e}")
custom_fields = {
"Customer": [
@@ -145,6 +156,13 @@ def add_custom_fields():
options="Lead On-Site Meeting Link",
insert_after="quotations"
),
+ dict(
+ fieldname="custom_billing_address",
+ label="Custom Address",
+ fieldtype="Link",
+ options="Address",
+ insert_after="customer_name"
+ ),
dict(
fieldname="quotations",
label="Quotations",
@@ -505,6 +523,24 @@ def add_custom_fields():
allow_on_submit=1
)
],
+ "Project": [
+ dict(
+ fieldname="job_address",
+ label="Job Address",
+ fieldtype="Link",
+ options="Address",
+ insert_after="project_name",
+ description="The address where the job is being performed."
+ ),
+ dict(
+ fieldname="customer",
+ label="Customer",
+ fieldtype="Link",
+ options="Customer",
+ insert_after="job_address",
+ description="The customer for whom the project is being executed."
+ )
+ ],
"Project Template": [
dict(
fieldname="company",
diff --git a/custom_ui/services/address_service.py b/custom_ui/services/address_service.py
index 14d81d0..38bc14c 100644
--- a/custom_ui/services/address_service.py
+++ b/custom_ui/services/address_service.py
@@ -1,13 +1,22 @@
import frappe
+from frappe.model.document import Document
import requests
from .contact_service import ContactService, DbService
class AddressService:
+ @staticmethod
+ def build_address_title(customer_name, address_data) -> str:
+ """Build a title for the address based on its fields."""
+ print(f"DEBUG: Building address title for customer '{customer_name}' with address data: {address_data}")
+ is_billing = address_data.get("is_billing_address", False)
+ address_type = "Billing" if is_billing else "Service"
+ return f"{customer_name} - {address_data.get('address_line1', '')} {address_data.get('city')} - {address_type}"
+
@staticmethod
def build_full_dict(
- address_doc,
- included_links: list = ["contacts", "on-site meetings", "quotations", "sales orders", "projects", "companies"]) -> dict:
+ address_doc: Document,
+ included_links: list = ["contacts", "on-site meetings", "quotations", "sales orders", "projects", "companies"]) -> frappe._dict:
"""Build a full dictionary representation of an address, including all links. Can optionally exclude links."""
print(f"DEBUG: Building full dict for Address {address_doc.name}")
address_dict = address_doc.as_dict()
@@ -27,12 +36,12 @@ class AddressService:
return address_dict
@staticmethod
- def get_address_by_full_address(full_address: str):
+ def get_address_by_full_address(full_address: str) -> Document:
"""Retrieve an address document by its full_address field. Returns None if not found."""
print(f"DEBUG: Retrieving Address document with full_address: {full_address}")
address_name = frappe.db.get_value("Address", {"full_address": full_address})
if address_name:
- address_doc = frappe.get_doc("Address", address_name)
+ address_doc = DbService.get_or_throw("Address", address_name)
print("DEBUG: Address document found.")
return address_doc
print("DEBUG: Address document not found.")
@@ -47,18 +56,18 @@ class AddressService:
return result
@staticmethod
- def get(address_name: str):
+ def get(address_name: str) -> Document:
"""Retrieve an address document by name. Returns None if not found."""
print(f"DEBUG: Retrieving Address document with name: {address_name}")
if AddressService.exists(address_name):
- address_doc = frappe.get_doc("Address", address_name)
+ address_doc = DbService.get_or_throw("Address", address_name)
print("DEBUG: Address document found.")
return address_doc
print("DEBUG: Address document not found.")
return None
@staticmethod
- def get_or_throw(address_name: str):
+ def get_or_throw(address_name: str) -> Document:
"""Retrieve an address document by name or throw an error if not found."""
address_doc = AddressService.get(address_name)
if address_doc:
@@ -66,43 +75,43 @@ class AddressService:
raise ValueError(f"Address with name {address_name} does not exist.")
@staticmethod
- def update_value(docname: str, fieldname: str, value, save: bool = True) -> frappe._dict:
+ def update_value(doc_name: str, fieldname: str, value, save: bool = True) -> Document:
"""Update a specific field value of a document."""
- print(f"DEBUG: Updating Address {docname}, setting {fieldname} to {value}")
- if AddressService.exists(docname) is False:
- raise ValueError(f"Address with name {docname} does not exist.")
+ print(f"DEBUG: Updating Address {doc_name}, setting {fieldname} to {value}")
+ if AddressService.exists(doc_name) is False:
+ raise ValueError(f"Address with name {doc_name} does not exist.")
if save:
print("DEBUG: Saving updated Address document.")
- address_doc = AddressService.get_or_throw(docname)
+ address_doc = AddressService.get_or_throw(doc_name)
setattr(address_doc, fieldname, value)
address_doc.save(ignore_permissions=True)
else:
print("DEBUG: Not saving Address document as save=False.")
- frappe.db.set_value("Address", docname, fieldname, value)
- print(f"DEBUG: Updated Address {docname}: set {fieldname} to {value}")
+ frappe.db.set_value("Address", doc_name, fieldname, value)
+ print(f"DEBUG: Updated Address {doc_name}: set {fieldname} to {value}")
return address_doc
@staticmethod
- def get_value(docname: str, fieldname: str):
+ def get_value(doc_name: str, fieldname: str) -> any:
"""Get a specific field value of a document. Returns None if document does not exist."""
- print(f"DEBUG: Getting value of field {fieldname} from Address {docname}")
- if not AddressService.exists(docname):
+ print(f"DEBUG: Getting value of field {fieldname} from Address {doc_name}")
+ if not AddressService.exists(doc_name):
print("DEBUG: Value cannot be retrieved; Address does not exist.")
return None
- value = frappe.db.get_value("Address", docname, fieldname)
+ value = frappe.db.get_value("Address", doc_name, fieldname)
print(f"DEBUG: Retrieved value: {value}")
return value
@staticmethod
- def get_value_or_throw(docname: str, fieldname: str):
+ def get_value_or_throw(doc_name: str, fieldname: str) -> any:
"""Get a specific field value of a document or throw an error if document does not exist."""
- value = AddressService.get_value(docname, fieldname)
+ value = AddressService.get_value(doc_name, fieldname)
if value is not None:
return value
- raise ValueError(f"Address with name {docname} does not exist.")
+ raise ValueError(f"Address with name {doc_name} does not exist.")
@staticmethod
- def create(address_data: dict):
+ def create(address_data: dict) -> Document:
"""Create a new address."""
print("DEBUG: Creating new Address with data:", address_data)
address = frappe.get_doc({
@@ -114,26 +123,34 @@ class AddressService:
return address
@staticmethod
- def link_address_to_customer(address_doc, customer_type, customer_name):
+ def link_address_to_customer(address_doc: Document, customer_type: str, customer_name: str):
"""Link an address to a customer or lead."""
print(f"DEBUG: Linking Address {address_doc.name} to {customer_type} {customer_name}")
address_doc.customer_type = customer_type
address_doc.customer_name = customer_name
+ address_doc.append("links", {
+ "link_doctype": customer_type,
+ "link_name": customer_name
+ })
address_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_doc.name} to {customer_type} {customer_name}")
@staticmethod
- def link_address_to_contact(address_doc, contact_name):
+ def link_address_to_contact(address_doc: Document, contact_name: str):
"""Link an address to a contact."""
print(f"DEBUG: Linking Address {address_doc.name} to Contact {contact_name}")
address_doc.append("contacts", {
"contact": contact_name
})
+ address_doc.append("links", {
+ "link_doctype": "Contact",
+ "link_name": contact_name
+ })
address_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_doc.name} to Contact {contact_name}")
@staticmethod
- def create_address(address_data):
+ def create_address(address_data: dict) -> Document:
"""Create a new address."""
address = frappe.get_doc({
"doctype": "Address",
@@ -172,7 +189,7 @@ class AddressService:
print(f"DEBUG: Set link field {field} for Address {address_name} with link data {link}")
@staticmethod
- def get_county_and_set(address_doc, save: bool = False):
+ def get_county_and_set(address_doc: Document, save: bool = False):
"""Get the county from the address document and set it if not already set."""
if not address_doc.county:
print(f"DEBUG: Getting county for Address {address_doc.name}")
@@ -196,7 +213,11 @@ class AddressService:
except (KeyError, IndexError):
return None
- return {
+ county_info = {
"county": county,
"county_fips": county_fips
- }
\ No newline at end of file
+ }
+
+ AddressService.update_value(address_doc.name, "county", county_info, save)
+ AddressService.update_value(address_doc.name, "county_fips", county_fips, save)
+
\ No newline at end of file
diff --git a/custom_ui/services/client_service.py b/custom_ui/services/client_service.py
index 165b043..3207dd2 100644
--- a/custom_ui/services/client_service.py
+++ b/custom_ui/services/client_service.py
@@ -1,4 +1,5 @@
import frappe
+from frappe.model.document import Document
from .db_service import DbService
from erpnext.crm.doctype.lead.lead import make_customer
from .address_service import AddressService
@@ -8,6 +9,12 @@ from .onsite_meeting_service import OnSiteMeetingService
class ClientService:
+ @staticmethod
+ def get_client_or_throw(client_name: str) -> Document:
+ """Retrieve a Client document (Customer or Lead) or throw an error if it does not exist."""
+ doctype = ClientService.get_client_doctype(client_name)
+ return DbService.get_or_throw(doctype, client_name)
+
@staticmethod
def get_client_doctype(client_name: str) -> str:
"""Determine if the client is a Customer or Lead."""
@@ -56,8 +63,9 @@ class ClientService:
update_quotations: bool = True,
update_addresses: bool = True,
update_contacts: bool = True,
- update_onsite_meetings: bool = True
- ):
+ update_onsite_meetings: bool = True,
+ update_companies: bool = True
+ ) -> Document:
"""Convert a Lead to a Customer."""
print(f"DEBUG: Converting Lead {lead_name} to Customer")
try:
@@ -68,7 +76,7 @@ class ClientService:
customer_doc = make_customer(lead_doc.name)
print(f"DEBUG: make_customer() returned document type: {type(customer_doc)}")
print(f"DEBUG: Customer doc name: {customer_doc.name if hasattr(customer_doc, 'name') else 'NO NAME'}")
-
+ customer_doc.custom_billing_address = lead_doc.custom_billing_address
print("DEBUG: Calling customer_doc.insert()")
customer_doc.insert(ignore_permissions=True)
print(f"DEBUG: Customer inserted successfully: {customer_doc.name}")
@@ -128,6 +136,16 @@ class ClientService:
except Exception as e:
print(f"ERROR: Failed to link onsite meeting {meeting.get('onsite_meeting')}: {str(e)}")
frappe.log_error(f"Onsite meeting linking error: {str(e)}", "convert_lead_to_customer")
+ if update_companies:
+ print(f"DEBUG: Updating companies. Count: {len(lead_doc.get('companies', []))}")
+ for company in lead_doc.get("companies", []):
+ try:
+ print(f"DEBUG: Processing company: {company.get('company')}")
+ ClientService.append_link_v2(customer_doc.name, "companies", {"company": company.get("company")})
+ print(f"DEBUG: Linked company {company.get('company')} to customer")
+ except Exception as e:
+ print(f"ERROR: Failed to link company {company.get('company')}: {str(e)}")
+ frappe.log_error(f"Company linking error: {str(e)}", "convert_lead_to_customer")
print(f"DEBUG: Converted Lead {lead_name} to Customer {customer_doc.name}")
return customer_doc
diff --git a/custom_ui/services/contact_service.py b/custom_ui/services/contact_service.py
index 1f3b1cc..7cb3e28 100644
--- a/custom_ui/services/contact_service.py
+++ b/custom_ui/services/contact_service.py
@@ -1,10 +1,11 @@
import frappe
+from frappe.model.document import Document
from .db_service import DbService
class ContactService:
@staticmethod
- def create(data: dict):
+ def create(data: dict) -> Document:
"""Create a new contact."""
print("DEBUG: Creating new Contact with data:", data)
contact = frappe.get_doc({
@@ -16,25 +17,33 @@ class ContactService:
return contact
@staticmethod
- def link_contact_to_customer(contact_doc, customer_type, customer_name):
+ def link_contact_to_customer(contact_doc: Document, customer_type: str, customer_name: str):
"""Link a contact to a customer or lead."""
print(f"DEBUG: Linking Contact {contact_doc.name} to {customer_type} {customer_name}")
contact_doc.customer_type = customer_type
contact_doc.customer_name = customer_name
+ contact_doc.append("links", {
+ "link_doctype": customer_type,
+ "link_name": customer_name
+ })
contact_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Contact {contact_doc.name} to {customer_type} {customer_name}")
@staticmethod
- def link_contact_to_address(contact_doc, address_name):
+ def link_contact_to_address(contact_doc: Document, address_name: str):
"""Link an address to a contact."""
print(f"DEBUG: Linking Address {address_name} to Contact {contact_doc.name}")
contact_doc.append("addresses", {
"address": address_name
})
+ contact_doc.append("links", {
+ "link_doctype": "Address",
+ "link_name": address_name
+ })
contact_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Address {address_name} to Contact {contact_doc.name}")
@staticmethod
- def get_or_throw(contact_name: str):
+ def get_or_throw(contact_name: str) -> Document:
"""Retrieve a Contact document or throw an error if it does not exist."""
return DbService.get_or_throw("Contact", contact_name)
\ No newline at end of file
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 2699610..a6bb5e8 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -394,7 +394,11 @@ class Api {
static async getTaskStatusOptions() {
console.log("DEBUG: API - Loading Task Status options form the backend.");
const result = await this.request(FRAPPE_GET_TASKS_STATUS_OPTIONS, {});
- return result
+ return result;
+ }
+
+ static async setTaskStatus(taskName, newStatus) {
+ return await this.request(FRAPPE_SET_TASK_STATUS_METHOD, { taskName, newStatus });
}
// ============================================================================
diff --git a/frontend/src/components/clientSubPages/AddressInformationForm.vue b/frontend/src/components/clientSubPages/AddressInformationForm.vue
index 4d0ee9a..622bfb9 100644
--- a/frontend/src/components/clientSubPages/AddressInformationForm.vue
+++ b/frontend/src/components/clientSubPages/AddressInformationForm.vue
@@ -55,6 +55,7 @@
:id="`isBilling-${index}`"
v-model="address.isBillingAddress"
:disabled="isSubmitting"
+ @change="handleBillingChange(index)"
style="margin-top: 0"
/>
@@ -251,6 +252,36 @@ const formatAddressLine = (index, field, event) => {
localFormData.value.addresses[index][field] = formatted;
};
+const handleBillingChange = (selectedIndex) => {
+ // If the selected address is now checked as billing
+ if (localFormData.value.addresses[selectedIndex].isBillingAddress) {
+ // Uncheck all other addresses
+ localFormData.value.addresses.forEach((addr, idx) => {
+ if (idx !== selectedIndex) {
+ addr.isBillingAddress = false;
+ }
+ });
+
+ // Auto-select all contacts
+ if (contactOptions.value.length > 0) {
+ localFormData.value.addresses[selectedIndex].contacts = contactOptions.value.map(
+ (opt) => opt.value,
+ );
+ }
+
+ // Auto-select primary contact
+ if (localFormData.value.contacts && localFormData.value.contacts.length > 0) {
+ const primaryIndex = localFormData.value.contacts.findIndex((c) => c.isPrimary);
+ if (primaryIndex !== -1) {
+ localFormData.value.addresses[selectedIndex].primaryContact = primaryIndex;
+ } else {
+ // Fallback to first contact if no primary found
+ localFormData.value.addresses[selectedIndex].primaryContact = 0;
+ }
+ }
+ }
+};
+
const handleZipcodeInput = async (index, event) => {
const input = event.target.value;
@@ -299,7 +330,7 @@ const handleZipcodeInput = async (index, event) => {
.form-section {
background: var(--surface-card);
border-radius: 6px;
- padding: 1rem;
+ padding: 0.75rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
@@ -313,7 +344,7 @@ const handleZipcodeInput = async (index, event) => {
display: flex;
align-items: center;
gap: 0.5rem;
- margin-bottom: 0.75rem;
+ margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border);
}
diff --git a/frontend/src/components/clientSubPages/ClientInformationForm.vue b/frontend/src/components/clientSubPages/ClientInformationForm.vue
index cb249c7..899a986 100644
--- a/frontend/src/components/clientSubPages/ClientInformationForm.vue
+++ b/frontend/src/components/clientSubPages/ClientInformationForm.vue
@@ -254,7 +254,7 @@ defineExpose({
.form-section {
background: var(--surface-card);
border-radius: 6px;
- padding: 1rem;
+ padding: 0.75rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
@@ -268,7 +268,7 @@ defineExpose({
display: flex;
align-items: center;
gap: 0.5rem;
- margin-bottom: 0.75rem;
+ margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border);
}
diff --git a/frontend/src/components/clientSubPages/ContactInformationForm.vue b/frontend/src/components/clientSubPages/ContactInformationForm.vue
index 5f6285e..8e0bf57 100644
--- a/frontend/src/components/clientSubPages/ContactInformationForm.vue
+++ b/frontend/src/components/clientSubPages/ContactInformationForm.vue
@@ -285,7 +285,7 @@ defineExpose({});
.form-section {
background: var(--surface-card);
border-radius: 6px;
- padding: 1rem;
+ padding: 0.75rem;
border: 1px solid var(--surface-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s ease;
@@ -299,7 +299,7 @@ defineExpose({});
display: flex;
align-items: center;
gap: 0.5rem;
- margin-bottom: 0.75rem;
+ margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--surface-border);
}
diff --git a/frontend/src/components/clientSubPages/Overview.vue b/frontend/src/components/clientSubPages/Overview.vue
index 2fa0ccb..a84f507 100644
--- a/frontend/src/components/clientSubPages/Overview.vue
+++ b/frontend/src/components/clientSubPages/Overview.vue
@@ -1074,8 +1074,9 @@ const handleCancel = () => {
.status-cards {
display: flex;
- flex-wrap: wrap;
- gap: 1rem;
+ flex-direction: column;
+ gap: 0.5rem;
+ width: 100%;
}
.status-card {
diff --git a/frontend/src/components/pages/Estimate.vue b/frontend/src/components/pages/Estimate.vue
index ffb9339..e2e6a7a 100644
--- a/frontend/src/components/pages/Estimate.vue
+++ b/frontend/src/components/pages/Estimate.vue
@@ -50,19 +50,30 @@
placeholder="Select a contact"
:disabled="!formData.address || !isEditable"
fluid
- />
+ >
+
+