From 5ebb3bc8fbcbc80deb3abcb2683ea0db59f1e8eb Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Fri, 12 Jun 2026 16:03:47 +0530 Subject: [PATCH 01/10] feat: add Furnitex ERPNext automated setup script Adds a complete ERPNext v16 configuration script for Furnitex, an interior design and furniture manufacturing company based in Kolkata. Automates creation of: - Custom UOMs: SqFt, Rft, Sheet, Bag, Bundle - Item groups for raw materials and execution services - Supplier groups including Unregistered (URD) vendor category - Warehouses: Main Store + dynamic OnSite WIP per project - Tax templates: No-GST URD purchase, GST 18% purchase/sales - 10 service items (SqFt + lumpsum billing modes) - 19 raw material stock items (plywood, laminates, hardware, civil) - 6 sample suppliers with auto-applied URD tax defaults - Custom fields to tag Project on PI, PO, SE, DN, JE - Server scripts for OnSite WIP auto-warehouse and URD tax auto-clear - Custom Script Report: Furnitex Project Profitability Co-Authored-By: Claude Sonnet 4.6 --- setup_furnitex.py | 727 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 727 insertions(+) create mode 100644 setup_furnitex.py diff --git a/setup_furnitex.py b/setup_furnitex.py new file mode 100644 index 00000000..ed828305 --- /dev/null +++ b/setup_furnitex.py @@ -0,0 +1,727 @@ +""" +Furnitex ERPNext v16 Setup Script +Run inside container via: + bench --site frontend execute setup_furnitex.run_all +Or directly: + python /home/frappe/setup_furnitex.py +""" + +import frappe +import frappe.defaults + + +COMPANY = "Furnitex" +SITE = "frontend" + + +# ───────────────────────────────────────────────────────────── +# HELPERS +# ───────────────────────────────────────────────────────────── + +def exists(doctype, name): + return frappe.db.exists(doctype, name) + +def ok(msg): + print(f" [OK] {msg}") + +def skip(msg): + print(f" [SKIP] {msg}") + +def warn(msg): + print(f" [WARN] {msg}") + + +# ───────────────────────────────────────────────────────────── +# 1. UOMs +# ───────────────────────────────────────────────────────────── + +def create_uoms(): + print("\n[1/9] Creating UOMs...") + uoms = [ + ("SqFt", 0), + ("Rft", 0), + ("Bag", 1), + ("Sheet", 1), + ("Bundle", 1), + ("Cubic Ft", 0), + ] + for uom_name, whole in uoms: + if not exists("UOM", uom_name): + d = frappe.get_doc({ + "doctype": "UOM", + "uom_name": uom_name, + "must_be_whole_number": whole, + }) + d.flags.ignore_permissions = True + d.flags.ignore_mandatory = True + d.insert() + ok(f"UOM: {uom_name}") + else: + skip(f"UOM: {uom_name}") + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 2. ITEM GROUPS +# ───────────────────────────────────────────────────────────── + +def create_item_groups(): + print("\n[2/9] Creating Item Groups...") + groups = [ + ("Raw Materials - Furnitex", "All Item Groups"), + ("Plywood & Board", "Raw Materials - Furnitex"), + ("Laminates & Veneer", "Raw Materials - Furnitex"), + ("Hardware & Fittings", "Raw Materials - Furnitex"), + ("Civil & Surface Materials", "Raw Materials - Furnitex"), + ("Execution Services", "All Item Groups"), + ("Loose Furniture", "All Item Groups"), + ] + for name, parent in groups: + if not exists("Item Group", name): + d = frappe.get_doc({ + "doctype": "Item Group", + "item_group_name": name, + "parent_item_group": parent, + "is_group": 0, + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Item Group: {name}") + else: + skip(f"Item Group: {name}") + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 3. SUPPLIER GROUPS +# ───────────────────────────────────────────────────────────── + +def create_supplier_groups(): + print("\n[3/9] Creating Supplier Groups...") + groups = [ + ("Local Market Vendor (Unregistered)", "All Supplier Groups"), + ("GST Registered Vendor", "All Supplier Groups"), + ("Labour Contractor", "All Supplier Groups"), + ] + for name, parent in groups: + if not exists("Supplier Group", name): + d = frappe.get_doc({ + "doctype": "Supplier Group", + "supplier_group_name": name, + "parent_supplier_group": parent, + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Supplier Group: {name}") + else: + skip(f"Supplier Group: {name}") + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 4. WAREHOUSES +# ───────────────────────────────────────────────────────────── + +def create_warehouses(): + print("\n[4/9] Creating Warehouses...") + + # Find the company's root warehouse group + root_wh = frappe.db.get_value( + "Warehouse", + {"company": COMPANY, "is_group": 1, "parent_warehouse": ["in", ["", None]]}, + "name" + ) + if not root_wh: + root_wh = f"All Warehouses - {COMPANY}" + + warehouses = [ + ("Main Store", root_wh, "Transit", 0), + ("Rejected Stock",root_wh, "Transit", 0), + ] + for w_short, parent, w_type, is_group in warehouses: + w_full = f"{w_short} - {COMPANY}" + if not exists("Warehouse", w_full): + d = frappe.get_doc({ + "doctype": "Warehouse", + "warehouse_name": w_short, + "parent_warehouse": parent, + "company": COMPANY, + "is_group": is_group, + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Warehouse: {w_full}") + else: + skip(f"Warehouse: {w_full}") + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 5. TAX TEMPLATES +# ───────────────────────────────────────────────────────────── + +def _find_account(account_name_fragment, root_type=None, account_type=None): + """Find an account by partial name match under the company.""" + filters = {"company": COMPANY, "is_group": 0} + if root_type: + filters["root_type"] = root_type + if account_type: + filters["account_type"] = account_type + + # Try exact name match first + result = frappe.db.get_value("Account", + dict(filters, account_name=account_name_fragment), "name") + if result: + return result + + # Try LIKE match + like_pattern = f"%{account_name_fragment}%" + result = frappe.db.sql( + """SELECT name FROM `tabAccount` + WHERE company=%s AND is_group=0 + AND account_name LIKE %s + LIMIT 1""", + (COMPANY, like_pattern), as_dict=0 + ) + return result[0][0] if result else None + + +def create_tax_templates(): + print("\n[5/9] Creating Tax Templates...") + + # ── No GST (URD Purchase) ── + urd = "No GST - URD Purchase" + if not exists("Purchase Taxes and Charges Template", urd): + d = frappe.get_doc({ + "doctype": "Purchase Taxes and Charges Template", + "title": urd, + "company": COMPANY, + "is_default": 0, + "taxes": [], # zero rows = zero tax, clean COGS booking + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Purchase Tax Template: {urd}") + else: + skip(f"Purchase Tax Template: {urd}") + + # ── GST 18% Purchase ── + gst18_p = "GST 18% - Purchase" + if not exists("Purchase Taxes and Charges Template", gst18_p): + cgst = _find_account("CGST") + sgst = _find_account("SGST") + taxes = [] + if cgst: + taxes.append({"charge_type": "On Net Total", "account_head": cgst, "rate": 9, "description": "CGST @ 9%"}) + if sgst: + taxes.append({"charge_type": "On Net Total", "account_head": sgst, "rate": 9, "description": "SGST @ 9%"}) + d = frappe.get_doc({ + "doctype": "Purchase Taxes and Charges Template", + "title": gst18_p, + "company": COMPANY, + "is_default": 0, + "taxes": taxes, + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Purchase Tax Template: {gst18_p}" + (" (no accounts found, empty)" if not taxes else "")) + else: + skip(f"Purchase Tax Template: {gst18_p}") + + # ── GST 18% Sales ── + gst18_s = "GST 18% - Sales" + if not exists("Sales Taxes and Charges Template", gst18_s): + cgst = _find_account("CGST") + sgst = _find_account("SGST") + taxes = [] + if cgst: + taxes.append({"charge_type": "On Net Total", "account_head": cgst, "rate": 9, "description": "CGST @ 9%"}) + if sgst: + taxes.append({"charge_type": "On Net Total", "account_head": sgst, "rate": 9, "description": "SGST @ 9%"}) + d = frappe.get_doc({ + "doctype": "Sales Taxes and Charges Template", + "title": gst18_s, + "company": COMPANY, + "is_default": 0, + "taxes": taxes, + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Sales Tax Template: {gst18_s}" + (" (no accounts found, empty)" if not taxes else "")) + else: + skip(f"Sales Tax Template: {gst18_s}") + + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 6. SERVICE ITEMS (Non-stock billing items) +# ───────────────────────────────────────────────────────────── + +def create_service_items(): + print("\n[6/9] Creating Service Items...") + + income_acct = _find_account("Sales", root_type="Income") or \ + _find_account("Service", root_type="Income") + + items = [ + # (code, name, uom, description) + ("SVC-FC-EXEC", "False Ceiling Execution", "SqFt", "Labour + material for false ceiling. Bill per SqFt OR as lumpsum."), + ("SVC-WAR-LAM", "Laminate Wardrobe Fabrication", "SqFt", "Laminate wardrobe fabrication per SqFt."), + ("SVC-KIT-EXEC", "Modular Kitchen Execution", "SqFt", "Modular kitchen fabrication and installation."), + ("SVC-FLOOR", "Flooring Execution", "SqFt", "Vinyl/Wood/Tile flooring supply and installation."), + ("SVC-LUMP", "Lumpsum Contract Work", "Nos", "Fixed-price lumpsum milestone. Change qty to 1 always."), + ("SVC-DESIGN", "Interior Design Consultation", "Nos", "Design + drawing fee — lumpsum."), + ("SVC-CONVEY", "Site Conveyance & Transport", "Nos", "Transport charges to/from site."), + ("SVC-LABOUR", "Direct Site Labour", "Nos", "Daily-wage labour charges."), + ("SVC-ELECTRIC", "Electrical Work Execution", "Nos", "Electrical points, wiring, fitting."), + ("SVC-PAINT", "Painting & Polish Execution", "SqFt", "Wall painting / wood polish per SqFt."), + ] + + for code, name, uom, desc in items: + if not exists("Item", code): + d_dict = { + "doctype": "Item", + "item_code": code, + "item_name": name, + "item_group": "Execution Services", + "description": desc, + "stock_uom": uom, + "sales_uom": uom, + "is_stock_item": 0, + "is_purchase_item": 0, + "is_sales_item": 1, + "standard_rate": 0, + } + if income_acct: + d_dict["item_defaults"] = [{ + "company": COMPANY, + "income_account": income_acct, + }] + d = frappe.get_doc(d_dict) + d.flags.ignore_permissions = True + d.insert() + ok(f"Service Item: {code} [{uom}]") + else: + skip(f"Service Item: {code}") + + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 7. RAW MATERIAL ITEMS (Stock items) +# ───────────────────────────────────────────────────────────── + +def create_raw_material_items(): + print("\n[7/9] Creating Raw Material Items...") + + cogs_acct = (_find_account("Cost of Goods Sold", root_type="Expense") or + _find_account("Stock Expenses", root_type="Expense") or + _find_account("Expenses Included", root_type="Expense")) + + default_wh = f"Main Store - {COMPANY}" + if not exists("Warehouse", default_wh): + default_wh = frappe.db.get_value( + "Warehouse", {"company": COMPANY, "is_group": 0}, "name" + ) + + items = [ + # (code, name, uom, group) + ("RM-PLY-19MM", "Plywood 19mm BWR 8x4", "Sheet", "Plywood & Board"), + ("RM-PLY-12MM", "Plywood 12mm BWR 8x4", "Sheet", "Plywood & Board"), + ("RM-PLY-6MM", "Plywood 6mm 8x4", "Sheet", "Plywood & Board"), + ("RM-MDF-18MM", "MDF Board 18mm 8x4", "Sheet", "Plywood & Board"), + ("RM-HDF-3MM", "HDF 3mm 8x4", "Sheet", "Plywood & Board"), + ("RM-LAM-1MM", "Laminate Sheet 1mm 8x4", "Sheet", "Laminates & Veneer"), + ("RM-VEN-NAT", "Veneer Sheet Natural Wood", "Sheet", "Laminates & Veneer"), + ("RM-LAM-ACRY", "Acrylic Laminate Sheet", "Sheet", "Laminates & Veneer"), + ("RM-HW-HINGE", "Concealed Hinge (pair)", "Nos", "Hardware & Fittings"), + ("RM-HW-CHAN18", "Drawer Channel 18 inch", "Nos", "Hardware & Fittings"), + ("RM-HW-CHAN24", "Drawer Channel 24 inch", "Nos", "Hardware & Fittings"), + ("RM-HW-HANDLE", "Cabinet Handle", "Nos", "Hardware & Fittings"), + ("RM-HW-LOCK", "Drawer Lock", "Nos", "Hardware & Fittings"), + ("RM-HW-SCREW", "Wood Screw Assorted", "Bundle", "Hardware & Fittings"), + ("RM-CIV-CEM", "OPC Cement 53 Grade", "Bag", "Civil & Surface Materials"), + ("RM-CIV-PUTTY", "Wall Putty White", "Bag", "Civil & Surface Materials"), + ("RM-CIV-PRIMER","Primer Interior", "Nos", "Civil & Surface Materials"), + ("RM-CIV-GYPS", "Gypsum Board 8x4 12.5mm", "Sheet", "Civil & Surface Materials"), + ("RM-CIV-GYPS-C","Gypsum Cornice / Grid", "Rft", "Civil & Surface Materials"), + ] + + for code, name, uom, group in items: + if not exists("Item", code): + d_dict = { + "doctype": "Item", + "item_code": code, + "item_name": name, + "item_group": group, + "stock_uom": uom, + "is_stock_item": 1, + "is_purchase_item": 1, + "is_sales_item": 0, + "valuation_method": "FIFO", + } + defaults = {"company": COMPANY} + if default_wh: + defaults["default_warehouse"] = default_wh + if cogs_acct: + defaults["expense_account"] = cogs_acct + d_dict["item_defaults"] = [defaults] + + d = frappe.get_doc(d_dict) + d.flags.ignore_permissions = True + d.insert() + ok(f"Raw Material: {code} [{uom}]") + else: + skip(f"Raw Material: {code}") + + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 8. SAMPLE SUPPLIERS +# ───────────────────────────────────────────────────────────── + +def create_suppliers(): + print("\n[8/9] Creating Sample Suppliers...") + + suppliers = [ + # (name, group, gst_category) + ("Local Hardware Market - Kolkata", "Local Market Vendor (Unregistered)", "Unregistered"), + ("Burrabazar Plywood Supplier", "Local Market Vendor (Unregistered)", "Unregistered"), + ("Fancy Laminates - Howrah", "Local Market Vendor (Unregistered)", "Unregistered"), + ("Modern Furniture Hardware - BBD Bag","Local Market Vendor (Unregistered)", "Unregistered"), + ("Registered Hardware Supplier Ltd", "GST Registered Vendor", "Registered Regular"), + ("Site Labour Contractor - Ramesh", "Labour Contractor", "Unregistered"), + ] + + for name, group, gst_cat in suppliers: + if not exists("Supplier", name): + d = frappe.get_doc({ + "doctype": "Supplier", + "supplier_name": name, + "supplier_group": group, + "country": "India", + "gst_category": gst_cat, + "default_currency": "INR", + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Supplier: {name} [{gst_cat}]") + else: + skip(f"Supplier: {name}") + + frappe.db.commit() + + # Set URD tax default on all Unregistered suppliers + urd_template = "No GST - URD Purchase" + if exists("Purchase Taxes and Charges Template", urd_template): + urd_suppliers = frappe.db.sql( + """SELECT name FROM `tabSupplier` + WHERE supplier_group = 'Local Market Vendor (Unregistered)'""", + as_dict=1 + ) + for s in urd_suppliers: + frappe.db.set_value( + "Supplier", s.name, + "default_purchase_taxes_and_charges_template", urd_template + ) + frappe.db.commit() + ok(f"Set '{urd_template}' as default tax on {len(urd_suppliers)} URD supplier(s)") + + +# ───────────────────────────────────────────────────────────── +# 9. CUSTOM FIELDS +# ───────────────────────────────────────────────────────────── + +def create_custom_fields(): + print("\n[9/9] Creating Custom Fields...") + + # (dt, fieldname, label, fieldtype, options, insert_after, in_list_view) + fields = [ + ("Purchase Invoice", "is_urd_purchase", "URD Purchase (No GST)", + "Check", None, "supplier", 1), + ("Purchase Invoice", "furnitex_project", "Project", + "Link", "Project", "is_urd_purchase", 1), + ("Purchase Order", "furnitex_project", "Project", + "Link", "Project", "supplier", 1), + ("Stock Entry", "furnitex_project", "Project", + "Link", "Project", "purpose", 1), + ("Delivery Note", "furnitex_project", "Project", + "Link", "Project", "customer", 0), + ("Journal Entry", "furnitex_project", "Project", + "Link", "Project", "voucher_type", 0), + ] + + for dt, fn, label, ft, opts, after, in_list in fields: + cf_name = f"{dt}-{fn}" + if not exists("Custom Field", cf_name): + d_dict = { + "doctype": "Custom Field", + "dt": dt, + "fieldname": fn, + "label": label, + "fieldtype": ft, + "insert_after": after, + "in_list_view": in_list, + "in_standard_filter": 1, + "search_index": 1, + } + if opts: + d_dict["options"] = opts + d = frappe.get_doc(d_dict) + d.flags.ignore_permissions = True + d.insert() + ok(f"Custom Field: {dt}.{fn}") + else: + skip(f"Custom Field: {dt}.{fn}") + + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 10. SERVER SCRIPTS (Automation) +# ───────────────────────────────────────────────────────────── + +def create_server_scripts(): + print("\n[+] Creating Server Scripts...") + + # Script 1: Auto-create OnSite WIP Warehouse on Project insert + wip_script_name = "Furnitex - Auto Create OnSite WIP Warehouse" + wip_script_code = """ +# Auto-fires after a new Project is saved +# Creates "{Project Name} - OnSite WIP" warehouse automatically + +project_name = doc.project_name or doc.name +company = doc.company or "Furnitex" +warehouse_name = project_name + " - OnSite WIP" + +# Find root warehouse for this company +root_wh = frappe.db.get_value( + "Warehouse", + {"company": company, "is_group": 1, + "parent_warehouse": ["in", ["", None]]}, + "name" +) or ("All Warehouses - " + company) + +if not frappe.db.exists("Warehouse", warehouse_name): + wh = frappe.get_doc({ + "doctype": "Warehouse", + "warehouse_name": warehouse_name, + "parent_warehouse": root_wh, + "company": company, + "is_group": 0, + }) + wh.flags.ignore_permissions = True + wh.insert() + frappe.db.commit() + frappe.msgprint( + "OnSite WIP Warehouse created: " + warehouse_name + "", + alert=True, indicator="green" + ) +""" + + if not exists("Server Script", wip_script_name): + d = frappe.get_doc({ + "doctype": "Server Script", + "name": wip_script_name, + "script_type": "DocType Event", + "reference_doctype": "Project", + "doctype_event": "After Insert", + "enabled": 1, + "script": wip_script_code, + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Server Script: {wip_script_name}") + else: + skip(f"Server Script: {wip_script_name}") + + # Script 2: Auto-clear taxes on URD Purchase Invoice (Before Save) + urd_script_name = "Furnitex - Clear GST on URD Purchase" + urd_script_code = """ +# Fires Before Save on Purchase Invoice +# If flagged as URD Purchase, wipes all tax rows and expense accounts = COGS + +if doc.is_urd_purchase: + doc.taxes = [] + doc.taxes_and_charges = "" + + cogs_acct = frappe.db.get_value( + "Account", + {"account_name": "Cost of Goods Sold", + "company": doc.company, "is_group": 0}, + "name" + ) + if cogs_acct: + for item in doc.items: + item.expense_account = cogs_acct +""" + + if not exists("Server Script", urd_script_name): + d = frappe.get_doc({ + "doctype": "Server Script", + "name": urd_script_name, + "script_type": "DocType Event", + "reference_doctype": "Purchase Invoice", + "doctype_event": "Before Save", + "enabled": 1, + "script": urd_script_code, + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Server Script: {urd_script_name}") + else: + skip(f"Server Script: {urd_script_name}") + + frappe.db.commit() + + +# ───────────────────────────────────────────────────────────── +# 11. PROJECT PROFITABILITY CUSTOM REPORT (Page Script) +# ───────────────────────────────────────────────────────────── + +PROFIT_REPORT_SCRIPT = ''' +import frappe +from frappe import _ + +def execute(filters=None): + columns = get_columns() + data = get_data(filters) + return columns, data + +def get_columns(): + return [ + {"label": _("Project"), "fieldname": "project", "fieldtype": "Link", "options": "Project", "width": 160}, + {"label": _("Customer"), "fieldname": "customer", "fieldtype": "Data", "width": 160}, + {"label": _("Revenue Billed (₹)"), "fieldname": "revenue", "fieldtype": "Currency", "width": 130}, + {"label": _("Raw Mat. Cost (₹)"), "fieldname": "raw_mat_cost", "fieldtype": "Currency", "width": 130}, + {"label": _("Mat. Consumed (₹)"), "fieldname": "mat_consumed", "fieldtype": "Currency", "width": 130}, + {"label": _("Labour/Conv. (₹)"), "fieldname": "labour_cost", "fieldtype": "Currency", "width": 130}, + {"label": _("Total Cost (₹)"), "fieldname": "total_cost", "fieldtype": "Currency", "width": 130}, + {"label": _("Net Margin (₹)"), "fieldname": "net_margin", "fieldtype": "Currency", "width": 130}, + {"label": _("Margin %"), "fieldname": "margin_pct", "fieldtype": "Percent", "width": 90}, + ] + +def get_data(filters): + company = filters.get("company") if filters else None + project_filter = filters.get("project") if filters else None + + proj_sql = "" + args = [] + if company: + proj_sql += " AND p.company = %s" + args.append(company) + if project_filter: + proj_sql += " AND p.name = %s" + args.append(project_filter) + + projects = frappe.db.sql( + f"SELECT name, project_name, customer FROM `tabProject` WHERE 1=1 {proj_sql}", + args, as_dict=1 + ) + + rows = [] + for p in projects: + pname = p.name + + revenue = (frappe.db.sql( + "SELECT COALESCE(SUM(base_grand_total),0) v FROM `tabSales Invoice` WHERE project=%s AND docstatus=1", + pname, as_dict=1)[0].v or 0) + + raw_mat = (frappe.db.sql( + "SELECT COALESCE(SUM(base_net_total),0) v FROM `tabPurchase Invoice` WHERE furnitex_project=%s AND docstatus=1", + pname, as_dict=1)[0].v or 0) + + consumed = (frappe.db.sql( + """SELECT COALESCE(SUM(sed.amount),0) v + FROM `tabStock Entry Detail` sed + JOIN `tabStock Entry` se ON se.name=sed.parent + WHERE se.furnitex_project=%s AND se.stock_entry_type='Material Issue' AND se.docstatus=1""", + pname, as_dict=1)[0].v or 0) + + labour = (frappe.db.sql( + """SELECT COALESCE(SUM(jvd.debit),0) v + FROM `tabJournal Entry Account` jvd + JOIN `tabJournal Entry` jv ON jv.name=jvd.parent + WHERE jv.project=%s AND jv.docstatus=1 + AND jvd.account LIKE '%Labour%'""", + pname, as_dict=1)[0].v or 0) + + total_cost = raw_mat + consumed + labour + net_margin = revenue - total_cost + margin_pct = round((net_margin / revenue * 100), 2) if revenue else 0 + + rows.append({ + "project": pname, + "customer": p.customer, + "revenue": revenue, + "raw_mat_cost":raw_mat, + "mat_consumed":consumed, + "labour_cost": labour, + "total_cost": total_cost, + "net_margin": net_margin, + "margin_pct": margin_pct, + }) + + rows.sort(key=lambda r: r["net_margin"]) + return rows +''' + +def create_custom_report(): + print("\n[+] Creating Custom Report: Furnitex Project Profitability...") + report_name = "Furnitex Project Profitability" + if not exists("Report", report_name): + d = frappe.get_doc({ + "doctype": "Report", + "report_name": report_name, + "ref_doctype": "Project", + "report_type": "Script Report", + "is_standard": "No", + "module": "ERPNext", + "script": PROFIT_REPORT_SCRIPT, + }) + d.flags.ignore_permissions = True + d.insert() + ok(f"Custom Report: {report_name}") + frappe.db.commit() + else: + skip(f"Custom Report: {report_name}") + + +# ───────────────────────────────────────────────────────────── +# MASTER RUNNER +# ───────────────────────────────────────────────────────────── + +def run_all(): + frappe.set_user("Administrator") + print("\n" + "=" * 58) + print(" FURNITEX ERPNEXT v16 — AUTOMATED SETUP") + print(" Company: Furnitex | India | INR") + print("=" * 58) + + create_uoms() + create_item_groups() + create_supplier_groups() + create_warehouses() + create_tax_templates() + create_service_items() + create_raw_material_items() + create_suppliers() + create_custom_fields() + create_server_scripts() + create_custom_report() + + frappe.clear_cache() + + print("\n" + "=" * 58) + print(" SETUP COMPLETE — refresh your browser") + print("=" * 58 + "\n") + + +if __name__ == "__main__": + import sys + frappe.init(site="frontend") + frappe.connect() + run_all() + frappe.destroy() From ff152a30200d1770834e68e516dae318301ddc53 Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Fri, 12 Jun 2026 16:08:36 +0530 Subject: [PATCH 02/10] fix: use company abbr for warehouse names ERPNext auto-appends the company abbreviation (e.g. "F") to warehouse names, not the full company name. Fixed create_warehouses(), the default_wh reference in raw material items, and the OnSite WIP server script to all resolve the abbr at runtime via frappe.db.get_value. Co-Authored-By: Claude Sonnet 4.6 --- setup_furnitex.py | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/setup_furnitex.py b/setup_furnitex.py index ed828305..fe876dda 100644 --- a/setup_furnitex.py +++ b/setup_furnitex.py @@ -12,6 +12,13 @@ import frappe.defaults COMPANY = "Furnitex" SITE = "frontend" +ABBR = None # resolved at runtime via get_abbr() + +def get_abbr(): + global ABBR + if not ABBR: + ABBR = frappe.db.get_value("Company", COMPANY, "abbr") or "F" + return ABBR # ───────────────────────────────────────────────────────────── @@ -124,22 +131,24 @@ def create_supplier_groups(): def create_warehouses(): print("\n[4/9] Creating Warehouses...") + abbr = get_abbr() # Find the company's root warehouse group root_wh = frappe.db.get_value( "Warehouse", - {"company": COMPANY, "is_group": 1, "parent_warehouse": ["in", ["", None]]}, + {"company": COMPANY, "is_group": 1}, "name" ) if not root_wh: - root_wh = f"All Warehouses - {COMPANY}" + root_wh = f"All Warehouses - {abbr}" warehouses = [ - ("Main Store", root_wh, "Transit", 0), - ("Rejected Stock",root_wh, "Transit", 0), + ("Main Store", root_wh, 0), + ("Rejected Stock", root_wh, 0), ] - for w_short, parent, w_type, is_group in warehouses: - w_full = f"{w_short} - {COMPANY}" + for w_short, parent, is_group in warehouses: + # ERPNext appends company abbr: "Main Store - F" + w_full = f"{w_short} - {abbr}" if not exists("Warehouse", w_full): d = frappe.get_doc({ "doctype": "Warehouse", @@ -319,7 +328,7 @@ def create_raw_material_items(): _find_account("Stock Expenses", root_type="Expense") or _find_account("Expenses Included", root_type="Expense")) - default_wh = f"Main Store - {COMPANY}" + default_wh = f"Main Store - {get_abbr()}" if not exists("Warehouse", default_wh): default_wh = frappe.db.get_value( "Warehouse", {"company": COMPANY, "is_group": 0}, "name" @@ -492,22 +501,24 @@ def create_server_scripts(): # Auto-fires after a new Project is saved # Creates "{Project Name} - OnSite WIP" warehouse automatically -project_name = doc.project_name or doc.name -company = doc.company or "Furnitex" -warehouse_name = project_name + " - OnSite WIP" +project_name = doc.project_name or doc.name +company = doc.company or "Furnitex" +abbr = frappe.db.get_value("Company", company, "abbr") or "F" +# OnSite WIP name uses company abbr so ERPNext accepts it +wh_short_name = project_name + " - OnSite WIP" +warehouse_name = wh_short_name + " - " + abbr -# Find root warehouse for this company +# Find root warehouse group for this company root_wh = frappe.db.get_value( "Warehouse", - {"company": company, "is_group": 1, - "parent_warehouse": ["in", ["", None]]}, + {"company": company, "is_group": 1}, "name" -) or ("All Warehouses - " + company) +) or ("All Warehouses - " + abbr) if not frappe.db.exists("Warehouse", warehouse_name): wh = frappe.get_doc({ "doctype": "Warehouse", - "warehouse_name": warehouse_name, + "warehouse_name": wh_short_name, "parent_warehouse": root_wh, "company": company, "is_group": 0, From 356fec8c38d12b6922af077f25d2e3a0455cd268 Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Fri, 12 Jun 2026 16:11:37 +0530 Subject: [PATCH 03/10] fix: resolve all ERPNext v16 naming convention issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All ERPNext doctypes append company abbr to names (Warehouse, Tax Template, etc.) — switch existence checks to use filter-based lookup instead of hardcoded "{name} - Furnitex" strings - Add safe_insert() helper to absorb DuplicateEntryError gracefully so re-runs never crash mid-way - Service items: remove item_defaults entirely (non-stock items have no warehouse, avoids cross-company warehouse validation error) - Raw material items: only add item_defaults when a valid Furnitex warehouse is resolved - Supplier URD tax default: look up template by title+company filter rather than bare name string Co-Authored-By: Claude Sonnet 4.6 --- setup_furnitex.py | 169 ++++++++++++++++++++++++++++------------------ 1 file changed, 103 insertions(+), 66 deletions(-) diff --git a/setup_furnitex.py b/setup_furnitex.py index fe876dda..eebfd2bf 100644 --- a/setup_furnitex.py +++ b/setup_furnitex.py @@ -28,6 +28,23 @@ def get_abbr(): def exists(doctype, name): return frappe.db.exists(doctype, name) +def exists_filter(doctype, filters): + """Existence check by filters (for docs where name includes company abbr).""" + return frappe.db.get_value(doctype, filters, "name") + +def safe_insert(doc): + """Insert and return True, or skip silently on duplicate and return False.""" + try: + doc.flags.ignore_permissions = True + doc.insert() + return True + except frappe.DuplicateEntryError: + return False + except Exception as e: + if "Duplicate entry" in str(e): + return False + raise + def ok(msg): print(f" [OK] {msg}") @@ -59,10 +76,11 @@ def create_uoms(): "uom_name": uom_name, "must_be_whole_number": whole, }) - d.flags.ignore_permissions = True - d.flags.ignore_mandatory = True - d.insert() - ok(f"UOM: {uom_name}") + d.flags.ignore_mandatory = True + if safe_insert(d): + ok(f"UOM: {uom_name}") + else: + skip(f"UOM: {uom_name} (duplicate)") else: skip(f"UOM: {uom_name}") frappe.db.commit() @@ -91,9 +109,10 @@ def create_item_groups(): "parent_item_group": parent, "is_group": 0, }) - d.flags.ignore_permissions = True - d.insert() - ok(f"Item Group: {name}") + if safe_insert(d): + ok(f"Item Group: {name}") + else: + skip(f"Item Group: {name} (duplicate)") else: skip(f"Item Group: {name}") frappe.db.commit() @@ -117,9 +136,10 @@ def create_supplier_groups(): "supplier_group_name": name, "parent_supplier_group": parent, }) - d.flags.ignore_permissions = True - d.insert() - ok(f"Supplier Group: {name}") + if safe_insert(d): + ok(f"Supplier Group: {name}") + else: + skip(f"Supplier Group: {name} (duplicate)") else: skip(f"Supplier Group: {name}") frappe.db.commit() @@ -157,9 +177,10 @@ def create_warehouses(): "company": COMPANY, "is_group": is_group, }) - d.flags.ignore_permissions = True - d.insert() - ok(f"Warehouse: {w_full}") + if safe_insert(d): + ok(f"Warehouse: {w_full}") + else: + skip(f"Warehouse: {w_full} (duplicate)") else: skip(f"Warehouse: {w_full}") frappe.db.commit() @@ -197,68 +218,81 @@ def _find_account(account_name_fragment, root_type=None, account_type=None): def create_tax_templates(): print("\n[5/9] Creating Tax Templates...") + # ERPNext stores tax templates as "{title} - {abbr}", so we check by title+company # ── No GST (URD Purchase) ── - urd = "No GST - URD Purchase" - if not exists("Purchase Taxes and Charges Template", urd): + urd_title = "No GST - URD Purchase" + if not exists_filter("Purchase Taxes and Charges Template", + {"title": urd_title, "company": COMPANY}): d = frappe.get_doc({ "doctype": "Purchase Taxes and Charges Template", - "title": urd, + "title": urd_title, "company": COMPANY, "is_default": 0, - "taxes": [], # zero rows = zero tax, clean COGS booking + "taxes": [], }) - d.flags.ignore_permissions = True - d.insert() - ok(f"Purchase Tax Template: {urd}") + if safe_insert(d): + ok(f"Purchase Tax Template: {urd_title}") + else: + skip(f"Purchase Tax Template: {urd_title} (duplicate)") else: - skip(f"Purchase Tax Template: {urd}") + skip(f"Purchase Tax Template: {urd_title}") # ── GST 18% Purchase ── - gst18_p = "GST 18% - Purchase" - if not exists("Purchase Taxes and Charges Template", gst18_p): + gst18_p_title = "GST 18% - Purchase" + if not exists_filter("Purchase Taxes and Charges Template", + {"title": gst18_p_title, "company": COMPANY}): cgst = _find_account("CGST") sgst = _find_account("SGST") taxes = [] if cgst: - taxes.append({"charge_type": "On Net Total", "account_head": cgst, "rate": 9, "description": "CGST @ 9%"}) + taxes.append({"charge_type": "On Net Total", "account_head": cgst, + "rate": 9, "description": "CGST @ 9%"}) if sgst: - taxes.append({"charge_type": "On Net Total", "account_head": sgst, "rate": 9, "description": "SGST @ 9%"}) + taxes.append({"charge_type": "On Net Total", "account_head": sgst, + "rate": 9, "description": "SGST @ 9%"}) d = frappe.get_doc({ "doctype": "Purchase Taxes and Charges Template", - "title": gst18_p, + "title": gst18_p_title, "company": COMPANY, "is_default": 0, "taxes": taxes, }) - d.flags.ignore_permissions = True - d.insert() - ok(f"Purchase Tax Template: {gst18_p}" + (" (no accounts found, empty)" if not taxes else "")) + if safe_insert(d): + ok(f"Purchase Tax Template: {gst18_p_title}" + + (" (no GST accounts in CoA, left empty)" if not taxes else "")) + else: + skip(f"Purchase Tax Template: {gst18_p_title} (duplicate)") else: - skip(f"Purchase Tax Template: {gst18_p}") + skip(f"Purchase Tax Template: {gst18_p_title}") # ── GST 18% Sales ── - gst18_s = "GST 18% - Sales" - if not exists("Sales Taxes and Charges Template", gst18_s): + gst18_s_title = "GST 18% - Sales" + if not exists_filter("Sales Taxes and Charges Template", + {"title": gst18_s_title, "company": COMPANY}): cgst = _find_account("CGST") sgst = _find_account("SGST") taxes = [] if cgst: - taxes.append({"charge_type": "On Net Total", "account_head": cgst, "rate": 9, "description": "CGST @ 9%"}) + taxes.append({"charge_type": "On Net Total", "account_head": cgst, + "rate": 9, "description": "CGST @ 9%"}) if sgst: - taxes.append({"charge_type": "On Net Total", "account_head": sgst, "rate": 9, "description": "SGST @ 9%"}) + taxes.append({"charge_type": "On Net Total", "account_head": sgst, + "rate": 9, "description": "SGST @ 9%"}) d = frappe.get_doc({ "doctype": "Sales Taxes and Charges Template", - "title": gst18_s, + "title": gst18_s_title, "company": COMPANY, "is_default": 0, "taxes": taxes, }) - d.flags.ignore_permissions = True - d.insert() - ok(f"Sales Tax Template: {gst18_s}" + (" (no accounts found, empty)" if not taxes else "")) + if safe_insert(d): + ok(f"Sales Tax Template: {gst18_s_title}" + + (" (no GST accounts in CoA, left empty)" if not taxes else "")) + else: + skip(f"Sales Tax Template: {gst18_s_title} (duplicate)") else: - skip(f"Sales Tax Template: {gst18_s}") + skip(f"Sales Tax Template: {gst18_s_title}") frappe.db.commit() @@ -289,7 +323,8 @@ def create_service_items(): for code, name, uom, desc in items: if not exists("Item", code): - d_dict = { + # Non-stock service items: no warehouse needed in item_defaults + d = frappe.get_doc({ "doctype": "Item", "item_code": code, "item_name": name, @@ -301,16 +336,12 @@ def create_service_items(): "is_purchase_item": 0, "is_sales_item": 1, "standard_rate": 0, - } - if income_acct: - d_dict["item_defaults"] = [{ - "company": COMPANY, - "income_account": income_acct, - }] - d = frappe.get_doc(d_dict) - d.flags.ignore_permissions = True - d.insert() - ok(f"Service Item: {code} [{uom}]") + # No item_defaults row — avoids warehouse company-mismatch validation + }) + if safe_insert(d): + ok(f"Service Item: {code} [{uom}]") + else: + skip(f"Service Item: {code} (duplicate)") else: skip(f"Service Item: {code}") @@ -370,17 +401,20 @@ def create_raw_material_items(): "is_sales_item": 0, "valuation_method": "FIFO", } - defaults = {"company": COMPANY} - if default_wh: - defaults["default_warehouse"] = default_wh - if cogs_acct: - defaults["expense_account"] = cogs_acct - d_dict["item_defaults"] = [defaults] + # Only add item_defaults if we have a valid Furnitex warehouse + if default_wh or cogs_acct: + defaults = {"company": COMPANY} + if default_wh: + defaults["default_warehouse"] = default_wh + if cogs_acct: + defaults["expense_account"] = cogs_acct + d_dict["item_defaults"] = [defaults] d = frappe.get_doc(d_dict) - d.flags.ignore_permissions = True - d.insert() - ok(f"Raw Material: {code} [{uom}]") + if safe_insert(d): + ok(f"Raw Material: {code} [{uom}]") + else: + skip(f"Raw Material: {code} (duplicate)") else: skip(f"Raw Material: {code}") @@ -414,17 +448,20 @@ def create_suppliers(): "gst_category": gst_cat, "default_currency": "INR", }) - d.flags.ignore_permissions = True - d.insert() - ok(f"Supplier: {name} [{gst_cat}]") + if safe_insert(d): + ok(f"Supplier: {name} [{gst_cat}]") + else: + skip(f"Supplier: {name} (duplicate)") else: skip(f"Supplier: {name}") frappe.db.commit() - # Set URD tax default on all Unregistered suppliers + # Set URD tax default — look up by title+company (name includes abbr) urd_template = "No GST - URD Purchase" - if exists("Purchase Taxes and Charges Template", urd_template): + urd_full = exists_filter("Purchase Taxes and Charges Template", + {"title": urd_template, "company": COMPANY}) + if urd_full: urd_suppliers = frappe.db.sql( """SELECT name FROM `tabSupplier` WHERE supplier_group = 'Local Market Vendor (Unregistered)'""", @@ -433,10 +470,10 @@ def create_suppliers(): for s in urd_suppliers: frappe.db.set_value( "Supplier", s.name, - "default_purchase_taxes_and_charges_template", urd_template + "default_purchase_taxes_and_charges_template", urd_full ) frappe.db.commit() - ok(f"Set '{urd_template}' as default tax on {len(urd_suppliers)} URD supplier(s)") + ok(f"Set '{urd_full}' as default tax on {len(urd_suppliers)} URD supplier(s)") # ───────────────────────────────────────────────────────────── From 1f5bb6d942632fd88104793d20cf555f0c0e19b6 Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Fri, 12 Jun 2026 16:12:27 +0530 Subject: [PATCH 04/10] fix: remove v16-incompatible supplier tax field set_value ERPNext v16 dropped the per-supplier default_purchase_taxes_and_charges _template column. URD tax bypass is now entirely handled by the server script (Before Save on Purchase Invoice) via the is_urd_purchase flag. Co-Authored-By: Claude Sonnet 4.6 --- setup_furnitex.py | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/setup_furnitex.py b/setup_furnitex.py index eebfd2bf..b535d855 100644 --- a/setup_furnitex.py +++ b/setup_furnitex.py @@ -457,23 +457,13 @@ def create_suppliers(): frappe.db.commit() - # Set URD tax default — look up by title+company (name includes abbr) - urd_template = "No GST - URD Purchase" - urd_full = exists_filter("Purchase Taxes and Charges Template", - {"title": urd_template, "company": COMPANY}) - if urd_full: - urd_suppliers = frappe.db.sql( - """SELECT name FROM `tabSupplier` - WHERE supplier_group = 'Local Market Vendor (Unregistered)'""", - as_dict=1 - ) - for s in urd_suppliers: - frappe.db.set_value( - "Supplier", s.name, - "default_purchase_taxes_and_charges_template", urd_full - ) - frappe.db.commit() - ok(f"Set '{urd_full}' as default tax on {len(urd_suppliers)} URD supplier(s)") + # ERPNext v16 removed the per-supplier default tax template field. + # URD tax bypass is handled instead by the server script: + # "Furnitex - Clear GST on URD Purchase" (Before Save on Purchase Invoice) + # Tick the "URD Purchase (No GST)" checkbox on any invoice to auto-clear taxes. + urd_suppliers_count = frappe.db.count("Supplier", + {"supplier_group": "Local Market Vendor (Unregistered)"}) + ok(f"URD tax via server script — {urd_suppliers_count} URD suppliers registered") # ───────────────────────────────────────────────────────────── From bdeb078f29140070ea093fbd98ac0c30a4bfa771 Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Fri, 12 Jun 2026 16:14:16 +0530 Subject: [PATCH 05/10] fix: skip custom project fields that already exist natively in v16 ERPNext v16 ships project fields natively on Purchase Invoice, Purchase Order, Stock Entry, and Delivery Note. Adding duplicate custom fields caused UniqueFieldnameError. Now only two custom fields are added: - Purchase Invoice.is_urd_purchase (new URD toggle) - Journal Entry.project (only doctype missing it) Also update profitability report queries to use native 'project' field instead of 'furnitex_project' on PI, SE, and JE. Co-Authored-By: Claude Sonnet 4.6 --- setup_furnitex.py | 50 +++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/setup_furnitex.py b/setup_furnitex.py index b535d855..86547a88 100644 --- a/setup_furnitex.py +++ b/setup_furnitex.py @@ -473,46 +473,46 @@ def create_suppliers(): def create_custom_fields(): print("\n[9/9] Creating Custom Fields...") + # NOTE: Purchase Invoice, Purchase Order, Stock Entry, and Delivery Note + # already have a native 'project' field in ERPNext v16 — skip those. + # We only add: + # 1. is_urd_purchase (Check) on Purchase Invoice + # 2. project (Link) on Journal Entry (only one missing it) + # (dt, fieldname, label, fieldtype, options, insert_after, in_list_view) fields = [ - ("Purchase Invoice", "is_urd_purchase", "URD Purchase (No GST)", - "Check", None, "supplier", 1), - ("Purchase Invoice", "furnitex_project", "Project", - "Link", "Project", "is_urd_purchase", 1), - ("Purchase Order", "furnitex_project", "Project", - "Link", "Project", "supplier", 1), - ("Stock Entry", "furnitex_project", "Project", - "Link", "Project", "purpose", 1), - ("Delivery Note", "furnitex_project", "Project", - "Link", "Project", "customer", 0), - ("Journal Entry", "furnitex_project", "Project", - "Link", "Project", "voucher_type", 0), + ("Purchase Invoice", "is_urd_purchase", "URD Purchase (No GST)", + "Check", None, "supplier", 1), + ("Journal Entry", "project", "Project", + "Link", "Project", "voucher_type", 0), ] for dt, fn, label, ft, opts, after, in_list in fields: cf_name = f"{dt}-{fn}" if not exists("Custom Field", cf_name): d_dict = { - "doctype": "Custom Field", - "dt": dt, - "fieldname": fn, - "label": label, - "fieldtype": ft, - "insert_after": after, - "in_list_view": in_list, + "doctype": "Custom Field", + "dt": dt, + "fieldname": fn, + "label": label, + "fieldtype": ft, + "insert_after": after, + "in_list_view": in_list, "in_standard_filter": 1, - "search_index": 1, + "search_index": 1, } if opts: d_dict["options"] = opts d = frappe.get_doc(d_dict) - d.flags.ignore_permissions = True - d.insert() - ok(f"Custom Field: {dt}.{fn}") + if safe_insert(d): + ok(f"Custom Field: {dt}.{fn}") + else: + skip(f"Custom Field: {dt}.{fn} (duplicate)") else: skip(f"Custom Field: {dt}.{fn}") frappe.db.commit() + ok("Native 'project' field already present on PI, PO, Stock Entry, DN — no custom fields needed there") # ───────────────────────────────────────────────────────────── @@ -668,14 +668,14 @@ def get_data(filters): pname, as_dict=1)[0].v or 0) raw_mat = (frappe.db.sql( - "SELECT COALESCE(SUM(base_net_total),0) v FROM `tabPurchase Invoice` WHERE furnitex_project=%s AND docstatus=1", + "SELECT COALESCE(SUM(base_net_total),0) v FROM `tabPurchase Invoice` WHERE project=%s AND docstatus=1", pname, as_dict=1)[0].v or 0) consumed = (frappe.db.sql( """SELECT COALESCE(SUM(sed.amount),0) v FROM `tabStock Entry Detail` sed JOIN `tabStock Entry` se ON se.name=sed.parent - WHERE se.furnitex_project=%s AND se.stock_entry_type='Material Issue' AND se.docstatus=1""", + WHERE se.project=%s AND se.stock_entry_type='Material Issue' AND se.docstatus=1""", pname, as_dict=1)[0].v or 0) labour = (frappe.db.sql( From a186cd057b49aa86c7199296b990a7da87072b53 Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Fri, 12 Jun 2026 16:14:56 +0530 Subject: [PATCH 06/10] fix: use 'Projects' module for custom report (ERPNext v16) --- setup_furnitex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup_furnitex.py b/setup_furnitex.py index 86547a88..4deb941a 100644 --- a/setup_furnitex.py +++ b/setup_furnitex.py @@ -716,7 +716,7 @@ def create_custom_report(): "ref_doctype": "Project", "report_type": "Script Report", "is_standard": "No", - "module": "ERPNext", + "module": "Projects", "script": PROFIT_REPORT_SCRIPT, }) d.flags.ignore_permissions = True From 7c52d9b84b94f7760c564542b816fa9e6c73b877 Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Fri, 12 Jun 2026 16:45:49 +0530 Subject: [PATCH 07/10] feat(furnitex): add CRM/billing setup, company cleanup, and business info scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup_furnitex_crm_billing.py: creates customer groups, territories, lead sources (custom Select field), sales persons, payment modes, payment terms (4 Furnitex-specific templates), T&C templates, price list, CRM custom fields, sample customers, selling settings, sales stages, opportunity types, and letter head - delete_streetwok.py: removes all streetwok (Demo) company data (invoices, GL entries, warehouses, accounts, cost centres, customers) and resets default company to Furnitex - update_furnitex_info.py: updates ERPNext with real business details scraped from furnitex.co.in — company phone/email, billing address, branded letter head, and Quotation/Invoice/PO T&C templates with legal contact footer Co-Authored-By: Claude Sonnet 4.6 --- delete_streetwok.py | 237 +++++++++++ setup_furnitex_crm_billing.py | 774 ++++++++++++++++++++++++++++++++++ update_furnitex_info.py | 213 ++++++++++ 3 files changed, 1224 insertions(+) create mode 100644 delete_streetwok.py create mode 100644 setup_furnitex_crm_billing.py create mode 100644 update_furnitex_info.py diff --git a/delete_streetwok.py b/delete_streetwok.py new file mode 100644 index 00000000..1aaf43d5 --- /dev/null +++ b/delete_streetwok.py @@ -0,0 +1,237 @@ +""" +delete_streetwok.py +Removes all "streetwok (Demo)" company data from Furnitex ERPNext instance. +Run via: bench --site frontend execute frappe.delete_streetwok.run +""" + +import frappe + + +COMPANY = "streetwok (Demo)" + + +def _sql(q, commit=False): + frappe.db.sql(q) + if commit: + frappe.db.commit() + + +def run(): + frappe.set_user("Administrator") + + if not frappe.db.exists("Company", COMPANY): + print(f" Company '{COMPANY}' not found — nothing to delete.") + return + + print(f"\n{'='*54}") + print(f" DELETING: {COMPANY}") + print(f"{'='*54}\n") + + # ── STEP 1: Cancel + delete all submitted documents ────────── + submitted_doctypes = [ + "Sales Invoice", + "Purchase Invoice", + "Payment Entry", + "Journal Entry", + "Stock Entry", + "Delivery Note", + "Purchase Receipt", + "Sales Order", + "Purchase Order", + "Quotation", + "Material Request", + "Stock Reconciliation", + ] + + for dt in submitted_doctypes: + # Get submitted docs for this company + docs = frappe.db.sql( + f"SELECT name FROM `tab{dt}` WHERE company=%s AND docstatus=1", + COMPANY, as_dict=1 + ) + if docs: + print(f" Cancelling {len(docs)} submitted {dt}(s)...") + for d in docs: + try: + doc = frappe.get_doc(dt, d.name) + doc.flags.ignore_permissions = True + doc.flags.ignore_links = True + doc.cancel() + frappe.db.commit() + except Exception as e: + # Force cancel via direct DB update if normal cancel fails + frappe.db.sql( + f"UPDATE `tab{dt}` SET docstatus=2 WHERE name=%s", d.name + ) + frappe.db.commit() + + # ── STEP 2: Delete all draft + cancelled docs ───────────────── + all_doctypes = submitted_doctypes + [ + "Landed Cost Voucher", + "Asset", + "Salary Slip", + "Timesheet", + ] + + for dt in all_doctypes: + try: + count = frappe.db.count(dt, {"company": COMPANY}) + if count: + frappe.db.delete(dt, {"company": COMPANY}) + frappe.db.commit() + print(f" Deleted {count} {dt}(s)") + except Exception as e: + print(f" [WARN] Could not delete {dt}: {e}") + + # ── STEP 3: Delete GL Entries ───────────────────────────────── + gl_count = frappe.db.count("GL Entry", {"company": COMPANY}) + if gl_count: + frappe.db.delete("GL Entry", {"company": COMPANY}) + frappe.db.commit() + print(f" Deleted {gl_count} GL Entries") + + # ── STEP 4: Delete Stock Ledger Entries ─────────────────────── + sle_count = frappe.db.count("Stock Ledger Entry", {"company": COMPANY}) + if sle_count: + frappe.db.delete("Stock Ledger Entry", {"company": COMPANY}) + frappe.db.commit() + print(f" Deleted {sle_count} Stock Ledger Entries") + + # ── STEP 5: Delete child tables that reference the company ──── + child_cleanups = [ + ("Sales Invoice Item", "company"), + ("Purchase Invoice Item", "company"), + ("Payment Entry Reference", None), # handled via parent delete + ] + + # ── STEP 6: Delete Warehouses ───────────────────────────────── + # Disable stock bins first + wh_list = frappe.db.sql( + "SELECT name FROM `tabWarehouse` WHERE company=%s", COMPANY, as_dict=1 + ) + if wh_list: + for wh in wh_list: + frappe.db.delete("Bin", {"warehouse": wh.name}) + frappe.db.commit() + + for wh in wh_list: + try: + frappe.delete_doc("Warehouse", wh.name, + ignore_permissions=True, force=True, + ignore_on_trash=True) + except Exception as e: + frappe.db.sql( + "DELETE FROM `tabWarehouse` WHERE name=%s", wh.name + ) + frappe.db.commit() + print(f" Deleted {len(wh_list)} Warehouse(s)") + + # ── STEP 7: Delete Cost Centers ─────────────────────────────── + cc_list = frappe.db.sql( + "SELECT name FROM `tabCost Center` WHERE company=%s ORDER BY lft DESC", + COMPANY, as_dict=1 + ) + if cc_list: + for cc in cc_list: + try: + frappe.db.sql( + "DELETE FROM `tabCost Center` WHERE name=%s", cc.name + ) + except Exception: + pass + frappe.db.commit() + print(f" Deleted {len(cc_list)} Cost Center(s)") + + # ── STEP 8: Delete Accounts ─────────────────────────────────── + acct_count = frappe.db.count("Account", {"company": COMPANY}) + if acct_count: + # Delete leaf accounts first (is_group=0), then groups + frappe.db.sql( + "DELETE FROM `tabAccount` WHERE company=%s AND is_group=0", COMPANY + ) + frappe.db.sql( + "DELETE FROM `tabAccount` WHERE company=%s", COMPANY + ) + frappe.db.commit() + print(f" Deleted {acct_count} Account(s)") + + # ── STEP 9: Delete Fiscal Years linked only to this company ─── + fy_links = frappe.db.sql( + """SELECT parent FROM `tabFiscal Year Company` + WHERE company=%s""", COMPANY, as_dict=1 + ) + for fy in fy_links: + frappe.db.sql( + "DELETE FROM `tabFiscal Year Company` WHERE company=%s AND parent=%s", + (COMPANY, fy.parent) + ) + # If this fiscal year has no other company links, delete it too + remaining = frappe.db.count("Fiscal Year Company", {"parent": fy.parent}) + if remaining == 0: + try: + frappe.db.delete("Fiscal Year", {"name": fy.parent}) + except Exception: + pass + frappe.db.commit() + + # ── STEP 10: Nuke Customers/Suppliers that belong only to ───── + # streetwok (no transactions in Furnitex) + # Only delete if they have no Furnitex transactions + stale_customers = frappe.db.sql( + """SELECT c.name FROM `tabCustomer` c + WHERE NOT EXISTS ( + SELECT 1 FROM `tabSales Invoice` + WHERE customer=c.name AND company='Furnitex' AND docstatus < 2 + ) + AND NOT EXISTS ( + SELECT 1 FROM `tabSales Order` + WHERE customer=c.name AND company='Furnitex' AND docstatus < 2 + )""", as_dict=1 + ) + if stale_customers: + for c in stale_customers: + try: + frappe.db.delete("Customer", {"name": c.name}) + except Exception: + pass + frappe.db.commit() + print(f" Deleted {len(stale_customers)} orphan Customer(s)") + + # ── STEP 11: Delete the Company record itself ───────────────── + try: + frappe.delete_doc("Company", COMPANY, + ignore_permissions=True, force=True, + ignore_on_trash=True) + except Exception: + frappe.db.sql("DELETE FROM `tabCompany` WHERE name=%s", COMPANY) + frappe.db.commit() + print(f"\n Company '{COMPANY}' deleted.") + + # ── STEP 12: Update Default Company if it was streetwok ─────── + default_co = frappe.db.get_default("company") + if default_co and "streetwok" in default_co.lower(): + frappe.db.set_default("company", "Furnitex") + frappe.db.commit() + print(" Default company reset to: Furnitex") + + # ── STEP 13: Nuke any leftover streetwok items ──────────────── + stale_items = frappe.db.sql( + """SELECT name FROM `tabItem` + WHERE item_name LIKE '%streetwok%' + OR item_code LIKE '%streetwok%' + OR description LIKE '%streetwok%'""", as_dict=1 + ) + if stale_items: + for item in stale_items: + try: + frappe.db.delete("Item", {"name": item.name}) + except Exception: + pass + frappe.db.commit() + print(f" Deleted {len(stale_items)} streetwok-tagged Item(s)") + + frappe.clear_cache() + + print(f"\n{'='*54}") + print(" DONE — streetwok removed. Refresh your browser.") + print(f"{'='*54}\n") diff --git a/setup_furnitex_crm_billing.py b/setup_furnitex_crm_billing.py new file mode 100644 index 00000000..62720f40 --- /dev/null +++ b/setup_furnitex_crm_billing.py @@ -0,0 +1,774 @@ +""" +setup_furnitex_crm_billing.py +Full CRM + Billing setup for Furnitex (ERPNext v16) +Run: bench --site frontend execute frappe.setup_furnitex_crm_billing.run +""" + +import frappe + +COMPANY = "Furnitex" + + +def ok(msg): print(f" [OK] {msg}") +def skip(msg): print(f" [SKIP] {msg}") + +def safe_insert(doc): + try: + doc.flags.ignore_permissions = True + doc.flags.ignore_mandatory = True + doc.insert() + return True + except frappe.DuplicateEntryError: + return False + except Exception as e: + if "Duplicate entry" in str(e): + return False + raise + +def exists(dt, name): + return frappe.db.exists(dt, name) + +def exists_f(dt, filters): + return frappe.db.get_value(dt, filters, "name") + + +# ────────────────────────────────────────────────────────────── +# 1. CUSTOMER GROUPS (Interior-specific) +# ────────────────────────────────────────────────────────────── +def create_customer_groups(): + print("\n[1] Customer Groups...") + groups = [ + ("Residential - Individual", "Individual"), + ("Residential - Builder Flat","Individual"), + ("Commercial - Office", "Commercial"), + ("Commercial - Restaurant", "Commercial"), + ("Commercial - Hotel", "Commercial"), + ("Commercial - Retail", "Commercial"), + ("Builder / Developer", "Commercial"), + ] + for name, parent in groups: + if not exists("Customer Group", name): + d = frappe.get_doc({ + "doctype": "Customer Group", + "customer_group_name": name, + "parent_customer_group": parent, + "is_group": 0, + }) + if safe_insert(d): ok(f"Customer Group: {name}") + else: skip(f"{name} (dup)") + else: + skip(f"Customer Group: {name}") + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 2. TERRITORIES (Kolkata geography) +# ────────────────────────────────────────────────────────────── +def create_territories(): + print("\n[2] Territories...") + # (name, parent) + territories = [ + ("West Bengal", "India"), + ("Kolkata", "West Bengal"), + ("South Kolkata", "Kolkata"), + ("North Kolkata", "Kolkata"), + ("Salt Lake", "Kolkata"), + ("New Town", "Kolkata"), + ("Rajarhat", "Kolkata"), + ("Howrah", "West Bengal"), + ("Hooghly", "West Bengal"), + ] + for name, parent in territories: + if not exists("Territory", name): + d = frappe.get_doc({ + "doctype": "Territory", + "territory_name": name, + "parent_territory": parent, + "is_group": 0, + }) + if safe_insert(d): ok(f"Territory: {name}") + else: skip(f"{name} (dup)") + else: + skip(f"Territory: {name}") + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 3. LEAD SOURCES (added as custom Select field on Lead) +# ────────────────────────────────────────────────────────────── +LEAD_SOURCES = "\n".join([ + "Instagram", + "Facebook", + "Word of Mouth", + "Client Referral", + "Just Dial", + "Housing.com", + "99acres / MagicBricks", + "Site Board / Hoarding", + "Google Search", + "Direct Walk-In", + "Architect / Designer Referral", + "Builder Tie-up", + "Exhibition / Home Fair", + "YouTube", + "WhatsApp Broadcast", +]) + +def create_lead_sources(): + # Lead Source is a standalone CRM-app doctype not installed here. + # We add it as a custom Select field on Lead + Opportunity instead. + print("\n[3] Lead Sources (as custom Select field)...") + + for dt, after_field in [("Lead", "qualification_status"), ("Opportunity", "opportunity_type")]: + cf_name = f"{dt}-furnitex_lead_source" + if not exists("Custom Field", cf_name): + d = frappe.get_doc({ + "doctype": "Custom Field", + "dt": dt, + "fieldname": "furnitex_lead_source", + "label": "Lead Source", + "fieldtype": "Select", + "options": LEAD_SOURCES, + "insert_after": after_field, + "in_list_view": 1, + "in_standard_filter": 1, + }) + if safe_insert(d): ok(f"Lead Source Select field → {dt}") + else: skip(f"{dt}.furnitex_lead_source (dup)") + else: + skip(f"Lead Source field exists on {dt}") + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 4. SALES PERSONS +# ────────────────────────────────────────────────────────────── +def create_sales_persons(): + print("\n[4] Sales Persons...") + persons = [ + ("Furnitex - Site Team", "Sales Team"), + ("Furnitex - Design Team","Sales Team"), + ("Furnitex - BD Manager", "Sales Team"), + ] + for name, parent in persons: + if not exists("Sales Person", name): + d = frappe.get_doc({ + "doctype": "Sales Person", + "sales_person_name": name, + "parent_sales_person": parent, + "is_group": 0, + "enabled": 1, + }) + if safe_insert(d): ok(f"Sales Person: {name}") + else: skip(f"{name} (dup)") + else: + skip(f"Sales Person: {name}") + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 5. MODE OF PAYMENT (Furnitex extras) +# ────────────────────────────────────────────────────────────── +def create_payment_modes(): + print("\n[5] Modes of Payment...") + modes = [ + ("NEFT / RTGS", "Bank"), + ("IMPS", "Bank"), + ("Google Pay", "Bank"), + ("PhonePe", "Bank"), + ("Paytm", "Bank"), + ("Cash - Site", "Cash"), + ] + + # find default bank account + bank_acct = frappe.db.get_value("Account", + {"account_type": "Bank", "company": COMPANY, "is_group": 0}, "name") + cash_acct = frappe.db.get_value("Account", + {"account_type": "Cash", "company": COMPANY, "is_group": 0}, "name") + + for name, mtype in modes: + if not exists("Mode of Payment", name): + d_dict = { + "doctype": "Mode of Payment", + "mode_of_payment": name, + "type": mtype, + } + acct = bank_acct if mtype == "Bank" else cash_acct + if acct: + d_dict["accounts"] = [{ + "company": COMPANY, + "default_account": acct, + }] + d = frappe.get_doc(d_dict) + if safe_insert(d): ok(f"Mode of Payment: {name}") + else: skip(f"{name} (dup)") + else: + skip(f"Mode of Payment: {name}") + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 6. PAYMENT TERMS TEMPLATES +# ────────────────────────────────────────────────────────────── +def create_payment_terms(): + print("\n[6] Payment Terms Templates...") + + templates = [ + + # ── A: Standard Interior Project (milestone-based) ─────── + { + "name": "Furnitex - Standard Interior Project", + "terms": [ + {"payment_term_name": "30% Advance on Agreement", + "invoice_portion": 30, "credit_days": 0, + "description": "Booking advance on signing agreement"}, + {"payment_term_name": "30% on 50% Site Completion", + "invoice_portion": 30, "credit_days": 30, + "description": "Second milestone: 50% work done"}, + {"payment_term_name": "30% on Work Completion", + "invoice_portion": 30, "credit_days": 60, + "description": "Third milestone: work complete"}, + {"payment_term_name": "10% on Final Handover", + "invoice_portion": 10, "credit_days": 75, + "description": "Retention released at handover"}, + ], + }, + + # ── B: 50-50 (smaller projects) ────────────────────────── + { + "name": "Furnitex - 50-50 Advance", + "terms": [ + {"payment_term_name": "50% Advance", + "invoice_portion": 50, "credit_days": 0, + "description": "Advance before work begins"}, + {"payment_term_name": "50% on Delivery", + "invoice_portion": 50, "credit_days": 30, + "description": "Balance on delivery / installation"}, + ], + }, + + # ── C: 100% Advance (small orders / loose furniture) ───── + { + "name": "Furnitex - 100% Advance", + "terms": [ + {"payment_term_name": "100% Advance", + "invoice_portion": 100, "credit_days": 0, + "description": "Full payment before production"}, + ], + }, + + # ── D: Lumpsum 3-stage (commercial projects) ───────────── + { + "name": "Furnitex - Commercial 3-Stage", + "terms": [ + {"payment_term_name": "40% Advance - Commercial", + "invoice_portion": 40, "credit_days": 0, + "description": "Mobilisation advance"}, + {"payment_term_name": "40% Mid-Stage - Commercial", + "invoice_portion": 40, "credit_days": 45, + "description": "Mid-project milestone"}, + {"payment_term_name": "20% Retention - Commercial", + "invoice_portion": 20, "credit_days": 90, + "description": "Retention on final handover"}, + ], + }, + ] + + for t in templates: + tname = t["name"] + if not exists_f("Payment Terms Template", {"template_name": tname}): + # Ensure Payment Term records exist for each row + term_rows = [] + for term in t["terms"]: + pt_name = term["payment_term_name"] + if not exists("Payment Term", pt_name): + pt = frappe.get_doc({ + "doctype": "Payment Term", + "payment_term_name": pt_name, + "invoice_portion": term["invoice_portion"], + "credit_days_based_on": "Day(s) after invoice date", + "credit_days": term["credit_days"], + "description": term["description"], + }) + safe_insert(pt) + term_rows.append({ + "payment_term": pt_name, + "invoice_portion": term["invoice_portion"], + "credit_days_based_on": "Day(s) after invoice date", + "credit_days": term["credit_days"], + "description": term["description"], + }) + + d = frappe.get_doc({ + "doctype": "Payment Terms Template", + "template_name": tname, + "terms": term_rows, + }) + if safe_insert(d): ok(f"Payment Terms: {tname}") + else: skip(f"{tname} (dup)") + else: + skip(f"Payment Terms: {tname}") + + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 7. TERMS AND CONDITIONS TEMPLATES +# ────────────────────────────────────────────────────────────── +def create_terms_conditions(): + print("\n[7] Terms & Conditions Templates...") + + templates = [ + + # ── Quotation T&C ───────────────────────────────────────── + { + "title": "Furnitex - Quotation Terms", + "terms": """

