feat: default Shipping Rule per Thai company
"Shipping Charges - <abbr>": Selling, Fixed, posting to a "Shipping Charges" income account under Direct Income (created if the chart lacks it) with the default cost center. Shipping Rule is named by its label, so the company abbreviation keeps one rule per company, as ERPNext names its tax templates. Created on Company save, after install, and by a patch for existing companies. The amount is entered on the transaction, not the rule: ERPNext re-applies the rule on every recalculation and would reset the charge row to the rule's fixed amount. ThaiShippingRule overrides the doctype class so a Fixed rule with no amount only seeds the row and leaves the entered amount alone.
This commit is contained in:
@@ -139,8 +139,12 @@ fixtures = [
|
|||||||
# ------------
|
# ------------
|
||||||
|
|
||||||
# before_install = "default_thai_company.install.before_install"
|
# before_install = "default_thai_company.install.before_install"
|
||||||
# Fixtures are synced before this runs; VAT schemes for companies that already exist.
|
# Fixtures are synced before this runs; VAT schemes and Shipping Rules for companies
|
||||||
after_install = "default_thai_company.vat.setup_companies"
|
# that already exist.
|
||||||
|
after_install = [
|
||||||
|
"default_thai_company.vat.setup_companies",
|
||||||
|
"default_thai_company.shipping.setup_companies",
|
||||||
|
]
|
||||||
# Fixture import re-inserts the Location tree; restore lft/rgt for user-added nodes.
|
# Fixture import re-inserts the Location tree; restore lft/rgt for user-added nodes.
|
||||||
after_migrate = "default_thai_company.assets.rebuild_locations"
|
after_migrate = "default_thai_company.assets.rebuild_locations"
|
||||||
|
|
||||||
@@ -191,6 +195,7 @@ after_migrate = "default_thai_company.assets.rebuild_locations"
|
|||||||
override_doctype_class = {
|
override_doctype_class = {
|
||||||
"Sales Invoice": "default_thai_company.tax_withholding.ThaiSalesInvoice",
|
"Sales Invoice": "default_thai_company.tax_withholding.ThaiSalesInvoice",
|
||||||
"Payment Entry": "default_thai_company.tax_withholding.ThaiPaymentEntry",
|
"Payment Entry": "default_thai_company.tax_withholding.ThaiPaymentEntry",
|
||||||
|
"Shipping Rule": "default_thai_company.shipping.ThaiShippingRule",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Document Events
|
# Document Events
|
||||||
@@ -213,6 +218,7 @@ doc_events = {
|
|||||||
"default_thai_company.tax_withholding.setup_company",
|
"default_thai_company.tax_withholding.setup_company",
|
||||||
"default_thai_company.vat.setup_company",
|
"default_thai_company.vat.setup_company",
|
||||||
"default_thai_company.assets.setup_company",
|
"default_thai_company.assets.setup_company",
|
||||||
|
"default_thai_company.shipping.setup_company",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"Payment Entry": {
|
"Payment Entry": {
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
# Patches added in this section will be executed after doctypes are migrated
|
# Patches added in this section will be executed after doctypes are migrated
|
||||||
default_thai_company.patches.create_vat_accounts
|
default_thai_company.patches.create_vat_accounts
|
||||||
default_thai_company.patches.create_vat_templates
|
default_thai_company.patches.create_vat_templates
|
||||||
|
default_thai_company.patches.create_shipping_rules
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from default_thai_company.shipping import setup_companies
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
setup_companies()
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import erpnext
|
||||||
|
import frappe
|
||||||
|
from erpnext.accounts.doctype.shipping_rule.shipping_rule import ShippingRule
|
||||||
|
|
||||||
|
from default_thai_company.tax_withholding import company_ready, get_or_create_account, thai_companies
|
||||||
|
|
||||||
|
# Shipping Rule is named by its label (site-wide unique) but bound to one company,
|
||||||
|
# so each Thai company gets "<LABEL> - <abbr>", the way ERPNext names accounts and
|
||||||
|
# tax templates. The label doubles as the charge description on the transaction.
|
||||||
|
LABEL = "Shipping Charges"
|
||||||
|
|
||||||
|
# Shipping billed to customers is revenue; the courier's bill stays an expense.
|
||||||
|
SHIPPING_ACCOUNT = "Shipping Charges"
|
||||||
|
SHIPPING_ACCOUNT_GROUPS = ("Direct Income", "Income")
|
||||||
|
|
||||||
|
|
||||||
|
class ThaiShippingRule(ShippingRule):
|
||||||
|
def add_shipping_rule_to_tax_table(self, doc, shipping_amount):
|
||||||
|
"""A Fixed rule without an amount only seeds the charge row; the amount is
|
||||||
|
entered on the transaction. ERPNext re-applies the rule on every
|
||||||
|
recalculation, which would otherwise reset the row to 0."""
|
||||||
|
manual = self.calculate_based_on == "Fixed" and not self.shipping_amount
|
||||||
|
entered = [(row, row.tax_amount) for row in doc.get("taxes")] if manual else ()
|
||||||
|
super().add_shipping_rule_to_tax_table(doc, shipping_amount)
|
||||||
|
for row, amount in entered:
|
||||||
|
row.tax_amount = amount
|
||||||
|
|
||||||
|
|
||||||
|
def shipping_rule_name(company):
|
||||||
|
abbr = frappe.get_cached_value("Company", company, "abbr")
|
||||||
|
return f"{LABEL} - {abbr}"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_shipping_rule(company):
|
||||||
|
"""Selling Shipping Rule for `company` with the amount entered per
|
||||||
|
transaction (see ThaiShippingRule). No-op when the rule exists."""
|
||||||
|
name = shipping_rule_name(company)
|
||||||
|
if frappe.db.exists("Shipping Rule", name):
|
||||||
|
return
|
||||||
|
frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Shipping Rule",
|
||||||
|
"label": name,
|
||||||
|
"company": company,
|
||||||
|
"shipping_rule_type": "Selling",
|
||||||
|
"calculate_based_on": "Fixed",
|
||||||
|
"shipping_amount": 0,
|
||||||
|
"account": get_or_create_account(
|
||||||
|
company, SHIPPING_ACCOUNT, "Income", "Income Account", SHIPPING_ACCOUNT_GROUPS
|
||||||
|
),
|
||||||
|
"cost_center": erpnext.get_default_cost_center(company),
|
||||||
|
}
|
||||||
|
).insert(ignore_permissions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_company(doc, method=None):
|
||||||
|
"""Company.on_update: default Shipping Rule for a Thai company."""
|
||||||
|
if company_ready(doc):
|
||||||
|
ensure_shipping_rule(doc.name)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_companies():
|
||||||
|
"""after_install and the create_shipping_rules patch: companies that exist
|
||||||
|
before this code did never pass through `setup_company`."""
|
||||||
|
for company in thai_companies():
|
||||||
|
ensure_shipping_rule(company)
|
||||||
@@ -177,6 +177,39 @@ class TestTaxWithholding(FrappeTestCase):
|
|||||||
doctype,
|
doctype,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_company_creation_adds_shipping_rule(self):
|
||||||
|
rule = frappe.get_doc("Shipping Rule", f"Shipping Charges - {ABBR}")
|
||||||
|
self.assertEqual(
|
||||||
|
(rule.company, rule.shipping_rule_type, rule.calculate_based_on, rule.account, rule.cost_center),
|
||||||
|
(COMPANY, "Selling", "Fixed", f"Shipping Charges - {ABBR}", f"Main - {ABBR}"),
|
||||||
|
)
|
||||||
|
self.assertEqual(frappe.db.get_value("Account", rule.account, "root_type"), "Income")
|
||||||
|
|
||||||
|
def test_shipping_amount_entered_on_transaction_survives_recalculation(self):
|
||||||
|
rule = f"Shipping Charges - {ABBR}"
|
||||||
|
so = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Sales Order",
|
||||||
|
"company": COMPANY,
|
||||||
|
"customer": CUSTOMER,
|
||||||
|
"transaction_date": nowdate(),
|
||||||
|
"delivery_date": nowdate(),
|
||||||
|
"shipping_rule": rule,
|
||||||
|
"items": [{"item_code": ITEM, "qty": 1, "rate": 1000}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
so.set_missing_values()
|
||||||
|
so.apply_shipping_rule()
|
||||||
|
(charge,) = so.taxes
|
||||||
|
self.assertEqual((charge.description, charge.tax_amount), (rule, 0))
|
||||||
|
|
||||||
|
charge.tax_amount = 150
|
||||||
|
so.insert()
|
||||||
|
so.apply_shipping_rule()
|
||||||
|
so.save()
|
||||||
|
self.assertEqual([t.tax_amount for t in so.taxes], [150])
|
||||||
|
self.assertEqual(so.grand_total, 1150)
|
||||||
|
|
||||||
def test_fixture_reimport_keeps_site_account_and_relinks(self):
|
def test_fixture_reimport_keeps_site_account_and_relinks(self):
|
||||||
alt = frappe.get_doc(
|
alt = frappe.get_doc(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user