Ran the push flow against ERPNext 15.121.6 on a plain site and on a site with India Compliance 15.32.0 (podman, scripts/erpnext-e2e). 15 ignored live tests pass on both. Fixes, each covered by a mock-based test: - load_options no longer fails on a plain site: the India Compliance-only gstin field is dropped on a 417 and retried. - Code-less rows with fractional hours or minutes send stock_uom, since ERPNext defaults it to Nos. - A created document whose total differs from Voiced's (ERPNext's default Banker's Rounding on half-paise ties) is recorded as a conflict, kept as a draft, not submitted and not given a PDF. The message names Commercial Rounding as the fix. - An existing customer address is reused instead of creating a duplicate on every first push. A bare creation order-by is not used with a Dynamic Link filter. - A re-adopted document no longer gets a second copy of the PDF. - Reverse charge: India Compliance rejects is_reverse_charge unless tax rows are negative RCM amounts, so the flag is sent as 0 with a warning that the invoice lands as a normal taxed invoice. - The 16-character name rule is checked locally in mirror mode under India Compliance, and a TDS-only payment is refused locally with the reason. Payment Entry mapping verified: bank_account is the Account name (paid_to), paid and received amounts equal the cash received, allocated_amount is cash plus TDS, and TDS is one positive deductions row. The integration user needs the Accounts User and Sales User roles. Not verified: ERPNext v14 and v16, other India Compliance versions, the Tauri commands and UI against a live site, SEZ and overseas customers, UTGST supplier states, TLS sites, e-invoicing.
198 lines
6.6 KiB
Python
198 lines
6.6 KiB
Python
"""Prepare one ERPNext site for Voiced's live integration tests.
|
|
|
|
Run inside the backend container (see README.md):
|
|
podman exec -i voiced-erp-backend python - <site> [ic] [rounding=commercial] < setup_site.py
|
|
|
|
Creates (idempotently): the Indian company "Voiced Test Co" (abbr VTC, INR), the fiscal year, GST output tax
|
|
accounts, a bank account, a company address, an integration user with API keys, and a few Items/UOMs.
|
|
Prints one line `ENV KEY=VALUE` per value the Rust live tests need. Never run against a real site.
|
|
"""
|
|
import json
|
|
import sys
|
|
import time
|
|
|
|
import frappe
|
|
|
|
SITE = sys.argv[1]
|
|
IC = "ic" in sys.argv[2:]
|
|
# `rounding=commercial` switches System Settings > Rounding Method (the ERPNext default is Banker's Rounding).
|
|
ROUNDING = {"commercial": "Commercial Rounding", "banker": "Banker's Rounding"}.get(
|
|
next((a.split("=", 1)[1] for a in sys.argv[2:] if a.startswith("rounding=")), ""), None
|
|
)
|
|
ABBR = "VTC"
|
|
COMPANY = "Voiced Test Co"
|
|
|
|
frappe.init(site=SITE, sites_path="/home/frappe/frappe-bench/sites")
|
|
frappe.connect()
|
|
frappe.set_user("Administrator")
|
|
|
|
|
|
def out(key, value):
|
|
print(f"ENV {key}={value}")
|
|
|
|
|
|
def exists(doctype, name):
|
|
return bool(frappe.db.exists(doctype, name))
|
|
|
|
|
|
def insert(doc):
|
|
d = frappe.get_doc(doc)
|
|
d.insert(ignore_permissions=True)
|
|
frappe.db.commit()
|
|
return d
|
|
|
|
|
|
# ---- 1. site, optionally India Compliance (before the wizard so it can add its own accounts) ----
|
|
if IC and "india_compliance" not in frappe.get_installed_apps():
|
|
from frappe.installer import install_app
|
|
|
|
install_app("india_compliance", verbose=False)
|
|
frappe.db.commit()
|
|
|
|
# ---- 2. setup wizard ----
|
|
if not frappe.db.get_single_value("System Settings", "setup_complete"):
|
|
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
|
|
|
|
setup_complete(
|
|
{
|
|
"language": "English",
|
|
"country": "India",
|
|
"timezone": "Asia/Kolkata",
|
|
"currency": "INR",
|
|
"full_name": "Voiced Admin",
|
|
"email": "admin@voiced.test",
|
|
"password": "admin",
|
|
"company_name": COMPANY,
|
|
"company_abbr": ABBR,
|
|
"domains": ["Services"],
|
|
"chart_of_accounts": "Standard",
|
|
"fy_start_date": "2026-04-01",
|
|
"fy_end_date": "2027-03-31",
|
|
"bank_account": "HDFC",
|
|
"setup_demo": 0,
|
|
}
|
|
)
|
|
# setup_complete may run its stages on a worker; wait for it.
|
|
for _ in range(120):
|
|
frappe.db.rollback()
|
|
if frappe.db.get_single_value("System Settings", "setup_complete") and exists("Company", COMPANY):
|
|
break
|
|
time.sleep(2)
|
|
frappe.clear_cache()
|
|
frappe.db.commit()
|
|
assert exists("Company", COMPANY), "setup wizard did not create the company"
|
|
|
|
# ---- 3. accounts ----
|
|
duties = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Duties and Taxes", "is_group": 1}, "name")
|
|
assert duties, "Duties and Taxes group not found"
|
|
for head in ("CGST", "SGST", "UTGST", "IGST"):
|
|
name = f"Output Tax {head} - {ABBR}"
|
|
if not exists("Account", name):
|
|
insert(
|
|
{
|
|
"doctype": "Account",
|
|
"account_name": f"Output Tax {head}",
|
|
"parent_account": duties,
|
|
"company": COMPANY,
|
|
"account_type": "Tax",
|
|
"root_type": "Liability",
|
|
"is_group": 0,
|
|
}
|
|
)
|
|
tds = f"TDS Receivable - {ABBR}"
|
|
if not exists("Account", tds):
|
|
parent = frappe.db.get_value("Account", {"company": COMPANY, "account_name": "Current Assets", "is_group": 1}, "name")
|
|
insert(
|
|
{
|
|
"doctype": "Account",
|
|
"account_name": "TDS Receivable",
|
|
"parent_account": parent,
|
|
"company": COMPANY,
|
|
"account_type": "Tax",
|
|
"root_type": "Asset",
|
|
"is_group": 0,
|
|
}
|
|
)
|
|
|
|
# ---- 4. company address (India Compliance needs one with a GSTIN-ish state) ----
|
|
addr_name = f"{COMPANY}-Billing"
|
|
if not exists("Address", addr_name):
|
|
d = {
|
|
"doctype": "Address",
|
|
"address_title": COMPANY,
|
|
"address_type": "Billing",
|
|
"address_line1": "1 Test Street",
|
|
"city": "Mumbai",
|
|
"state": "Maharashtra",
|
|
"pincode": "400001",
|
|
"country": "India",
|
|
"links": [{"link_doctype": "Company", "link_name": COMPANY}],
|
|
}
|
|
if IC:
|
|
d["gst_category"] = "Registered Regular"
|
|
d["gstin"] = "27AAPFU0939F1ZV"
|
|
insert(d)
|
|
|
|
# ---- 5. items / UOMs ----
|
|
for uom in ("Hour", "Minute", "Second"):
|
|
if not exists("UOM", uom):
|
|
insert({"doctype": "UOM", "uom_name": uom})
|
|
for code, uom in (("VOICED-SERVICE", "Nos"), ("VOICED-HOURLY", "Hour")):
|
|
if not exists("Item", code):
|
|
d = {
|
|
"doctype": "Item",
|
|
"item_code": code,
|
|
"item_name": code,
|
|
"item_group": "Services",
|
|
"stock_uom": uom,
|
|
"is_stock_item": 0,
|
|
}
|
|
if IC:
|
|
d["gst_hsn_code"] = "998314"
|
|
insert(d)
|
|
|
|
# ---- 5b. rounding method ----
|
|
if ROUNDING:
|
|
frappe.db.set_single_value("System Settings", "rounding_method", ROUNDING)
|
|
frappe.db.commit()
|
|
frappe.clear_cache()
|
|
|
|
# ---- 6. integration user ----
|
|
USER = f"voiced-integration@{SITE.replace('.', '-')}.test"
|
|
# The role set that was verified live (with and without India Compliance): "Accounts User" creates and submits Sales
|
|
# Invoices and Payment Entries and reads the accounting masters; "Sales User" adds the Customer and Address rights
|
|
# (look up, create) and the tax-template list. Accounts User alone gets HTTP 403 on Customer.
|
|
ROLES = ["Accounts User", "Sales User"]
|
|
if not exists("User", USER):
|
|
insert(
|
|
{
|
|
"doctype": "User",
|
|
"email": USER,
|
|
"first_name": "Voiced",
|
|
"last_name": "Integration",
|
|
"send_welcome_email": 0,
|
|
"user_type": "System User",
|
|
"roles": [{"role": r} for r in ROLES],
|
|
}
|
|
)
|
|
user = frappe.get_doc("User", USER)
|
|
user.set("roles", [{"role": r} for r in ROLES])
|
|
secret = frappe.generate_hash(length=32)
|
|
if not user.api_key:
|
|
user.api_key = frappe.generate_hash(length=15)
|
|
user.api_secret = secret
|
|
user.save(ignore_permissions=True)
|
|
frappe.db.commit()
|
|
frappe.clear_cache(user=USER)
|
|
frappe.clear_cache()
|
|
|
|
out("ERPNEXT_KEY", user.api_key)
|
|
out("ERPNEXT_SECRET", secret)
|
|
out("ERPNEXT_COMPANY", COMPANY)
|
|
out("ERPNEXT_ABBR", ABBR)
|
|
out("ERPNEXT_IC", "1" if IC else "0")
|
|
out("ERPNEXT_USER", USER)
|
|
out("ERPNEXT_ROUNDING", "commercial" if frappe.db.get_single_value("System Settings", "rounding_method") == "Commercial Rounding" else "banker")
|
|
print(json.dumps({"roles": ROLES}))
|
|
frappe.destroy()
|