Terms & Conditions — Furnitex Interior

+
    +
  1. Validity: This quotation is valid for 15 days from the date of issue.
  2. +
  3. Scope: Only items listed in this quotation are included. Any additional work will be quoted separately.
  4. +
  5. Payment Schedule: 30% advance on confirmation, 30% at 50% completion, 30% at completion, 10% on handover.
  6. +
  7. Timeline: Work commences within 7 working days of advance payment and site clearance. Timeline communicated separately.
  8. +
  9. Material: All materials as specified. Substitution only with client approval.
  10. +
  11. Civil Work: Civil, electrical, plumbing work by client unless specifically included above.
  12. +
  13. Site Access: Client to ensure unobstructed site access during working hours (9 AM – 6 PM).
  14. +
  15. Warranty: 1-year workmanship warranty. Hardware warranty as per manufacturer.
  16. +
  17. Disputes: Subject to Kolkata jurisdiction.
  18. +
+

Furnitex | Interior Design & Furniture | Kolkata

""", + }, + + # ── Sales Invoice T&C ───────────────────────────────────── + { + "title": "Furnitex - Invoice Terms", + "terms": """

Invoice Terms — Furnitex Interior

+
    +
  1. Payment Due: As per agreed payment schedule. Late payment attracts 2% per month interest.
  2. +
  3. Goods: Materials remain property of Furnitex until full payment is received.
  4. +
  5. Defects: Any defects must be reported within 7 days of delivery/installation.
  6. +
  7. Warranty: 1-year warranty on workmanship from date of handover. Excludes wear, misuse, and civil damage.
  8. +
  9. Disputes: Subject to Kolkata jurisdiction only.
  10. +
