From 5ebb3bc8fbcbc80deb3abcb2683ea0db59f1e8eb Mon Sep 17 00:00:00 2001 From: SUBHANKAR DHAR Date: Fri, 12 Jun 2026 16:03:47 +0530 Subject: [PATCH] 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()