diff --git a/delete_streetwok.py b/delete_streetwok.py new file mode 100644 index 00000000..6018f46c --- /dev/null +++ b/delete_streetwok.py @@ -0,0 +1,244 @@ +""" +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/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 new file mode 100644 index 00000000..0f8d7f19 --- /dev/null +++ b/setup_furnitex.py @@ -0,0 +1,922 @@ +""" +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" +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 + + +# ───────────────────────────────────────────────────────────── +# 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: + 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}") + + +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_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() + + +# ───────────────────────────────────────────────────────────── +# 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, + } + ) + 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() + + +# ───────────────────────────────────────────────────────────── +# 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, + } + ) + 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() + + +# ───────────────────────────────────────────────────────────── +# 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" + ) + if not root_wh: + root_wh = f"All Warehouses - {abbr}" + + warehouses = [ + ("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, + } + ) + 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() + + +# ───────────────────────────────────────────────────────────── +# 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...") + # ERPNext stores tax templates as "{title} - {abbr}", so we check by title+company + + # ── 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 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_title}") + + # ── GST 18% Purchase ── + 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%", + } + ) + 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, + } + ) + 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_title}") + + # ── GST 18% Sales ── + 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%", + } + ) + 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, + } + ) + 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_title}") + + 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): + # 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 + } + ) + if safe_insert(d): + ok(f"Service Item: {code} [{uom}]") + else: + skip(f"Service Item: {code} (duplicate)") + 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 - {get_abbr()}" + 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", + } + # 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) + if safe_insert(d): + ok(f"Raw Material: {code} [{uom}]") + else: + skip(f"Raw Material: {code} (duplicate)") + 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", + } + ) + if safe_insert(d): + ok(f"Supplier: {name} [{gst_cat}]") + else: + skip(f"Supplier: {name} (duplicate)") + else: + skip(f"Supplier: {name}") + + frappe.db.commit() + + # 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") + + +# ───────────────────────────────────────────────────────────── +# 9. CUSTOM FIELDS +# ───────────────────────────────────────────────────────────── + + +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, + ), + ("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, + "in_standard_filter": 1, + "search_index": 1, + } + 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"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" + ) + + +# ───────────────────────────────────────────────────────────── +# 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" +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 group for this company +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() + # No frappe.db.commit() — Frappe manages the transaction in server scripts + 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 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.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": "Projects", + "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() diff --git a/setup_furnitex_crm_billing.py b/setup_furnitex_crm_billing.py new file mode 100644 index 00000000..459d8112 --- /dev/null +++ b/setup_furnitex_crm_billing.py @@ -0,0 +1,1029 @@ +""" +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": """
Furnitex | Interior Design & Furniture | Kolkata
""", + }, + # ── Sales Invoice T&C ───────────────────────────────────── + { + "title": "Furnitex - Invoice Terms", + "terms": """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": """
+ FURNITEX++ INTERIOR DESIGN & FURNITURE MANUFACTURING + + |
+
+ Kolkata, West Bengal — India + 📞 +91 XXXXX XXXXX + ✉ info@furnitex.in + 🌐 www.furnitex.in + |
+
|
+
+ FURNITEX
+
+
+ {TAGLINE}
+
+
+ {LEGAL_NAME}
+
+ |
+
+ {ADDRESS}
+ {CITY} - {PINCODE}, {STATE}
+ Phone: {PHONE}
+ Email: {EMAIL}
+ Web: furnitex.co.in
+ |
+