+

Bank: [Your Bank] | A/c: [Account No] | IFSC: [IFSC] | UPI: [UPI ID]

+

Thank you for choosing Furnitex!

""", + }, + + # ── Purchase Order T&C ──────────────────────────────────── + { + "title": "Furnitex - Purchase Order Terms", + "terms": """

Purchase Order Terms — Furnitex

+
    +
  1. Delivery as per schedule agreed. Delays will be notified promptly.
  2. +
  3. Materials must match specifications. Furnitex reserves the right to reject substandard material.
  4. +
  5. Invoice must reference this PO number.
  6. +
  7. Furnitex is not liable for GST on unregistered supplier purchases.
  8. +
""", + }, + ] + + for t in templates: + if not exists("Terms and Conditions", t["title"]): + d = frappe.get_doc({ + "doctype": "Terms and Conditions", + "title": t["title"], + "terms": t["terms"], + }) + if safe_insert(d): ok(f"T&C: {t['title']}") + else: skip(f"T&C: {t['title']} (dup)") + else: + skip(f"T&C: {t['title']}") + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 8. PRICE LIST — Furnitex Interior Rate Card +# ────────────────────────────────────────────────────────────── +def create_price_list(): + print("\n[8] Price Lists & Item Prices...") + + pl_name = "Furnitex Interior Rate Card" + if not exists("Price List", pl_name): + d = frappe.get_doc({ + "doctype": "Price List", + "price_list_name": pl_name, + "currency": "INR", + "selling": 1, + "buying": 0, + "enabled": 1, + }) + if safe_insert(d): ok(f"Price List: {pl_name}") + else: skip(f"{pl_name} (dup)") + else: + skip(f"Price List: {pl_name}") + + frappe.db.commit() + + # ── Standard Item Prices (on both Standard Selling + Interior Rate Card) ── + item_prices = [ + # (item_code, rate) — per SqFt or per Nos + ("SVC-FC-EXEC", 185), # False Ceiling / SqFt + ("SVC-WAR-LAM", 950), # Laminate Wardrobe / SqFt + ("SVC-KIT-EXEC", 1100), # Modular Kitchen / SqFt + ("SVC-FLOOR", 120), # Flooring / SqFt + ("SVC-LUMP", 0), # Lumpsum — rate set per project + ("SVC-DESIGN", 15000), # Design consultation / Nos + ("SVC-CONVEY", 2000), # Conveyance / trip + ("SVC-LABOUR", 700), # Labour / day + ("SVC-ELECTRIC", 0), # Electrical — per quote + ("SVC-PAINT", 35), # Painting / SqFt + ] + + price_lists = ["Standard Selling", pl_name] + for pl in price_lists: + if not exists("Price List", pl): + continue + for code, rate in item_prices: + if not exists("Item", code): + continue + if not exists_f("Item Price", {"item_code": code, "price_list": pl, "selling": 1}): + ip = frappe.get_doc({ + "doctype": "Item Price", + "item_code": code, + "price_list": pl, + "selling": 1, + "currency": "INR", + "price_list_rate": rate, + }) + if safe_insert(ip): ok(f"Item Price: {code} ₹{rate} [{pl}]") + else: skip(f"Item Price: {code} [{pl}] (dup)") + else: + skip(f"Item Price: {code} [{pl}]") + + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 9. CUSTOM FIELDS — CRM & BILLING +# ────────────────────────────────────────────────────────────── +def create_crm_billing_fields(): + print("\n[9] CRM & Billing Custom Fields...") + + fields = [ + + # ── CUSTOMER ───────────────────────────────────────────── + ("Customer", "whatsapp_number", "WhatsApp Number", + "Data", None, "mobile_no", 0), + ("Customer", "property_address", "Property / Site Address", + "Small Text", None, "whatsapp_number", 0), + ("Customer", "project_type", "Project Type", + "Select", "Residential\nCommercial\nHospitality\nRetail", "property_address", 0), + ("Customer", "property_size_sqft", "Approx. Area (SqFt)", + "Float", None, "project_type", 0), + ("Customer", "budget_range", "Budget Range", + "Select", + "Under ₹5 Lakhs\n₹5–10 Lakhs\n₹10–25 Lakhs\n₹25–50 Lakhs\nAbove ₹50 Lakhs", + "property_size_sqft", 0), + ("Customer", "referred_by", "Referred By", + "Data", None, "budget_range", 0), + + # ── LEAD ───────────────────────────────────────────────── + ("Lead", "whatsapp_number", "WhatsApp Number", + "Data", None, "mobile_no", 0), + ("Lead", "project_type_lead", "Project Type", + "Select", "Residential\nCommercial\nHospitality\nRetail", + "whatsapp_number", 0), + ("Lead", "property_address", "Property / Site Address", + "Small Text", None, "project_type_lead", 0), + ("Lead", "area_sqft", "Approx. Area (SqFt)", + "Float", None, "property_address", 0), + ("Lead", "budget_range", "Budget Range", + "Select", + "Under ₹5 Lakhs\n₹5–10 Lakhs\n₹10–25 Lakhs\n₹25–50 Lakhs\nAbove ₹50 Lakhs", + "area_sqft", 0), + ("Lead", "site_visit_done", "Site Visit Done", + "Check", None, "budget_range", 0), + ("Lead", "site_visit_date", "Site Visit Date", + "Date", None, "site_visit_done", 0), + ("Lead", "estimated_value", "Estimated Project Value (₹)", + "Currency", None, "site_visit_date", 0), + + # ── QUOTATION ──────────────────────────────────────────── + ("Quotation", "site_address", "Site / Delivery Address", + "Small Text", None, "customer_address", 0), + ("Quotation", "scope_of_work", "Scope of Work", + "Small Text", None, "site_address", 0), + ("Quotation", "site_visit_date", "Site Visit Date", + "Date", None, "scope_of_work", 0), + ("Quotation", "expected_start_date_q", "Expected Start Date", + "Date", None, "site_visit_date", 0), + ("Quotation", "expected_handover_date","Expected Handover Date", + "Date", None, "expected_start_date_q", 0), + ("Quotation", "design_style", "Design Style", + "Select", + "Modern / Contemporary\nTraditional / Classic\nMinimalist\nIndustrial\nBohemian\nMediterranean", + "expected_handover_date", 0), + + # ── SALES INVOICE ──────────────────────────────────────── + # 'project' field is native on Sales Invoice in v16 — no custom field needed. + # Adding billing-specific extras after 'customer_name' instead. + ("Sales Invoice", "milestone_description", "Milestone Description", + "Small Text", None, "customer_name", 0), + ("Sales Invoice", "payment_mode_note", "Payment Mode Note", + "Data", None, "milestone_description", 0), + + # ── OPPORTUNITY ────────────────────────────────────────── + ("Opportunity", "property_address", "Property / Site Address", + "Small Text", None, "customer_name", 0), + ("Opportunity", "area_sqft", "Area (SqFt)", + "Float", None, "property_address", 0), + ("Opportunity", "design_style", "Design Style", + "Select", + "Modern / Contemporary\nTraditional / Classic\nMinimalist\nIndustrial\nBohemian\nMediterranean", + "area_sqft", 0), + ("Opportunity", "site_visit_done", "Site Visit Done", + "Check", None, "design_style", 0), + ("Opportunity", "site_visit_date", "Site Visit Date", + "Date", None, "site_visit_done", 0), + ] + + for dt, fn, label, ft, opts, after, in_list in [ + (*f, 0) if len(f) == 6 else f for f in fields + ]: + cf_name = f"{dt}-{fn}" + if not exists("Custom Field", cf_name): + d_dict = { + "doctype": "Custom Field", + "dt": dt, + "fieldname": fn, + "label": label, + "fieldtype": ft, + "insert_after": after, + "in_list_view": in_list, + } + if opts: + d_dict["options"] = opts + d = frappe.get_doc(d_dict) + if safe_insert(d): ok(f"Custom Field: {dt}.{fn}") + else: skip(f"{dt}.{fn} (dup)") + else: + skip(f"Custom Field: {dt}.{fn}") + + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 10. SAMPLE CUSTOMERS (Kolkata residential) +# ────────────────────────────────────────────────────────────── +def create_sample_customers(): + print("\n[10] Sample Customers...") + + customers = [ + { + "customer_name": "Sharma Residence - Salt Lake", + "customer_group": "Residential - Individual", + "territory": "Salt Lake", + "customer_type": "Individual", + }, + { + "customer_name": "Mukherjee Apartment - Behala", + "customer_group": "Residential - Builder Flat", + "territory": "South Kolkata", + "customer_type": "Individual", + }, + { + "customer_name": "Bansal Office - Park Street", + "customer_group": "Commercial - Office", + "territory": "Kolkata", + "customer_type": "Company", + }, + ] + for c in customers: + if not exists("Customer", c["customer_name"]): + d = frappe.get_doc({"doctype": "Customer", **c}) + if safe_insert(d): ok(f"Customer: {c['customer_name']}") + else: skip(f"{c['customer_name']} (dup)") + else: + skip(f"Customer: {c['customer_name']}") + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 11. SELLING SETTINGS — set Furnitex defaults +# ────────────────────────────────────────────────────────────── +def configure_selling_settings(): + print("\n[11] Selling & CRM Settings...") + try: + s = frappe.get_single("Selling Settings") + s.cust_master_name = "Customer Name" + s.customer_group = "Residential - Individual" + s.territory = "Kolkata" + s.price_list = "Standard Selling" + s.selling_price_list = "Standard Selling" + s.flags.ignore_permissions = True + s.save() + ok("Selling Settings updated") + except Exception as e: + skip(f"Selling Settings: {e}") + + try: + b = frappe.get_single("Buying Settings") + b.supp_master_name = "Supplier Name" + b.supplier_group = "Local Market Vendor (Unregistered)" + b.flags.ignore_permissions = True + b.save() + ok("Buying Settings updated") + except Exception as e: + skip(f"Buying Settings: {e}") + + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 12. CRM PIPELINE — Sales Stages + Opportunity Types +# ────────────────────────────────────────────────────────────── +def create_crm_stages(): + print("\n[12] CRM Pipeline (Sales Stages + Opportunity Types)...") + + # ── Sales Stages ───────────────────────────────────────── + furnitex_stages = [ + "New Enquiry", + "Site Visit Scheduled", + "Site Visit Done", + "Design Presentation", + "Quotation Sent", + "Negotiation", + "PO / Work Order Received", + "Work In Progress", + "Handover Done", + ] + for stage in furnitex_stages: + if not exists("Sales Stage", stage): + try: + d = frappe.get_doc({ + "doctype": "Sales Stage", + "stage_name": stage, + }) + if safe_insert(d): ok(f"Sales Stage: {stage}") + else: skip(f"{stage} (dup)") + except Exception as e: + skip(f"Sales Stage {stage}: {e}") + else: + skip(f"Sales Stage: {stage}") + + # ── Opportunity Types ───────────────────────────────────── + opp_types = [ + "Interior Design - Full Home", + "Interior Design - Bedroom", + "Interior Design - Kitchen", + "Interior Design - Office", + "Interior Design - Restaurant", + "Loose Furniture Supply", + "Renovation / Refurbishment", + "False Ceiling Only", + "Wardrobe / Storage Only", + "Flooring Only", + ] + for ot in opp_types: + if not exists("Opportunity Type", ot): + try: + d = frappe.get_doc({ + "doctype": "Opportunity Type", + "name": ot, + }) + if safe_insert(d): ok(f"Opportunity Type: {ot}") + else: skip(f"{ot} (dup)") + except Exception as e: + skip(f"Opportunity Type {ot}: {e}") + else: + skip(f"Opportunity Type: {ot}") + + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 13. PRINT FORMAT — Quotation (set default T&C) +# ────────────────────────────────────────────────────────────── +def configure_print_defaults(): + print("\n[13] Setting default T&C on Quotation & Sales Invoice...") + + # Set default terms on Quotation doctype + try: + meta = frappe.get_meta("Quotation") + default_tc = "Furnitex - Quotation Terms" + if exists("Terms and Conditions", default_tc): + frappe.db.set_default("terms_and_conditions_quotation", default_tc) + ok(f"Default T&C for Quotation: {default_tc}") + except Exception as e: + skip(f"Quotation T&C default: {e}") + + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# 14. LETTER HEAD +# ────────────────────────────────────────────────────────────── +def create_letter_head(): + print("\n[14] Letter Head...") + lh_name = "Furnitex" + if not exists("Letter Head", lh_name): + d = frappe.get_doc({ + "doctype": "Letter Head", + "letter_head_name": lh_name, + "is_default": 1, + "source": "Rich Text", + "content": """ +
+ + + + + +
+

FURNITEX

+

+ INTERIOR DESIGN & FURNITURE MANUFACTURING +

+
+ Kolkata, West Bengal — India
+ 📞 +91 XXXXX XXXXX
+ ✉ info@furnitex.in
+ 🌐 www.furnitex.in +
+
""", + "footer": """ +
+ Furnitex | Interior Design & Furniture | Kolkata | GSTIN: [Your GSTIN] | CIN: [If applicable] +
""", + }) + if safe_insert(d): ok(f"Letter Head: {lh_name}") + else: skip(f"Letter Head {lh_name} (dup)") + else: + skip(f"Letter Head: {lh_name}") + frappe.db.commit() + + +# ────────────────────────────────────────────────────────────── +# MASTER RUNNER +# ────────────────────────────────────────────────────────────── +def run(): + frappe.set_user("Administrator") + + print("\n" + "=" * 58) + print(" FURNITEX — CRM + BILLING SETUP") + print("=" * 58) + + create_customer_groups() + create_territories() + create_lead_sources() + create_sales_persons() + create_payment_modes() + create_payment_terms() + create_terms_conditions() + create_price_list() + create_crm_billing_fields() + create_sample_customers() + configure_selling_settings() + create_crm_stages() + configure_print_defaults() + create_letter_head() + + frappe.clear_cache() + + print("\n" + "=" * 58) + print(" CRM + BILLING SETUP COMPLETE — refresh your browser") + print("=" * 58 + "\n") diff --git a/update_furnitex_info.py b/update_furnitex_info.py new file mode 100644 index 00000000..42937b92 --- /dev/null +++ b/update_furnitex_info.py @@ -0,0 +1,213 @@ +""" +update_furnitex_info.py +Updates ERPNext with real Furnitex business details from furnitex.co.in +Run via: bench --site frontend execute frappe.update_furnitex_info.run +""" + +import frappe + +COMPANY = "Furnitex" + +ADDRESS = "6/A/108 Mukundapur" +CITY = "Kolkata" +STATE = "West Bengal" +PINCODE = "700099" +COUNTRY = "India" +PHONE = "+91 62905 91422" +EMAIL = "info.furnitex@gmail.com" +WEBSITE = "https://furnitex.co.in" +INSTAGRAM = "https://www.instagram.com/frunitex" +LEGAL_NAME = "Furnitex Atelier Pvt. Ltd." +TAGLINE = "Redefine What Surrounds You" +FULL_ADDR = f"{ADDRESS}, {CITY} - {PINCODE}, {STATE}, {COUNTRY}" +REP_NAME = "Subhankar Dhar" +OFFICE_HOURS = "Monday – Saturday, 10:00 AM – 7:00 PM IST" + + +def run(): + frappe.set_user("Administrator") + + update_company() + update_address() + update_letter_head() + update_terms_conditions() + + frappe.db.commit() + frappe.clear_cache() + print("\n✓ Furnitex business info updated successfully.\n") + + +# ── 1. Company record ───────────────────────────────────────────────────────── + +def update_company(): + co = frappe.get_doc("Company", COMPANY) + co.phone_no = PHONE + co.email = EMAIL + co.website = WEBSITE + co.company_name = COMPANY # keep short name as primary + co.flags.ignore_permissions = True + co.save() + print(f" ✓ Company record updated: phone={PHONE}, email={EMAIL}") + + +# ── 2. Address record ───────────────────────────────────────────────────────── + +def update_address(): + # Check if a Furnitex address already exists + existing = frappe.db.get_value( + "Address", + {"address_title": COMPANY, "address_type": "Billing"}, + "name" + ) + + addr_doc = { + "doctype": "Address", + "address_title": COMPANY, + "address_type": "Billing", + "address_line1": ADDRESS, + "city": CITY, + "state": STATE, + "pincode": PINCODE, + "country": COUNTRY, + "phone": PHONE, + "email_id": EMAIL, + "is_primary_address": 1, + "links": [{ + "link_doctype": "Company", + "link_name": COMPANY + }] + } + + if existing: + doc = frappe.get_doc("Address", existing) + doc.update(addr_doc) + doc.flags.ignore_permissions = True + doc.save() + print(f" ✓ Address updated: {FULL_ADDR}") + else: + doc = frappe.get_doc(addr_doc) + doc.flags.ignore_permissions = True + doc.insert() + print(f" ✓ Address created: {FULL_ADDR}") + + +# ── 3. Letter Head ──────────────────────────────────────────────────────────── + +LETTER_HEAD_HTML = f""" +
+ + + + + +
+
+ FURNITEX +
+
+ {TAGLINE} +
+
+ {LEGAL_NAME} +
+
+
{ADDRESS}
+
{CITY} - {PINCODE}, {STATE}
+
Phone: {PHONE}
+
Email: {EMAIL}
+
Web: furnitex.co.in
+
+
+""" + +def update_letter_head(): + lh_name = "Furnitex" + if frappe.db.exists("Letter Head", lh_name): + doc = frappe.get_doc("Letter Head", lh_name) + doc.content = LETTER_HEAD_HTML + doc.is_default = 1 + doc.flags.ignore_permissions = True + doc.save() + print(" ✓ Letter Head updated with real contact details") + else: + doc = frappe.get_doc({ + "doctype": "Letter Head", + "letter_head_name": lh_name, + "content": LETTER_HEAD_HTML, + "is_default": 1, + }) + doc.flags.ignore_permissions = True + doc.insert() + print(" ✓ Letter Head created") + + +# ── 4. Terms & Conditions ───────────────────────────────────────────────────── + +QUOTATION_TC = f"""
+FURNITEX — Quotation Terms & Conditions

+ +1. Validity: This quotation is valid for 15 days from the date of issue.
+2. Payment Terms: 30% advance on order confirmation, 30% at 50% completion, 30% at completion, 10% at handover.
+3. Delivery: Timelines are estimated and subject to site readiness and material availability. Furnitex is not liable for delays caused by site conditions.
+4. Design Changes: Any changes post order confirmation may attract additional charges and revised timelines.
+5. Material: All materials as specified. Substitutions may be made with equivalent or superior alternatives with prior intimation.
+6. Site Access: Client to ensure uninterrupted site access during agreed working hours: {OFFICE_HOURS}.
+7. Warranty: 1-year manufacturing warranty on all Furnitex-fabricated items. Hardware and third-party products carry manufacturer warranty.
+8. Dispute Resolution: All disputes subject to Kolkata jurisdiction.

+ +Furnitex Atelier Pvt. Ltd. · {ADDRESS}, {CITY} - {PINCODE} · {PHONE} · {EMAIL} +
""" + +INVOICE_TC = f"""
+FURNITEX — Invoice Terms & Conditions

+ +1. Payment Due: Payment is due within 7 days of invoice date unless otherwise agreed in writing.
+2. Late Payment: Overdue amounts attract interest at 18% per annum.
+3. GST: GST as applicable under Indian law is charged additionally where mentioned.
+4. Delivery & Installation: Goods remain property of Furnitex until full payment is received.
+5. Returns: Custom-manufactured furniture is non-returnable. Defects to be reported within 48 hours of delivery.
+6. Warranty: 1-year manufacturing defect warranty. Normal wear, misuse, or site-caused damage not covered.
+7. Disputes: Subject to Kolkata jurisdiction.

+ +Furnitex Atelier Pvt. Ltd. · {ADDRESS}, {CITY} - {PINCODE} · {PHONE} · {EMAIL} +
""" + +PO_TC = f"""
+FURNITEX — Purchase Order Terms & Conditions

+ +1. Acceptance: Supply of goods/services against this PO constitutes acceptance of these terms.
+2. Quality: All materials must conform to the specifications mentioned. Substandard materials will be rejected at supplier's cost.
+3. Delivery: Deliver to the address specified on the PO by the agreed date. Delays must be communicated 48 hours in advance.
+4. Invoice: Raise GST-compliant invoice (or cash memo for URD) with PO reference. Payment processed within 7 days of invoice receipt and material acceptance.
+5. Warranty: Supplier warrants materials against defects for minimum 6 months from delivery.
+6. Jurisdiction: Kolkata courts have exclusive jurisdiction.

+ +Furnitex Atelier Pvt. Ltd. · {ADDRESS}, {CITY} - {PINCODE} · {PHONE} · {EMAIL} +
""" + +TC_MAP = { + "Furnitex - Quotation T&C": QUOTATION_TC, + "Furnitex - Invoice T&C": INVOICE_TC, + "Furnitex - Purchase Order T&C": PO_TC, +} + +def update_terms_conditions(): + for title, content in TC_MAP.items(): + if frappe.db.exists("Terms and Conditions", title): + doc = frappe.get_doc("Terms and Conditions", title) + doc.terms = content + doc.flags.ignore_permissions = True + doc.save() + print(f" ✓ Updated T&C: {title}") + else: + doc = frappe.get_doc({ + "doctype": "Terms and Conditions", + "title": title, + "terms": content, + "selling": 1, + "buying": 1, + "hr": 0, + }) + doc.flags.ignore_permissions = True + doc.insert() + print(f" ✓ Created T&C: {title}") From 4678d517ae6b166174a7f2d23c832d455df2de0c Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Tue, 16 Jun 2026 15:32:38 +0530 Subject: [PATCH 08/10] fix: remove frappe.db.commit() from OnSite WIP server script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frappe v16's server script sandbox does not allow explicit commits — the framework manages the transaction automatically after the event handler runs. Calling frappe.db.commit() inside a DocType Event script throws AttributeError: module has no attribute 'commit'. Also adds fix_server_script_commit.py to patch already-deployed instances. Co-Authored-By: Claude Sonnet 4.6 --- fix_server_script_commit.py | 61 +++++++++++++++++++++++++++++++++++++ setup_furnitex.py | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 fix_server_script_commit.py diff --git a/fix_server_script_commit.py b/fix_server_script_commit.py new file mode 100644 index 00000000..48569f2a --- /dev/null +++ b/fix_server_script_commit.py @@ -0,0 +1,61 @@ +""" +fix_server_script_commit.py +Removes frappe.db.commit() from the OnSite WIP warehouse server script. +In DocType Event server scripts, Frappe manages the transaction automatically — +calling frappe.db.commit() inside the script throws AttributeError in the sandbox. +Run via: bench --site frontend execute frappe.fix_server_script_commit.run +""" + +import frappe + +SCRIPT_NAME = "Furnitex - Auto Create OnSite WIP Warehouse" + +FIXED_SCRIPT = """\ +# Auto-fires after a new Project is saved +# Creates "{Project Name} - OnSite WIP" warehouse automatically + +project_name = doc.project_name or doc.name +company = doc.company or "Furnitex" +abbr = frappe.db.get_value("Company", company, "abbr") or "F" +wh_short_name = project_name + " - OnSite WIP" +warehouse_name = wh_short_name + " - " + abbr + +root_wh = frappe.db.get_value( + "Warehouse", + {"company": company, "is_group": 1}, + "name" +) or ("All Warehouses - " + abbr) + +if not frappe.db.exists("Warehouse", warehouse_name): + wh = frappe.get_doc({ + "doctype": "Warehouse", + "warehouse_name": wh_short_name, + "parent_warehouse": root_wh, + "company": company, + "is_group": 0, + }) + wh.flags.ignore_permissions = True + wh.insert() + # NOTE: no frappe.db.commit() here — Frappe handles the transaction + frappe.msgprint( + "OnSite WIP Warehouse created: " + warehouse_name + "", + alert=True, indicator="green" + ) +""" + + +def run(): + frappe.set_user("Administrator") + + if not frappe.db.exists("Server Script", SCRIPT_NAME): + print(f" [WARN] Server Script '{SCRIPT_NAME}' not found — nothing to fix.") + return + + doc = frappe.get_doc("Server Script", SCRIPT_NAME) + doc.script = FIXED_SCRIPT + doc.flags.ignore_permissions = True + doc.save() + frappe.db.commit() + + print(f" [OK] Removed frappe.db.commit() from: {SCRIPT_NAME}") + print(" Warehouse auto-creation will now work without AttributeError.") diff --git a/setup_furnitex.py b/setup_furnitex.py index 4deb941a..0e487b1a 100644 --- a/setup_furnitex.py +++ b/setup_furnitex.py @@ -552,7 +552,7 @@ if not frappe.db.exists("Warehouse", warehouse_name): }) wh.flags.ignore_permissions = True wh.insert() - frappe.db.commit() + # No frappe.db.commit() — Frappe manages the transaction in server scripts frappe.msgprint( "OnSite WIP Warehouse created: " + warehouse_name + "", alert=True, indicator="green" From c40e7992cba55c142ef30aae2067328e261ab743 Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Tue, 16 Jun 2026 15:41:54 +0530 Subject: [PATCH 09/10] style: fix black/isort formatting and codespell typo to pass pre-commit lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - black: auto-reformatted all 4 Furnitex scripts to PEP 8 style - isort: sorted imports in setup_furnitex.py and delete_streetwok.py - codespell: renamed loop variable `ot` → `opp_type` in create_crm_stages() (codespell flagged `ot` as a misspelling) Co-Authored-By: Claude Sonnet 4.6 --- delete_streetwok.py | 55 ++- setup_furnitex.py | 555 +++++++++++++-------- setup_furnitex_crm_billing.py | 879 ++++++++++++++++++++++------------ update_furnitex_info.py | 105 ++-- 4 files changed, 1008 insertions(+), 586 deletions(-) diff --git a/delete_streetwok.py b/delete_streetwok.py index 1aaf43d5..6018f46c 100644 --- a/delete_streetwok.py +++ b/delete_streetwok.py @@ -6,7 +6,6 @@ Run via: bench --site frontend execute frappe.delete_streetwok.run import frappe - COMPANY = "streetwok (Demo)" @@ -47,7 +46,8 @@ def run(): # Get submitted docs for this company docs = frappe.db.sql( f"SELECT name FROM `tab{dt}` WHERE company=%s AND docstatus=1", - COMPANY, as_dict=1 + COMPANY, + as_dict=1, ) if docs: print(f" Cancelling {len(docs)} submitted {dt}(s)...") @@ -99,9 +99,9 @@ def run(): # ── STEP 5: Delete child tables that reference the company ──── child_cleanups = [ - ("Sales Invoice Item", "company"), + ("Sales Invoice Item", "company"), ("Purchase Invoice Item", "company"), - ("Payment Entry Reference", None), # handled via parent delete + ("Payment Entry Reference", None), # handled via parent delete ] # ── STEP 6: Delete Warehouses ───────────────────────────────── @@ -116,27 +116,28 @@ def run(): for wh in wh_list: try: - frappe.delete_doc("Warehouse", wh.name, - ignore_permissions=True, force=True, - ignore_on_trash=True) - except Exception as e: - frappe.db.sql( - "DELETE FROM `tabWarehouse` WHERE name=%s", wh.name + frappe.delete_doc( + "Warehouse", + wh.name, + ignore_permissions=True, + force=True, + ignore_on_trash=True, ) + except Exception as e: + frappe.db.sql("DELETE FROM `tabWarehouse` WHERE name=%s", wh.name) frappe.db.commit() print(f" Deleted {len(wh_list)} Warehouse(s)") # ── STEP 7: Delete Cost Centers ─────────────────────────────── cc_list = frappe.db.sql( "SELECT name FROM `tabCost Center` WHERE company=%s ORDER BY lft DESC", - COMPANY, as_dict=1 + COMPANY, + as_dict=1, ) if cc_list: for cc in cc_list: try: - frappe.db.sql( - "DELETE FROM `tabCost Center` WHERE name=%s", cc.name - ) + frappe.db.sql("DELETE FROM `tabCost Center` WHERE name=%s", cc.name) except Exception: pass frappe.db.commit() @@ -149,21 +150,21 @@ def run(): frappe.db.sql( "DELETE FROM `tabAccount` WHERE company=%s AND is_group=0", COMPANY ) - frappe.db.sql( - "DELETE FROM `tabAccount` WHERE company=%s", COMPANY - ) + frappe.db.sql("DELETE FROM `tabAccount` WHERE company=%s", COMPANY) frappe.db.commit() print(f" Deleted {acct_count} Account(s)") # ── STEP 9: Delete Fiscal Years linked only to this company ─── fy_links = frappe.db.sql( """SELECT parent FROM `tabFiscal Year Company` - WHERE company=%s""", COMPANY, as_dict=1 + WHERE company=%s""", + COMPANY, + as_dict=1, ) for fy in fy_links: frappe.db.sql( "DELETE FROM `tabFiscal Year Company` WHERE company=%s AND parent=%s", - (COMPANY, fy.parent) + (COMPANY, fy.parent), ) # If this fiscal year has no other company links, delete it too remaining = frappe.db.count("Fiscal Year Company", {"parent": fy.parent}) @@ -186,7 +187,8 @@ def run(): AND NOT EXISTS ( SELECT 1 FROM `tabSales Order` WHERE customer=c.name AND company='Furnitex' AND docstatus < 2 - )""", as_dict=1 + )""", + as_dict=1, ) if stale_customers: for c in stale_customers: @@ -199,9 +201,13 @@ def run(): # ── STEP 11: Delete the Company record itself ───────────────── try: - frappe.delete_doc("Company", COMPANY, - ignore_permissions=True, force=True, - ignore_on_trash=True) + frappe.delete_doc( + "Company", + COMPANY, + ignore_permissions=True, + force=True, + ignore_on_trash=True, + ) except Exception: frappe.db.sql("DELETE FROM `tabCompany` WHERE name=%s", COMPANY) frappe.db.commit() @@ -219,7 +225,8 @@ def run(): """SELECT name FROM `tabItem` WHERE item_name LIKE '%streetwok%' OR item_code LIKE '%streetwok%' - OR description LIKE '%streetwok%'""", as_dict=1 + OR description LIKE '%streetwok%'""", + as_dict=1, ) if stale_items: for item in stale_items: diff --git a/setup_furnitex.py b/setup_furnitex.py index 0e487b1a..0f8d7f19 100644 --- a/setup_furnitex.py +++ b/setup_furnitex.py @@ -9,10 +9,10 @@ Or directly: import frappe import frappe.defaults +COMPANY = "Furnitex" +SITE = "frontend" +ABBR = None # resolved at runtime via get_abbr() -COMPANY = "Furnitex" -SITE = "frontend" -ABBR = None # resolved at runtime via get_abbr() def get_abbr(): global ABBR @@ -25,13 +25,16 @@ def get_abbr(): # HELPERS # ───────────────────────────────────────────────────────────── + def exists(doctype, name): return frappe.db.exists(doctype, name) + def exists_filter(doctype, filters): """Existence check by filters (for docs where name includes company abbr).""" return frappe.db.get_value(doctype, filters, "name") + def safe_insert(doc): """Insert and return True, or skip silently on duplicate and return False.""" try: @@ -45,12 +48,15 @@ def safe_insert(doc): return False raise + def ok(msg): print(f" [OK] {msg}") + def skip(msg): print(f" [SKIP] {msg}") + def warn(msg): print(f" [WARN] {msg}") @@ -59,23 +65,26 @@ def warn(msg): # 1. UOMs # ───────────────────────────────────────────────────────────── + def create_uoms(): print("\n[1/9] Creating UOMs...") uoms = [ - ("SqFt", 0), - ("Rft", 0), - ("Bag", 1), - ("Sheet", 1), + ("SqFt", 0), + ("Rft", 0), + ("Bag", 1), + ("Sheet", 1), ("Bundle", 1), ("Cubic Ft", 0), ] for uom_name, whole in uoms: if not exists("UOM", uom_name): - d = frappe.get_doc({ - "doctype": "UOM", - "uom_name": uom_name, - "must_be_whole_number": whole, - }) + d = frappe.get_doc( + { + "doctype": "UOM", + "uom_name": uom_name, + "must_be_whole_number": whole, + } + ) d.flags.ignore_mandatory = True if safe_insert(d): ok(f"UOM: {uom_name}") @@ -90,25 +99,28 @@ def create_uoms(): # 2. ITEM GROUPS # ───────────────────────────────────────────────────────────── + def create_item_groups(): print("\n[2/9] Creating Item Groups...") groups = [ - ("Raw Materials - Furnitex", "All Item Groups"), - ("Plywood & Board", "Raw Materials - Furnitex"), - ("Laminates & Veneer", "Raw Materials - Furnitex"), - ("Hardware & Fittings", "Raw Materials - Furnitex"), + ("Raw Materials - Furnitex", "All Item Groups"), + ("Plywood & Board", "Raw Materials - Furnitex"), + ("Laminates & Veneer", "Raw Materials - Furnitex"), + ("Hardware & Fittings", "Raw Materials - Furnitex"), ("Civil & Surface Materials", "Raw Materials - Furnitex"), - ("Execution Services", "All Item Groups"), - ("Loose Furniture", "All Item Groups"), + ("Execution Services", "All Item Groups"), + ("Loose Furniture", "All Item Groups"), ] for name, parent in groups: if not exists("Item Group", name): - d = frappe.get_doc({ - "doctype": "Item Group", - "item_group_name": name, - "parent_item_group": parent, - "is_group": 0, - }) + d = frappe.get_doc( + { + "doctype": "Item Group", + "item_group_name": name, + "parent_item_group": parent, + "is_group": 0, + } + ) if safe_insert(d): ok(f"Item Group: {name}") else: @@ -122,20 +134,23 @@ def create_item_groups(): # 3. SUPPLIER GROUPS # ───────────────────────────────────────────────────────────── + def create_supplier_groups(): print("\n[3/9] Creating Supplier Groups...") groups = [ ("Local Market Vendor (Unregistered)", "All Supplier Groups"), - ("GST Registered Vendor", "All Supplier Groups"), - ("Labour Contractor", "All Supplier Groups"), + ("GST Registered Vendor", "All Supplier Groups"), + ("Labour Contractor", "All Supplier Groups"), ] for name, parent in groups: if not exists("Supplier Group", name): - d = frappe.get_doc({ - "doctype": "Supplier Group", - "supplier_group_name": name, - "parent_supplier_group": parent, - }) + d = frappe.get_doc( + { + "doctype": "Supplier Group", + "supplier_group_name": name, + "parent_supplier_group": parent, + } + ) if safe_insert(d): ok(f"Supplier Group: {name}") else: @@ -149,34 +164,35 @@ def create_supplier_groups(): # 4. WAREHOUSES # ───────────────────────────────────────────────────────────── + def create_warehouses(): print("\n[4/9] Creating Warehouses...") abbr = get_abbr() # Find the company's root warehouse group root_wh = frappe.db.get_value( - "Warehouse", - {"company": COMPANY, "is_group": 1}, - "name" + "Warehouse", {"company": COMPANY, "is_group": 1}, "name" ) if not root_wh: root_wh = f"All Warehouses - {abbr}" warehouses = [ - ("Main Store", root_wh, 0), + ("Main Store", root_wh, 0), ("Rejected Stock", root_wh, 0), ] for w_short, parent, is_group in warehouses: # ERPNext appends company abbr: "Main Store - F" w_full = f"{w_short} - {abbr}" if not exists("Warehouse", w_full): - d = frappe.get_doc({ - "doctype": "Warehouse", - "warehouse_name": w_short, - "parent_warehouse": parent, - "company": COMPANY, - "is_group": is_group, - }) + d = frappe.get_doc( + { + "doctype": "Warehouse", + "warehouse_name": w_short, + "parent_warehouse": parent, + "company": COMPANY, + "is_group": is_group, + } + ) if safe_insert(d): ok(f"Warehouse: {w_full}") else: @@ -190,6 +206,7 @@ def create_warehouses(): # 5. TAX TEMPLATES # ───────────────────────────────────────────────────────────── + def _find_account(account_name_fragment, root_type=None, account_type=None): """Find an account by partial name match under the company.""" filters = {"company": COMPANY, "is_group": 0} @@ -199,8 +216,9 @@ def _find_account(account_name_fragment, root_type=None, account_type=None): filters["account_type"] = account_type # Try exact name match first - result = frappe.db.get_value("Account", - dict(filters, account_name=account_name_fragment), "name") + result = frappe.db.get_value( + "Account", dict(filters, account_name=account_name_fragment), "name" + ) if result: return result @@ -211,7 +229,8 @@ def _find_account(account_name_fragment, root_type=None, account_type=None): WHERE company=%s AND is_group=0 AND account_name LIKE %s LIMIT 1""", - (COMPANY, like_pattern), as_dict=0 + (COMPANY, like_pattern), + as_dict=0, ) return result[0][0] if result else None @@ -222,15 +241,18 @@ def create_tax_templates(): # ── No GST (URD Purchase) ── urd_title = "No GST - URD Purchase" - if not exists_filter("Purchase Taxes and Charges Template", - {"title": urd_title, "company": COMPANY}): - d = frappe.get_doc({ - "doctype": "Purchase Taxes and Charges Template", - "title": urd_title, - "company": COMPANY, - "is_default": 0, - "taxes": [], - }) + if not exists_filter( + "Purchase Taxes and Charges Template", {"title": urd_title, "company": COMPANY} + ): + d = frappe.get_doc( + { + "doctype": "Purchase Taxes and Charges Template", + "title": urd_title, + "company": COMPANY, + "is_default": 0, + "taxes": [], + } + ) if safe_insert(d): ok(f"Purchase Tax Template: {urd_title}") else: @@ -240,27 +262,45 @@ def create_tax_templates(): # ── GST 18% Purchase ── gst18_p_title = "GST 18% - Purchase" - if not exists_filter("Purchase Taxes and Charges Template", - {"title": gst18_p_title, "company": COMPANY}): + if not exists_filter( + "Purchase Taxes and Charges Template", + {"title": gst18_p_title, "company": COMPANY}, + ): cgst = _find_account("CGST") sgst = _find_account("SGST") taxes = [] if cgst: - taxes.append({"charge_type": "On Net Total", "account_head": cgst, - "rate": 9, "description": "CGST @ 9%"}) + taxes.append( + { + "charge_type": "On Net Total", + "account_head": cgst, + "rate": 9, + "description": "CGST @ 9%", + } + ) if sgst: - taxes.append({"charge_type": "On Net Total", "account_head": sgst, - "rate": 9, "description": "SGST @ 9%"}) - d = frappe.get_doc({ - "doctype": "Purchase Taxes and Charges Template", - "title": gst18_p_title, - "company": COMPANY, - "is_default": 0, - "taxes": taxes, - }) + taxes.append( + { + "charge_type": "On Net Total", + "account_head": sgst, + "rate": 9, + "description": "SGST @ 9%", + } + ) + d = frappe.get_doc( + { + "doctype": "Purchase Taxes and Charges Template", + "title": gst18_p_title, + "company": COMPANY, + "is_default": 0, + "taxes": taxes, + } + ) if safe_insert(d): - ok(f"Purchase Tax Template: {gst18_p_title}" + - (" (no GST accounts in CoA, left empty)" if not taxes else "")) + ok( + f"Purchase Tax Template: {gst18_p_title}" + + (" (no GST accounts in CoA, left empty)" if not taxes else "") + ) else: skip(f"Purchase Tax Template: {gst18_p_title} (duplicate)") else: @@ -268,27 +308,44 @@ def create_tax_templates(): # ── GST 18% Sales ── gst18_s_title = "GST 18% - Sales" - if not exists_filter("Sales Taxes and Charges Template", - {"title": gst18_s_title, "company": COMPANY}): + if not exists_filter( + "Sales Taxes and Charges Template", {"title": gst18_s_title, "company": COMPANY} + ): cgst = _find_account("CGST") sgst = _find_account("SGST") taxes = [] if cgst: - taxes.append({"charge_type": "On Net Total", "account_head": cgst, - "rate": 9, "description": "CGST @ 9%"}) + taxes.append( + { + "charge_type": "On Net Total", + "account_head": cgst, + "rate": 9, + "description": "CGST @ 9%", + } + ) if sgst: - taxes.append({"charge_type": "On Net Total", "account_head": sgst, - "rate": 9, "description": "SGST @ 9%"}) - d = frappe.get_doc({ - "doctype": "Sales Taxes and Charges Template", - "title": gst18_s_title, - "company": COMPANY, - "is_default": 0, - "taxes": taxes, - }) + taxes.append( + { + "charge_type": "On Net Total", + "account_head": sgst, + "rate": 9, + "description": "SGST @ 9%", + } + ) + d = frappe.get_doc( + { + "doctype": "Sales Taxes and Charges Template", + "title": gst18_s_title, + "company": COMPANY, + "is_default": 0, + "taxes": taxes, + } + ) if safe_insert(d): - ok(f"Sales Tax Template: {gst18_s_title}" + - (" (no GST accounts in CoA, left empty)" if not taxes else "")) + ok( + f"Sales Tax Template: {gst18_s_title}" + + (" (no GST accounts in CoA, left empty)" if not taxes else "") + ) else: skip(f"Sales Tax Template: {gst18_s_title} (duplicate)") else: @@ -301,43 +358,92 @@ def create_tax_templates(): # 6. SERVICE ITEMS (Non-stock billing items) # ───────────────────────────────────────────────────────────── + def create_service_items(): print("\n[6/9] Creating Service Items...") - income_acct = _find_account("Sales", root_type="Income") or \ - _find_account("Service", root_type="Income") + income_acct = _find_account("Sales", root_type="Income") or _find_account( + "Service", root_type="Income" + ) items = [ # (code, name, uom, description) - ("SVC-FC-EXEC", "False Ceiling Execution", "SqFt", "Labour + material for false ceiling. Bill per SqFt OR as lumpsum."), - ("SVC-WAR-LAM", "Laminate Wardrobe Fabrication", "SqFt", "Laminate wardrobe fabrication per SqFt."), - ("SVC-KIT-EXEC", "Modular Kitchen Execution", "SqFt", "Modular kitchen fabrication and installation."), - ("SVC-FLOOR", "Flooring Execution", "SqFt", "Vinyl/Wood/Tile flooring supply and installation."), - ("SVC-LUMP", "Lumpsum Contract Work", "Nos", "Fixed-price lumpsum milestone. Change qty to 1 always."), - ("SVC-DESIGN", "Interior Design Consultation", "Nos", "Design + drawing fee — lumpsum."), - ("SVC-CONVEY", "Site Conveyance & Transport", "Nos", "Transport charges to/from site."), - ("SVC-LABOUR", "Direct Site Labour", "Nos", "Daily-wage labour charges."), - ("SVC-ELECTRIC", "Electrical Work Execution", "Nos", "Electrical points, wiring, fitting."), - ("SVC-PAINT", "Painting & Polish Execution", "SqFt", "Wall painting / wood polish per SqFt."), + ( + "SVC-FC-EXEC", + "False Ceiling Execution", + "SqFt", + "Labour + material for false ceiling. Bill per SqFt OR as lumpsum.", + ), + ( + "SVC-WAR-LAM", + "Laminate Wardrobe Fabrication", + "SqFt", + "Laminate wardrobe fabrication per SqFt.", + ), + ( + "SVC-KIT-EXEC", + "Modular Kitchen Execution", + "SqFt", + "Modular kitchen fabrication and installation.", + ), + ( + "SVC-FLOOR", + "Flooring Execution", + "SqFt", + "Vinyl/Wood/Tile flooring supply and installation.", + ), + ( + "SVC-LUMP", + "Lumpsum Contract Work", + "Nos", + "Fixed-price lumpsum milestone. Change qty to 1 always.", + ), + ( + "SVC-DESIGN", + "Interior Design Consultation", + "Nos", + "Design + drawing fee — lumpsum.", + ), + ( + "SVC-CONVEY", + "Site Conveyance & Transport", + "Nos", + "Transport charges to/from site.", + ), + ("SVC-LABOUR", "Direct Site Labour", "Nos", "Daily-wage labour charges."), + ( + "SVC-ELECTRIC", + "Electrical Work Execution", + "Nos", + "Electrical points, wiring, fitting.", + ), + ( + "SVC-PAINT", + "Painting & Polish Execution", + "SqFt", + "Wall painting / wood polish per SqFt.", + ), ] for code, name, uom, desc in items: if not exists("Item", code): # Non-stock service items: no warehouse needed in item_defaults - d = frappe.get_doc({ - "doctype": "Item", - "item_code": code, - "item_name": name, - "item_group": "Execution Services", - "description": desc, - "stock_uom": uom, - "sales_uom": uom, - "is_stock_item": 0, - "is_purchase_item": 0, - "is_sales_item": 1, - "standard_rate": 0, - # No item_defaults row — avoids warehouse company-mismatch validation - }) + d = frappe.get_doc( + { + "doctype": "Item", + "item_code": code, + "item_name": name, + "item_group": "Execution Services", + "description": desc, + "stock_uom": uom, + "sales_uom": uom, + "is_stock_item": 0, + "is_purchase_item": 0, + "is_sales_item": 1, + "standard_rate": 0, + # No item_defaults row — avoids warehouse company-mismatch validation + } + ) if safe_insert(d): ok(f"Service Item: {code} [{uom}]") else: @@ -352,12 +458,15 @@ def create_service_items(): # 7. RAW MATERIAL ITEMS (Stock items) # ───────────────────────────────────────────────────────────── + def create_raw_material_items(): print("\n[7/9] Creating Raw Material Items...") - cogs_acct = (_find_account("Cost of Goods Sold", root_type="Expense") or - _find_account("Stock Expenses", root_type="Expense") or - _find_account("Expenses Included", root_type="Expense")) + cogs_acct = ( + _find_account("Cost of Goods Sold", root_type="Expense") + or _find_account("Stock Expenses", root_type="Expense") + or _find_account("Expenses Included", root_type="Expense") + ) default_wh = f"Main Store - {get_abbr()}" if not exists("Warehouse", default_wh): @@ -367,39 +476,44 @@ def create_raw_material_items(): items = [ # (code, name, uom, group) - ("RM-PLY-19MM", "Plywood 19mm BWR 8x4", "Sheet", "Plywood & Board"), - ("RM-PLY-12MM", "Plywood 12mm BWR 8x4", "Sheet", "Plywood & Board"), - ("RM-PLY-6MM", "Plywood 6mm 8x4", "Sheet", "Plywood & Board"), - ("RM-MDF-18MM", "MDF Board 18mm 8x4", "Sheet", "Plywood & Board"), - ("RM-HDF-3MM", "HDF 3mm 8x4", "Sheet", "Plywood & Board"), - ("RM-LAM-1MM", "Laminate Sheet 1mm 8x4", "Sheet", "Laminates & Veneer"), - ("RM-VEN-NAT", "Veneer Sheet Natural Wood", "Sheet", "Laminates & Veneer"), - ("RM-LAM-ACRY", "Acrylic Laminate Sheet", "Sheet", "Laminates & Veneer"), - ("RM-HW-HINGE", "Concealed Hinge (pair)", "Nos", "Hardware & Fittings"), - ("RM-HW-CHAN18", "Drawer Channel 18 inch", "Nos", "Hardware & Fittings"), - ("RM-HW-CHAN24", "Drawer Channel 24 inch", "Nos", "Hardware & Fittings"), - ("RM-HW-HANDLE", "Cabinet Handle", "Nos", "Hardware & Fittings"), - ("RM-HW-LOCK", "Drawer Lock", "Nos", "Hardware & Fittings"), - ("RM-HW-SCREW", "Wood Screw Assorted", "Bundle", "Hardware & Fittings"), - ("RM-CIV-CEM", "OPC Cement 53 Grade", "Bag", "Civil & Surface Materials"), - ("RM-CIV-PUTTY", "Wall Putty White", "Bag", "Civil & Surface Materials"), - ("RM-CIV-PRIMER","Primer Interior", "Nos", "Civil & Surface Materials"), - ("RM-CIV-GYPS", "Gypsum Board 8x4 12.5mm", "Sheet", "Civil & Surface Materials"), - ("RM-CIV-GYPS-C","Gypsum Cornice / Grid", "Rft", "Civil & Surface Materials"), + ("RM-PLY-19MM", "Plywood 19mm BWR 8x4", "Sheet", "Plywood & Board"), + ("RM-PLY-12MM", "Plywood 12mm BWR 8x4", "Sheet", "Plywood & Board"), + ("RM-PLY-6MM", "Plywood 6mm 8x4", "Sheet", "Plywood & Board"), + ("RM-MDF-18MM", "MDF Board 18mm 8x4", "Sheet", "Plywood & Board"), + ("RM-HDF-3MM", "HDF 3mm 8x4", "Sheet", "Plywood & Board"), + ("RM-LAM-1MM", "Laminate Sheet 1mm 8x4", "Sheet", "Laminates & Veneer"), + ("RM-VEN-NAT", "Veneer Sheet Natural Wood", "Sheet", "Laminates & Veneer"), + ("RM-LAM-ACRY", "Acrylic Laminate Sheet", "Sheet", "Laminates & Veneer"), + ("RM-HW-HINGE", "Concealed Hinge (pair)", "Nos", "Hardware & Fittings"), + ("RM-HW-CHAN18", "Drawer Channel 18 inch", "Nos", "Hardware & Fittings"), + ("RM-HW-CHAN24", "Drawer Channel 24 inch", "Nos", "Hardware & Fittings"), + ("RM-HW-HANDLE", "Cabinet Handle", "Nos", "Hardware & Fittings"), + ("RM-HW-LOCK", "Drawer Lock", "Nos", "Hardware & Fittings"), + ("RM-HW-SCREW", "Wood Screw Assorted", "Bundle", "Hardware & Fittings"), + ("RM-CIV-CEM", "OPC Cement 53 Grade", "Bag", "Civil & Surface Materials"), + ("RM-CIV-PUTTY", "Wall Putty White", "Bag", "Civil & Surface Materials"), + ("RM-CIV-PRIMER", "Primer Interior", "Nos", "Civil & Surface Materials"), + ( + "RM-CIV-GYPS", + "Gypsum Board 8x4 12.5mm", + "Sheet", + "Civil & Surface Materials", + ), + ("RM-CIV-GYPS-C", "Gypsum Cornice / Grid", "Rft", "Civil & Surface Materials"), ] for code, name, uom, group in items: if not exists("Item", code): d_dict = { - "doctype": "Item", - "item_code": code, - "item_name": name, - "item_group": group, - "stock_uom": uom, - "is_stock_item": 1, - "is_purchase_item": 1, - "is_sales_item": 0, - "valuation_method": "FIFO", + "doctype": "Item", + "item_code": code, + "item_name": name, + "item_group": group, + "stock_uom": uom, + "is_stock_item": 1, + "is_purchase_item": 1, + "is_sales_item": 0, + "valuation_method": "FIFO", } # Only add item_defaults if we have a valid Furnitex warehouse if default_wh or cogs_acct: @@ -425,29 +539,52 @@ def create_raw_material_items(): # 8. SAMPLE SUPPLIERS # ───────────────────────────────────────────────────────────── + def create_suppliers(): print("\n[8/9] Creating Sample Suppliers...") suppliers = [ # (name, group, gst_category) - ("Local Hardware Market - Kolkata", "Local Market Vendor (Unregistered)", "Unregistered"), - ("Burrabazar Plywood Supplier", "Local Market Vendor (Unregistered)", "Unregistered"), - ("Fancy Laminates - Howrah", "Local Market Vendor (Unregistered)", "Unregistered"), - ("Modern Furniture Hardware - BBD Bag","Local Market Vendor (Unregistered)", "Unregistered"), - ("Registered Hardware Supplier Ltd", "GST Registered Vendor", "Registered Regular"), - ("Site Labour Contractor - Ramesh", "Labour Contractor", "Unregistered"), + ( + "Local Hardware Market - Kolkata", + "Local Market Vendor (Unregistered)", + "Unregistered", + ), + ( + "Burrabazar Plywood Supplier", + "Local Market Vendor (Unregistered)", + "Unregistered", + ), + ( + "Fancy Laminates - Howrah", + "Local Market Vendor (Unregistered)", + "Unregistered", + ), + ( + "Modern Furniture Hardware - BBD Bag", + "Local Market Vendor (Unregistered)", + "Unregistered", + ), + ( + "Registered Hardware Supplier Ltd", + "GST Registered Vendor", + "Registered Regular", + ), + ("Site Labour Contractor - Ramesh", "Labour Contractor", "Unregistered"), ] for name, group, gst_cat in suppliers: if not exists("Supplier", name): - d = frappe.get_doc({ - "doctype": "Supplier", - "supplier_name": name, - "supplier_group": group, - "country": "India", - "gst_category": gst_cat, - "default_currency": "INR", - }) + d = frappe.get_doc( + { + "doctype": "Supplier", + "supplier_name": name, + "supplier_group": group, + "country": "India", + "gst_category": gst_cat, + "default_currency": "INR", + } + ) if safe_insert(d): ok(f"Supplier: {name} [{gst_cat}]") else: @@ -461,8 +598,9 @@ def create_suppliers(): # URD tax bypass is handled instead by the server script: # "Furnitex - Clear GST on URD Purchase" (Before Save on Purchase Invoice) # Tick the "URD Purchase (No GST)" checkbox on any invoice to auto-clear taxes. - urd_suppliers_count = frappe.db.count("Supplier", - {"supplier_group": "Local Market Vendor (Unregistered)"}) + urd_suppliers_count = frappe.db.count( + "Supplier", {"supplier_group": "Local Market Vendor (Unregistered)"} + ) ok(f"URD tax via server script — {urd_suppliers_count} URD suppliers registered") @@ -470,6 +608,7 @@ def create_suppliers(): # 9. CUSTOM FIELDS # ───────────────────────────────────────────────────────────── + def create_custom_fields(): print("\n[9/9] Creating Custom Fields...") @@ -481,25 +620,31 @@ def create_custom_fields(): # (dt, fieldname, label, fieldtype, options, insert_after, in_list_view) fields = [ - ("Purchase Invoice", "is_urd_purchase", "URD Purchase (No GST)", - "Check", None, "supplier", 1), - ("Journal Entry", "project", "Project", - "Link", "Project", "voucher_type", 0), + ( + "Purchase Invoice", + "is_urd_purchase", + "URD Purchase (No GST)", + "Check", + None, + "supplier", + 1, + ), + ("Journal Entry", "project", "Project", "Link", "Project", "voucher_type", 0), ] for dt, fn, label, ft, opts, after, in_list in fields: cf_name = f"{dt}-{fn}" if not exists("Custom Field", cf_name): d_dict = { - "doctype": "Custom Field", - "dt": dt, - "fieldname": fn, - "label": label, - "fieldtype": ft, - "insert_after": after, - "in_list_view": in_list, + "doctype": "Custom Field", + "dt": dt, + "fieldname": fn, + "label": label, + "fieldtype": ft, + "insert_after": after, + "in_list_view": in_list, "in_standard_filter": 1, - "search_index": 1, + "search_index": 1, } if opts: d_dict["options"] = opts @@ -512,19 +657,22 @@ def create_custom_fields(): skip(f"Custom Field: {dt}.{fn}") frappe.db.commit() - ok("Native 'project' field already present on PI, PO, Stock Entry, DN — no custom fields needed there") + ok( + "Native 'project' field already present on PI, PO, Stock Entry, DN — no custom fields needed there" + ) # ───────────────────────────────────────────────────────────── # 10. SERVER SCRIPTS (Automation) # ───────────────────────────────────────────────────────────── + def create_server_scripts(): print("\n[+] Creating Server Scripts...") # Script 1: Auto-create OnSite WIP Warehouse on Project insert - wip_script_name = "Furnitex - Auto Create OnSite WIP Warehouse" - wip_script_code = """ + wip_script_name = "Furnitex - Auto Create OnSite WIP Warehouse" + wip_script_code = """ # Auto-fires after a new Project is saved # Creates "{Project Name} - OnSite WIP" warehouse automatically @@ -560,15 +708,17 @@ if not frappe.db.exists("Warehouse", warehouse_name): """ if not exists("Server Script", wip_script_name): - d = frappe.get_doc({ - "doctype": "Server Script", - "name": wip_script_name, - "script_type": "DocType Event", - "reference_doctype": "Project", - "doctype_event": "After Insert", - "enabled": 1, - "script": wip_script_code, - }) + d = frappe.get_doc( + { + "doctype": "Server Script", + "name": wip_script_name, + "script_type": "DocType Event", + "reference_doctype": "Project", + "doctype_event": "After Insert", + "enabled": 1, + "script": wip_script_code, + } + ) d.flags.ignore_permissions = True d.insert() ok(f"Server Script: {wip_script_name}") @@ -597,15 +747,17 @@ if doc.is_urd_purchase: """ if not exists("Server Script", urd_script_name): - d = frappe.get_doc({ - "doctype": "Server Script", - "name": urd_script_name, - "script_type": "DocType Event", - "reference_doctype": "Purchase Invoice", - "doctype_event": "Before Save", - "enabled": 1, - "script": urd_script_code, - }) + d = frappe.get_doc( + { + "doctype": "Server Script", + "name": urd_script_name, + "script_type": "DocType Event", + "reference_doctype": "Purchase Invoice", + "doctype_event": "Before Save", + "enabled": 1, + "script": urd_script_code, + } + ) d.flags.ignore_permissions = True d.insert() ok(f"Server Script: {urd_script_name}") @@ -706,19 +858,22 @@ def get_data(filters): return rows ''' + def create_custom_report(): print("\n[+] Creating Custom Report: Furnitex Project Profitability...") report_name = "Furnitex Project Profitability" if not exists("Report", report_name): - d = frappe.get_doc({ - "doctype": "Report", - "report_name": report_name, - "ref_doctype": "Project", - "report_type": "Script Report", - "is_standard": "No", - "module": "Projects", - "script": PROFIT_REPORT_SCRIPT, - }) + d = frappe.get_doc( + { + "doctype": "Report", + "report_name": report_name, + "ref_doctype": "Project", + "report_type": "Script Report", + "is_standard": "No", + "module": "Projects", + "script": PROFIT_REPORT_SCRIPT, + } + ) d.flags.ignore_permissions = True d.insert() ok(f"Custom Report: {report_name}") @@ -731,6 +886,7 @@ def create_custom_report(): # MASTER RUNNER # ───────────────────────────────────────────────────────────── + def run_all(): frappe.set_user("Administrator") print("\n" + "=" * 58) @@ -759,6 +915,7 @@ def run_all(): if __name__ == "__main__": import sys + frappe.init(site="frontend") frappe.connect() run_all() diff --git a/setup_furnitex_crm_billing.py b/setup_furnitex_crm_billing.py index 62720f40..459d8112 100644 --- a/setup_furnitex_crm_billing.py +++ b/setup_furnitex_crm_billing.py @@ -9,13 +9,18 @@ import frappe COMPANY = "Furnitex" -def ok(msg): print(f" [OK] {msg}") -def skip(msg): print(f" [SKIP] {msg}") +def ok(msg): + print(f" [OK] {msg}") + + +def skip(msg): + print(f" [SKIP] {msg}") + def safe_insert(doc): try: doc.flags.ignore_permissions = True - doc.flags.ignore_mandatory = True + doc.flags.ignore_mandatory = True doc.insert() return True except frappe.DuplicateEntryError: @@ -25,9 +30,11 @@ def safe_insert(doc): return False raise + def exists(dt, name): return frappe.db.exists(dt, name) + def exists_f(dt, filters): return frappe.db.get_value(dt, filters, "name") @@ -38,24 +45,28 @@ def exists_f(dt, filters): def create_customer_groups(): print("\n[1] Customer Groups...") groups = [ - ("Residential - Individual", "Individual"), - ("Residential - Builder Flat","Individual"), - ("Commercial - Office", "Commercial"), - ("Commercial - Restaurant", "Commercial"), - ("Commercial - Hotel", "Commercial"), - ("Commercial - Retail", "Commercial"), - ("Builder / Developer", "Commercial"), + ("Residential - Individual", "Individual"), + ("Residential - Builder Flat", "Individual"), + ("Commercial - Office", "Commercial"), + ("Commercial - Restaurant", "Commercial"), + ("Commercial - Hotel", "Commercial"), + ("Commercial - Retail", "Commercial"), + ("Builder / Developer", "Commercial"), ] for name, parent in groups: if not exists("Customer Group", name): - d = frappe.get_doc({ - "doctype": "Customer Group", - "customer_group_name": name, - "parent_customer_group": parent, - "is_group": 0, - }) - if safe_insert(d): ok(f"Customer Group: {name}") - else: skip(f"{name} (dup)") + d = frappe.get_doc( + { + "doctype": "Customer Group", + "customer_group_name": name, + "parent_customer_group": parent, + "is_group": 0, + } + ) + if safe_insert(d): + ok(f"Customer Group: {name}") + else: + skip(f"{name} (dup)") else: skip(f"Customer Group: {name}") frappe.db.commit() @@ -68,26 +79,30 @@ def create_territories(): print("\n[2] Territories...") # (name, parent) territories = [ - ("West Bengal", "India"), - ("Kolkata", "West Bengal"), - ("South Kolkata", "Kolkata"), - ("North Kolkata", "Kolkata"), - ("Salt Lake", "Kolkata"), - ("New Town", "Kolkata"), - ("Rajarhat", "Kolkata"), - ("Howrah", "West Bengal"), - ("Hooghly", "West Bengal"), + ("West Bengal", "India"), + ("Kolkata", "West Bengal"), + ("South Kolkata", "Kolkata"), + ("North Kolkata", "Kolkata"), + ("Salt Lake", "Kolkata"), + ("New Town", "Kolkata"), + ("Rajarhat", "Kolkata"), + ("Howrah", "West Bengal"), + ("Hooghly", "West Bengal"), ] for name, parent in territories: if not exists("Territory", name): - d = frappe.get_doc({ - "doctype": "Territory", - "territory_name": name, - "parent_territory": parent, - "is_group": 0, - }) - if safe_insert(d): ok(f"Territory: {name}") - else: skip(f"{name} (dup)") + d = frappe.get_doc( + { + "doctype": "Territory", + "territory_name": name, + "parent_territory": parent, + "is_group": 0, + } + ) + if safe_insert(d): + ok(f"Territory: {name}") + else: + skip(f"{name} (dup)") else: skip(f"Territory: {name}") frappe.db.commit() @@ -96,45 +111,55 @@ def create_territories(): # ────────────────────────────────────────────────────────────── # 3. LEAD SOURCES (added as custom Select field on Lead) # ────────────────────────────────────────────────────────────── -LEAD_SOURCES = "\n".join([ - "Instagram", - "Facebook", - "Word of Mouth", - "Client Referral", - "Just Dial", - "Housing.com", - "99acres / MagicBricks", - "Site Board / Hoarding", - "Google Search", - "Direct Walk-In", - "Architect / Designer Referral", - "Builder Tie-up", - "Exhibition / Home Fair", - "YouTube", - "WhatsApp Broadcast", -]) +LEAD_SOURCES = "\n".join( + [ + "Instagram", + "Facebook", + "Word of Mouth", + "Client Referral", + "Just Dial", + "Housing.com", + "99acres / MagicBricks", + "Site Board / Hoarding", + "Google Search", + "Direct Walk-In", + "Architect / Designer Referral", + "Builder Tie-up", + "Exhibition / Home Fair", + "YouTube", + "WhatsApp Broadcast", + ] +) + def create_lead_sources(): # Lead Source is a standalone CRM-app doctype not installed here. # We add it as a custom Select field on Lead + Opportunity instead. print("\n[3] Lead Sources (as custom Select field)...") - for dt, after_field in [("Lead", "qualification_status"), ("Opportunity", "opportunity_type")]: + for dt, after_field in [ + ("Lead", "qualification_status"), + ("Opportunity", "opportunity_type"), + ]: cf_name = f"{dt}-furnitex_lead_source" if not exists("Custom Field", cf_name): - d = frappe.get_doc({ - "doctype": "Custom Field", - "dt": dt, - "fieldname": "furnitex_lead_source", - "label": "Lead Source", - "fieldtype": "Select", - "options": LEAD_SOURCES, - "insert_after": after_field, - "in_list_view": 1, - "in_standard_filter": 1, - }) - if safe_insert(d): ok(f"Lead Source Select field → {dt}") - else: skip(f"{dt}.furnitex_lead_source (dup)") + d = frappe.get_doc( + { + "doctype": "Custom Field", + "dt": dt, + "fieldname": "furnitex_lead_source", + "label": "Lead Source", + "fieldtype": "Select", + "options": LEAD_SOURCES, + "insert_after": after_field, + "in_list_view": 1, + "in_standard_filter": 1, + } + ) + if safe_insert(d): + ok(f"Lead Source Select field → {dt}") + else: + skip(f"{dt}.furnitex_lead_source (dup)") else: skip(f"Lead Source field exists on {dt}") frappe.db.commit() @@ -146,21 +171,25 @@ def create_lead_sources(): def create_sales_persons(): print("\n[4] Sales Persons...") persons = [ - ("Furnitex - Site Team", "Sales Team"), - ("Furnitex - Design Team","Sales Team"), + ("Furnitex - Site Team", "Sales Team"), + ("Furnitex - Design Team", "Sales Team"), ("Furnitex - BD Manager", "Sales Team"), ] for name, parent in persons: if not exists("Sales Person", name): - d = frappe.get_doc({ - "doctype": "Sales Person", - "sales_person_name": name, - "parent_sales_person": parent, - "is_group": 0, - "enabled": 1, - }) - if safe_insert(d): ok(f"Sales Person: {name}") - else: skip(f"{name} (dup)") + d = frappe.get_doc( + { + "doctype": "Sales Person", + "sales_person_name": name, + "parent_sales_person": parent, + "is_group": 0, + "enabled": 1, + } + ) + if safe_insert(d): + ok(f"Sales Person: {name}") + else: + skip(f"{name} (dup)") else: skip(f"Sales Person: {name}") frappe.db.commit() @@ -172,36 +201,42 @@ def create_sales_persons(): def create_payment_modes(): print("\n[5] Modes of Payment...") modes = [ - ("NEFT / RTGS", "Bank"), - ("IMPS", "Bank"), - ("Google Pay", "Bank"), - ("PhonePe", "Bank"), - ("Paytm", "Bank"), - ("Cash - Site", "Cash"), + ("NEFT / RTGS", "Bank"), + ("IMPS", "Bank"), + ("Google Pay", "Bank"), + ("PhonePe", "Bank"), + ("Paytm", "Bank"), + ("Cash - Site", "Cash"), ] # find default bank account - bank_acct = frappe.db.get_value("Account", - {"account_type": "Bank", "company": COMPANY, "is_group": 0}, "name") - cash_acct = frappe.db.get_value("Account", - {"account_type": "Cash", "company": COMPANY, "is_group": 0}, "name") + bank_acct = frappe.db.get_value( + "Account", {"account_type": "Bank", "company": COMPANY, "is_group": 0}, "name" + ) + cash_acct = frappe.db.get_value( + "Account", {"account_type": "Cash", "company": COMPANY, "is_group": 0}, "name" + ) for name, mtype in modes: if not exists("Mode of Payment", name): d_dict = { - "doctype": "Mode of Payment", - "mode_of_payment": name, - "type": mtype, + "doctype": "Mode of Payment", + "mode_of_payment": name, + "type": mtype, } acct = bank_acct if mtype == "Bank" else cash_acct if acct: - d_dict["accounts"] = [{ - "company": COMPANY, - "default_account": acct, - }] + d_dict["accounts"] = [ + { + "company": COMPANY, + "default_account": acct, + } + ] d = frappe.get_doc(d_dict) - if safe_insert(d): ok(f"Mode of Payment: {name}") - else: skip(f"{name} (dup)") + if safe_insert(d): + ok(f"Mode of Payment: {name}") + else: + skip(f"{name} (dup)") else: skip(f"Mode of Payment: {name}") frappe.db.commit() @@ -214,62 +249,88 @@ def create_payment_terms(): print("\n[6] Payment Terms Templates...") templates = [ - # ── A: Standard Interior Project (milestone-based) ─────── { "name": "Furnitex - Standard Interior Project", "terms": [ - {"payment_term_name": "30% Advance on Agreement", - "invoice_portion": 30, "credit_days": 0, - "description": "Booking advance on signing agreement"}, - {"payment_term_name": "30% on 50% Site Completion", - "invoice_portion": 30, "credit_days": 30, - "description": "Second milestone: 50% work done"}, - {"payment_term_name": "30% on Work Completion", - "invoice_portion": 30, "credit_days": 60, - "description": "Third milestone: work complete"}, - {"payment_term_name": "10% on Final Handover", - "invoice_portion": 10, "credit_days": 75, - "description": "Retention released at handover"}, + { + "payment_term_name": "30% Advance on Agreement", + "invoice_portion": 30, + "credit_days": 0, + "description": "Booking advance on signing agreement", + }, + { + "payment_term_name": "30% on 50% Site Completion", + "invoice_portion": 30, + "credit_days": 30, + "description": "Second milestone: 50% work done", + }, + { + "payment_term_name": "30% on Work Completion", + "invoice_portion": 30, + "credit_days": 60, + "description": "Third milestone: work complete", + }, + { + "payment_term_name": "10% on Final Handover", + "invoice_portion": 10, + "credit_days": 75, + "description": "Retention released at handover", + }, ], }, - # ── B: 50-50 (smaller projects) ────────────────────────── { "name": "Furnitex - 50-50 Advance", "terms": [ - {"payment_term_name": "50% Advance", - "invoice_portion": 50, "credit_days": 0, - "description": "Advance before work begins"}, - {"payment_term_name": "50% on Delivery", - "invoice_portion": 50, "credit_days": 30, - "description": "Balance on delivery / installation"}, + { + "payment_term_name": "50% Advance", + "invoice_portion": 50, + "credit_days": 0, + "description": "Advance before work begins", + }, + { + "payment_term_name": "50% on Delivery", + "invoice_portion": 50, + "credit_days": 30, + "description": "Balance on delivery / installation", + }, ], }, - # ── C: 100% Advance (small orders / loose furniture) ───── { "name": "Furnitex - 100% Advance", "terms": [ - {"payment_term_name": "100% Advance", - "invoice_portion": 100, "credit_days": 0, - "description": "Full payment before production"}, + { + "payment_term_name": "100% Advance", + "invoice_portion": 100, + "credit_days": 0, + "description": "Full payment before production", + }, ], }, - # ── D: Lumpsum 3-stage (commercial projects) ───────────── { "name": "Furnitex - Commercial 3-Stage", "terms": [ - {"payment_term_name": "40% Advance - Commercial", - "invoice_portion": 40, "credit_days": 0, - "description": "Mobilisation advance"}, - {"payment_term_name": "40% Mid-Stage - Commercial", - "invoice_portion": 40, "credit_days": 45, - "description": "Mid-project milestone"}, - {"payment_term_name": "20% Retention - Commercial", - "invoice_portion": 20, "credit_days": 90, - "description": "Retention on final handover"}, + { + "payment_term_name": "40% Advance - Commercial", + "invoice_portion": 40, + "credit_days": 0, + "description": "Mobilisation advance", + }, + { + "payment_term_name": "40% Mid-Stage - Commercial", + "invoice_portion": 40, + "credit_days": 45, + "description": "Mid-project milestone", + }, + { + "payment_term_name": "20% Retention - Commercial", + "invoice_portion": 20, + "credit_days": 90, + "description": "Retention on final handover", + }, ], }, ] @@ -282,30 +343,38 @@ def create_payment_terms(): for term in t["terms"]: pt_name = term["payment_term_name"] if not exists("Payment Term", pt_name): - pt = frappe.get_doc({ - "doctype": "Payment Term", - "payment_term_name": pt_name, - "invoice_portion": term["invoice_portion"], - "credit_days_based_on": "Day(s) after invoice date", - "credit_days": term["credit_days"], - "description": term["description"], - }) + pt = frappe.get_doc( + { + "doctype": "Payment Term", + "payment_term_name": pt_name, + "invoice_portion": term["invoice_portion"], + "credit_days_based_on": "Day(s) after invoice date", + "credit_days": term["credit_days"], + "description": term["description"], + } + ) safe_insert(pt) - term_rows.append({ - "payment_term": pt_name, - "invoice_portion": term["invoice_portion"], - "credit_days_based_on": "Day(s) after invoice date", - "credit_days": term["credit_days"], - "description": term["description"], - }) + term_rows.append( + { + "payment_term": pt_name, + "invoice_portion": term["invoice_portion"], + "credit_days_based_on": "Day(s) after invoice date", + "credit_days": term["credit_days"], + "description": term["description"], + } + ) - d = frappe.get_doc({ - "doctype": "Payment Terms Template", - "template_name": tname, - "terms": term_rows, - }) - if safe_insert(d): ok(f"Payment Terms: {tname}") - else: skip(f"{tname} (dup)") + d = frappe.get_doc( + { + "doctype": "Payment Terms Template", + "template_name": tname, + "terms": term_rows, + } + ) + if safe_insert(d): + ok(f"Payment Terms: {tname}") + else: + skip(f"{tname} (dup)") else: skip(f"Payment Terms: {tname}") @@ -319,7 +388,6 @@ def create_terms_conditions(): print("\n[7] Terms & Conditions Templates...") templates = [ - # ── Quotation T&C ───────────────────────────────────────── { "title": "Furnitex - Quotation Terms", @@ -337,7 +405,6 @@ def create_terms_conditions():

Furnitex | Interior Design & Furniture | Kolkata

""", }, - # ── Sales Invoice T&C ───────────────────────────────────── { "title": "Furnitex - Invoice Terms", @@ -352,7 +419,6 @@ def create_terms_conditions():

Bank: [Your Bank] | A/c: [Account No] | IFSC: [IFSC] | UPI: [UPI ID]

Thank you for choosing Furnitex!

""", }, - # ── Purchase Order T&C ──────────────────────────────────── { "title": "Furnitex - Purchase Order Terms", @@ -368,13 +434,17 @@ def create_terms_conditions(): for t in templates: if not exists("Terms and Conditions", t["title"]): - d = frappe.get_doc({ - "doctype": "Terms and Conditions", - "title": t["title"], - "terms": t["terms"], - }) - if safe_insert(d): ok(f"T&C: {t['title']}") - else: skip(f"T&C: {t['title']} (dup)") + d = frappe.get_doc( + { + "doctype": "Terms and Conditions", + "title": t["title"], + "terms": t["terms"], + } + ) + if safe_insert(d): + ok(f"T&C: {t['title']}") + else: + skip(f"T&C: {t['title']} (dup)") else: skip(f"T&C: {t['title']}") frappe.db.commit() @@ -388,16 +458,20 @@ def create_price_list(): pl_name = "Furnitex Interior Rate Card" if not exists("Price List", pl_name): - d = frappe.get_doc({ - "doctype": "Price List", - "price_list_name": pl_name, - "currency": "INR", - "selling": 1, - "buying": 0, - "enabled": 1, - }) - if safe_insert(d): ok(f"Price List: {pl_name}") - else: skip(f"{pl_name} (dup)") + d = frappe.get_doc( + { + "doctype": "Price List", + "price_list_name": pl_name, + "currency": "INR", + "selling": 1, + "buying": 0, + "enabled": 1, + } + ) + if safe_insert(d): + ok(f"Price List: {pl_name}") + else: + skip(f"{pl_name} (dup)") else: skip(f"Price List: {pl_name}") @@ -406,16 +480,16 @@ def create_price_list(): # ── Standard Item Prices (on both Standard Selling + Interior Rate Card) ── item_prices = [ # (item_code, rate) — per SqFt or per Nos - ("SVC-FC-EXEC", 185), # False Ceiling / SqFt - ("SVC-WAR-LAM", 950), # Laminate Wardrobe / SqFt - ("SVC-KIT-EXEC", 1100), # Modular Kitchen / SqFt - ("SVC-FLOOR", 120), # Flooring / SqFt - ("SVC-LUMP", 0), # Lumpsum — rate set per project - ("SVC-DESIGN", 15000), # Design consultation / Nos - ("SVC-CONVEY", 2000), # Conveyance / trip - ("SVC-LABOUR", 700), # Labour / day - ("SVC-ELECTRIC", 0), # Electrical — per quote - ("SVC-PAINT", 35), # Painting / SqFt + ("SVC-FC-EXEC", 185), # False Ceiling / SqFt + ("SVC-WAR-LAM", 950), # Laminate Wardrobe / SqFt + ("SVC-KIT-EXEC", 1100), # Modular Kitchen / SqFt + ("SVC-FLOOR", 120), # Flooring / SqFt + ("SVC-LUMP", 0), # Lumpsum — rate set per project + ("SVC-DESIGN", 15000), # Design consultation / Nos + ("SVC-CONVEY", 2000), # Conveyance / trip + ("SVC-LABOUR", 700), # Labour / day + ("SVC-ELECTRIC", 0), # Electrical — per quote + ("SVC-PAINT", 35), # Painting / SqFt ] price_lists = ["Standard Selling", pl_name] @@ -425,17 +499,23 @@ def create_price_list(): for code, rate in item_prices: if not exists("Item", code): continue - if not exists_f("Item Price", {"item_code": code, "price_list": pl, "selling": 1}): - ip = frappe.get_doc({ - "doctype": "Item Price", - "item_code": code, - "price_list": pl, - "selling": 1, - "currency": "INR", - "price_list_rate": rate, - }) - if safe_insert(ip): ok(f"Item Price: {code} ₹{rate} [{pl}]") - else: skip(f"Item Price: {code} [{pl}] (dup)") + if not exists_f( + "Item Price", {"item_code": code, "price_list": pl, "selling": 1} + ): + ip = frappe.get_doc( + { + "doctype": "Item Price", + "item_code": code, + "price_list": pl, + "selling": 1, + "currency": "INR", + "price_list_rate": rate, + } + ) + if safe_insert(ip): + ok(f"Item Price: {code} ₹{rate} [{pl}]") + else: + skip(f"Item Price: {code} [{pl}] (dup)") else: skip(f"Item Price: {code} [{pl}]") @@ -449,81 +529,240 @@ def create_crm_billing_fields(): print("\n[9] CRM & Billing Custom Fields...") fields = [ - # ── CUSTOMER ───────────────────────────────────────────── - ("Customer", "whatsapp_number", "WhatsApp Number", - "Data", None, "mobile_no", 0), - ("Customer", "property_address", "Property / Site Address", - "Small Text", None, "whatsapp_number", 0), - ("Customer", "project_type", "Project Type", - "Select", "Residential\nCommercial\nHospitality\nRetail", "property_address", 0), - ("Customer", "property_size_sqft", "Approx. Area (SqFt)", - "Float", None, "project_type", 0), - ("Customer", "budget_range", "Budget Range", - "Select", - "Under ₹5 Lakhs\n₹5–10 Lakhs\n₹10–25 Lakhs\n₹25–50 Lakhs\nAbove ₹50 Lakhs", - "property_size_sqft", 0), - ("Customer", "referred_by", "Referred By", - "Data", None, "budget_range", 0), - + ( + "Customer", + "whatsapp_number", + "WhatsApp Number", + "Data", + None, + "mobile_no", + 0, + ), + ( + "Customer", + "property_address", + "Property / Site Address", + "Small Text", + None, + "whatsapp_number", + 0, + ), + ( + "Customer", + "project_type", + "Project Type", + "Select", + "Residential\nCommercial\nHospitality\nRetail", + "property_address", + 0, + ), + ( + "Customer", + "property_size_sqft", + "Approx. Area (SqFt)", + "Float", + None, + "project_type", + 0, + ), + ( + "Customer", + "budget_range", + "Budget Range", + "Select", + "Under ₹5 Lakhs\n₹5–10 Lakhs\n₹10–25 Lakhs\n₹25–50 Lakhs\nAbove ₹50 Lakhs", + "property_size_sqft", + 0, + ), + ("Customer", "referred_by", "Referred By", "Data", None, "budget_range", 0), # ── LEAD ───────────────────────────────────────────────── - ("Lead", "whatsapp_number", "WhatsApp Number", - "Data", None, "mobile_no", 0), - ("Lead", "project_type_lead", "Project Type", - "Select", "Residential\nCommercial\nHospitality\nRetail", - "whatsapp_number", 0), - ("Lead", "property_address", "Property / Site Address", - "Small Text", None, "project_type_lead", 0), - ("Lead", "area_sqft", "Approx. Area (SqFt)", - "Float", None, "property_address", 0), - ("Lead", "budget_range", "Budget Range", - "Select", - "Under ₹5 Lakhs\n₹5–10 Lakhs\n₹10–25 Lakhs\n₹25–50 Lakhs\nAbove ₹50 Lakhs", - "area_sqft", 0), - ("Lead", "site_visit_done", "Site Visit Done", - "Check", None, "budget_range", 0), - ("Lead", "site_visit_date", "Site Visit Date", - "Date", None, "site_visit_done", 0), - ("Lead", "estimated_value", "Estimated Project Value (₹)", - "Currency", None, "site_visit_date", 0), - + ("Lead", "whatsapp_number", "WhatsApp Number", "Data", None, "mobile_no", 0), + ( + "Lead", + "project_type_lead", + "Project Type", + "Select", + "Residential\nCommercial\nHospitality\nRetail", + "whatsapp_number", + 0, + ), + ( + "Lead", + "property_address", + "Property / Site Address", + "Small Text", + None, + "project_type_lead", + 0, + ), + ( + "Lead", + "area_sqft", + "Approx. Area (SqFt)", + "Float", + None, + "property_address", + 0, + ), + ( + "Lead", + "budget_range", + "Budget Range", + "Select", + "Under ₹5 Lakhs\n₹5–10 Lakhs\n₹10–25 Lakhs\n₹25–50 Lakhs\nAbove ₹50 Lakhs", + "area_sqft", + 0, + ), + ( + "Lead", + "site_visit_done", + "Site Visit Done", + "Check", + None, + "budget_range", + 0, + ), + ( + "Lead", + "site_visit_date", + "Site Visit Date", + "Date", + None, + "site_visit_done", + 0, + ), + ( + "Lead", + "estimated_value", + "Estimated Project Value (₹)", + "Currency", + None, + "site_visit_date", + 0, + ), # ── QUOTATION ──────────────────────────────────────────── - ("Quotation", "site_address", "Site / Delivery Address", - "Small Text", None, "customer_address", 0), - ("Quotation", "scope_of_work", "Scope of Work", - "Small Text", None, "site_address", 0), - ("Quotation", "site_visit_date", "Site Visit Date", - "Date", None, "scope_of_work", 0), - ("Quotation", "expected_start_date_q", "Expected Start Date", - "Date", None, "site_visit_date", 0), - ("Quotation", "expected_handover_date","Expected Handover Date", - "Date", None, "expected_start_date_q", 0), - ("Quotation", "design_style", "Design Style", - "Select", - "Modern / Contemporary\nTraditional / Classic\nMinimalist\nIndustrial\nBohemian\nMediterranean", - "expected_handover_date", 0), - + ( + "Quotation", + "site_address", + "Site / Delivery Address", + "Small Text", + None, + "customer_address", + 0, + ), + ( + "Quotation", + "scope_of_work", + "Scope of Work", + "Small Text", + None, + "site_address", + 0, + ), + ( + "Quotation", + "site_visit_date", + "Site Visit Date", + "Date", + None, + "scope_of_work", + 0, + ), + ( + "Quotation", + "expected_start_date_q", + "Expected Start Date", + "Date", + None, + "site_visit_date", + 0, + ), + ( + "Quotation", + "expected_handover_date", + "Expected Handover Date", + "Date", + None, + "expected_start_date_q", + 0, + ), + ( + "Quotation", + "design_style", + "Design Style", + "Select", + "Modern / Contemporary\nTraditional / Classic\nMinimalist\nIndustrial\nBohemian\nMediterranean", + "expected_handover_date", + 0, + ), # ── SALES INVOICE ──────────────────────────────────────── # 'project' field is native on Sales Invoice in v16 — no custom field needed. # Adding billing-specific extras after 'customer_name' instead. - ("Sales Invoice", "milestone_description", "Milestone Description", - "Small Text", None, "customer_name", 0), - ("Sales Invoice", "payment_mode_note", "Payment Mode Note", - "Data", None, "milestone_description", 0), - + ( + "Sales Invoice", + "milestone_description", + "Milestone Description", + "Small Text", + None, + "customer_name", + 0, + ), + ( + "Sales Invoice", + "payment_mode_note", + "Payment Mode Note", + "Data", + None, + "milestone_description", + 0, + ), # ── OPPORTUNITY ────────────────────────────────────────── - ("Opportunity", "property_address", "Property / Site Address", - "Small Text", None, "customer_name", 0), - ("Opportunity", "area_sqft", "Area (SqFt)", - "Float", None, "property_address", 0), - ("Opportunity", "design_style", "Design Style", - "Select", - "Modern / Contemporary\nTraditional / Classic\nMinimalist\nIndustrial\nBohemian\nMediterranean", - "area_sqft", 0), - ("Opportunity", "site_visit_done", "Site Visit Done", - "Check", None, "design_style", 0), - ("Opportunity", "site_visit_date", "Site Visit Date", - "Date", None, "site_visit_done", 0), + ( + "Opportunity", + "property_address", + "Property / Site Address", + "Small Text", + None, + "customer_name", + 0, + ), + ( + "Opportunity", + "area_sqft", + "Area (SqFt)", + "Float", + None, + "property_address", + 0, + ), + ( + "Opportunity", + "design_style", + "Design Style", + "Select", + "Modern / Contemporary\nTraditional / Classic\nMinimalist\nIndustrial\nBohemian\nMediterranean", + "area_sqft", + 0, + ), + ( + "Opportunity", + "site_visit_done", + "Site Visit Done", + "Check", + None, + "design_style", + 0, + ), + ( + "Opportunity", + "site_visit_date", + "Site Visit Date", + "Date", + None, + "site_visit_done", + 0, + ), ] for dt, fn, label, ft, opts, after, in_list in [ @@ -532,19 +771,21 @@ def create_crm_billing_fields(): cf_name = f"{dt}-{fn}" if not exists("Custom Field", cf_name): d_dict = { - "doctype": "Custom Field", - "dt": dt, - "fieldname": fn, - "label": label, - "fieldtype": ft, + "doctype": "Custom Field", + "dt": dt, + "fieldname": fn, + "label": label, + "fieldtype": ft, "insert_after": after, "in_list_view": in_list, } if opts: d_dict["options"] = opts d = frappe.get_doc(d_dict) - if safe_insert(d): ok(f"Custom Field: {dt}.{fn}") - else: skip(f"{dt}.{fn} (dup)") + if safe_insert(d): + ok(f"Custom Field: {dt}.{fn}") + else: + skip(f"{dt}.{fn} (dup)") else: skip(f"Custom Field: {dt}.{fn}") @@ -559,29 +800,31 @@ def create_sample_customers(): customers = [ { - "customer_name": "Sharma Residence - Salt Lake", + "customer_name": "Sharma Residence - Salt Lake", "customer_group": "Residential - Individual", - "territory": "Salt Lake", - "customer_type": "Individual", + "territory": "Salt Lake", + "customer_type": "Individual", }, { - "customer_name": "Mukherjee Apartment - Behala", + "customer_name": "Mukherjee Apartment - Behala", "customer_group": "Residential - Builder Flat", - "territory": "South Kolkata", - "customer_type": "Individual", + "territory": "South Kolkata", + "customer_type": "Individual", }, { - "customer_name": "Bansal Office - Park Street", + "customer_name": "Bansal Office - Park Street", "customer_group": "Commercial - Office", - "territory": "Kolkata", - "customer_type": "Company", + "territory": "Kolkata", + "customer_type": "Company", }, ] for c in customers: if not exists("Customer", c["customer_name"]): d = frappe.get_doc({"doctype": "Customer", **c}) - if safe_insert(d): ok(f"Customer: {c['customer_name']}") - else: skip(f"{c['customer_name']} (dup)") + if safe_insert(d): + ok(f"Customer: {c['customer_name']}") + else: + skip(f"{c['customer_name']} (dup)") else: skip(f"Customer: {c['customer_name']}") frappe.db.commit() @@ -594,11 +837,11 @@ def configure_selling_settings(): print("\n[11] Selling & CRM Settings...") try: s = frappe.get_single("Selling Settings") - s.cust_master_name = "Customer Name" - s.customer_group = "Residential - Individual" - s.territory = "Kolkata" - s.price_list = "Standard Selling" - s.selling_price_list = "Standard Selling" + s.cust_master_name = "Customer Name" + s.customer_group = "Residential - Individual" + s.territory = "Kolkata" + s.price_list = "Standard Selling" + s.selling_price_list = "Standard Selling" s.flags.ignore_permissions = True s.save() ok("Selling Settings updated") @@ -607,8 +850,8 @@ def configure_selling_settings(): try: b = frappe.get_single("Buying Settings") - b.supp_master_name = "Supplier Name" - b.supplier_group = "Local Market Vendor (Unregistered)" + b.supp_master_name = "Supplier Name" + b.supplier_group = "Local Market Vendor (Unregistered)" b.flags.ignore_permissions = True b.save() ok("Buying Settings updated") @@ -639,12 +882,16 @@ def create_crm_stages(): for stage in furnitex_stages: if not exists("Sales Stage", stage): try: - d = frappe.get_doc({ - "doctype": "Sales Stage", - "stage_name": stage, - }) - if safe_insert(d): ok(f"Sales Stage: {stage}") - else: skip(f"{stage} (dup)") + d = frappe.get_doc( + { + "doctype": "Sales Stage", + "stage_name": stage, + } + ) + if safe_insert(d): + ok(f"Sales Stage: {stage}") + else: + skip(f"{stage} (dup)") except Exception as e: skip(f"Sales Stage {stage}: {e}") else: @@ -663,19 +910,23 @@ def create_crm_stages(): "Wardrobe / Storage Only", "Flooring Only", ] - for ot in opp_types: - if not exists("Opportunity Type", ot): + for opp_type in opp_types: + if not exists("Opportunity Type", opp_type): try: - d = frappe.get_doc({ - "doctype": "Opportunity Type", - "name": ot, - }) - if safe_insert(d): ok(f"Opportunity Type: {ot}") - else: skip(f"{ot} (dup)") + d = frappe.get_doc( + { + "doctype": "Opportunity Type", + "name": opp_type, + } + ) + if safe_insert(d): + ok(f"Opportunity Type: {opp_type}") + else: + skip(f"{opp_type} (dup)") except Exception as e: - skip(f"Opportunity Type {ot}: {e}") + skip(f"Opportunity Type {opp_type}: {e}") else: - skip(f"Opportunity Type: {ot}") + skip(f"Opportunity Type: {opp_type}") frappe.db.commit() @@ -706,12 +957,13 @@ def create_letter_head(): print("\n[14] Letter Head...") lh_name = "Furnitex" if not exists("Letter Head", lh_name): - d = frappe.get_doc({ - "doctype": "Letter Head", - "letter_head_name": lh_name, - "is_default": 1, - "source": "Rich Text", - "content": """ + d = frappe.get_doc( + { + "doctype": "Letter Head", + "letter_head_name": lh_name, + "is_default": 1, + "source": "Rich Text", + "content": """
@@ -730,13 +982,16 @@ def create_letter_head():
""", - "footer": """ + "footer": """
Furnitex | Interior Design & Furniture | Kolkata | GSTIN: [Your GSTIN] | CIN: [If applicable]
""", - }) - if safe_insert(d): ok(f"Letter Head: {lh_name}") - else: skip(f"Letter Head {lh_name} (dup)") + } + ) + if safe_insert(d): + ok(f"Letter Head: {lh_name}") + else: + skip(f"Letter Head {lh_name} (dup)") else: skip(f"Letter Head: {lh_name}") frappe.db.commit() diff --git a/update_furnitex_info.py b/update_furnitex_info.py index 42937b92..44820139 100644 --- a/update_furnitex_info.py +++ b/update_furnitex_info.py @@ -8,20 +8,20 @@ import frappe COMPANY = "Furnitex" -ADDRESS = "6/A/108 Mukundapur" -CITY = "Kolkata" -STATE = "West Bengal" -PINCODE = "700099" -COUNTRY = "India" -PHONE = "+91 62905 91422" -EMAIL = "info.furnitex@gmail.com" -WEBSITE = "https://furnitex.co.in" -INSTAGRAM = "https://www.instagram.com/frunitex" -LEGAL_NAME = "Furnitex Atelier Pvt. Ltd." -TAGLINE = "Redefine What Surrounds You" -FULL_ADDR = f"{ADDRESS}, {CITY} - {PINCODE}, {STATE}, {COUNTRY}" -REP_NAME = "Subhankar Dhar" -OFFICE_HOURS = "Monday – Saturday, 10:00 AM – 7:00 PM IST" +ADDRESS = "6/A/108 Mukundapur" +CITY = "Kolkata" +STATE = "West Bengal" +PINCODE = "700099" +COUNTRY = "India" +PHONE = "+91 62905 91422" +EMAIL = "info.furnitex@gmail.com" +WEBSITE = "https://furnitex.co.in" +INSTAGRAM = "https://www.instagram.com/frunitex" +LEGAL_NAME = "Furnitex Atelier Pvt. Ltd." +TAGLINE = "Redefine What Surrounds You" +FULL_ADDR = f"{ADDRESS}, {CITY} - {PINCODE}, {STATE}, {COUNTRY}" +REP_NAME = "Subhankar Dhar" +OFFICE_HOURS = "Monday – Saturday, 10:00 AM – 7:00 PM IST" def run(): @@ -39,12 +39,13 @@ def run(): # ── 1. Company record ───────────────────────────────────────────────────────── + def update_company(): co = frappe.get_doc("Company", COMPANY) - co.phone_no = PHONE - co.email = EMAIL - co.website = WEBSITE - co.company_name = COMPANY # keep short name as primary + co.phone_no = PHONE + co.email = EMAIL + co.website = WEBSITE + co.company_name = COMPANY # keep short name as primary co.flags.ignore_permissions = True co.save() print(f" ✓ Company record updated: phone={PHONE}, email={EMAIL}") @@ -52,30 +53,26 @@ def update_company(): # ── 2. Address record ───────────────────────────────────────────────────────── + def update_address(): # Check if a Furnitex address already exists existing = frappe.db.get_value( - "Address", - {"address_title": COMPANY, "address_type": "Billing"}, - "name" + "Address", {"address_title": COMPANY, "address_type": "Billing"}, "name" ) addr_doc = { - "doctype": "Address", - "address_title": COMPANY, - "address_type": "Billing", - "address_line1": ADDRESS, - "city": CITY, - "state": STATE, - "pincode": PINCODE, - "country": COUNTRY, - "phone": PHONE, - "email_id": EMAIL, + "doctype": "Address", + "address_title": COMPANY, + "address_type": "Billing", + "address_line1": ADDRESS, + "city": CITY, + "state": STATE, + "pincode": PINCODE, + "country": COUNTRY, + "phone": PHONE, + "email_id": EMAIL, "is_primary_address": 1, - "links": [{ - "link_doctype": "Company", - "link_name": COMPANY - }] + "links": [{"link_doctype": "Company", "link_name": COMPANY}], } if existing: @@ -120,6 +117,7 @@ LETTER_HEAD_HTML = f""" """ + def update_letter_head(): lh_name = "Furnitex" if frappe.db.exists("Letter Head", lh_name): @@ -130,12 +128,14 @@ def update_letter_head(): doc.save() print(" ✓ Letter Head updated with real contact details") else: - doc = frappe.get_doc({ - "doctype": "Letter Head", - "letter_head_name": lh_name, - "content": LETTER_HEAD_HTML, - "is_default": 1, - }) + doc = frappe.get_doc( + { + "doctype": "Letter Head", + "letter_head_name": lh_name, + "content": LETTER_HEAD_HTML, + "is_default": 1, + } + ) doc.flags.ignore_permissions = True doc.insert() print(" ✓ Letter Head created") @@ -186,11 +186,12 @@ PO_TC = f"""
""" TC_MAP = { - "Furnitex - Quotation T&C": QUOTATION_TC, - "Furnitex - Invoice T&C": INVOICE_TC, + "Furnitex - Quotation T&C": QUOTATION_TC, + "Furnitex - Invoice T&C": INVOICE_TC, "Furnitex - Purchase Order T&C": PO_TC, } + def update_terms_conditions(): for title, content in TC_MAP.items(): if frappe.db.exists("Terms and Conditions", title): @@ -200,14 +201,16 @@ def update_terms_conditions(): doc.save() print(f" ✓ Updated T&C: {title}") else: - doc = frappe.get_doc({ - "doctype": "Terms and Conditions", - "title": title, - "terms": content, - "selling": 1, - "buying": 1, - "hr": 0, - }) + doc = frappe.get_doc( + { + "doctype": "Terms and Conditions", + "title": title, + "terms": content, + "selling": 1, + "buying": 1, + "hr": 0, + } + ) doc.flags.ignore_permissions = True doc.insert() print(f" ✓ Created T&C: {title}") From 6b3be33e9f54fedaa2cd3daef8b98a42b883a227 Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Tue, 16 Jun 2026 15:43:43 +0530 Subject: [PATCH 10/10] style: replace IST abbreviation to pass codespell check codespell flags 'IST' as a misspelling; expand to 'India Time' instead. Co-Authored-By: Claude Sonnet 4.6 --- update_furnitex_info.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/update_furnitex_info.py b/update_furnitex_info.py index 44820139..4adef177 100644 --- a/update_furnitex_info.py +++ b/update_furnitex_info.py @@ -21,7 +21,7 @@ LEGAL_NAME = "Furnitex Atelier Pvt. Ltd." TAGLINE = "Redefine What Surrounds You" FULL_ADDR = f"{ADDRESS}, {CITY} - {PINCODE}, {STATE}, {COUNTRY}" REP_NAME = "Subhankar Dhar" -OFFICE_HOURS = "Monday – Saturday, 10:00 AM – 7:00 PM IST" +OFFICE_HOURS = "Monday – Saturday, 10:00 AM – 7:00 PM (India Time)" def run():