feat(landed-cost-voucher): item-driven taxes table with vendor invoice import
Replaces the free-text description in Landed Cost Taxes and Charges with a service-item link, drives expense_account from a new Item.default_landed_cost_account, adds an in-form "Get Items from Vendor Invoices" button, and validates totals against vendor invoices on submit. Also fixes ERPNext's no-op items_remove (its `this.trigger` call hits a non-existent method) so applicable_charges recalculate correctly when rows are deleted from items or taxes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
91268cc466
commit
cae1de7682
|
|
@ -0,0 +1,177 @@
|
|||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, flt, fmt_money
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_taxes_from_vendor_invoices(vendor_invoices, company):
|
||||
"""Return service-item rows from the given Purchase Invoices, ready to
|
||||
be appended to the Landed Cost Voucher `taxes` table.
|
||||
|
||||
Service items are identified by Item.is_stock_item = 0. Rows are grouped
|
||||
by (item, expense_account) and amounts are summed in company currency.
|
||||
expense_account falls back to Item.default_landed_cost_account when the
|
||||
Purchase Invoice Item does not specify one.
|
||||
"""
|
||||
if isinstance(vendor_invoices, str):
|
||||
import json
|
||||
vendor_invoices = json.loads(vendor_invoices)
|
||||
|
||||
vendor_invoices = [v for v in (vendor_invoices or []) if v]
|
||||
if not vendor_invoices:
|
||||
return []
|
||||
|
||||
pii = frappe.qb.DocType("Purchase Invoice Item")
|
||||
item = frappe.qb.DocType("Item")
|
||||
rows = (
|
||||
frappe.qb.from_(pii)
|
||||
.left_join(item).on(item.name == pii.item_code)
|
||||
.select(
|
||||
pii.item_code,
|
||||
pii.item_name,
|
||||
pii.description,
|
||||
pii.base_amount,
|
||||
item.default_landed_cost_account,
|
||||
)
|
||||
.where(pii.parent.isin(vendor_invoices))
|
||||
.where(pii.parenttype == "Purchase Invoice")
|
||||
.where(item.is_stock_item == 0)
|
||||
).run(as_dict=True)
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
|
||||
# expense_account is taken strictly from Item.default_landed_cost_account.
|
||||
# We deliberately ignore Purchase Invoice Item's expense_account so the
|
||||
# account in LCV is always controlled from the Item master.
|
||||
#
|
||||
# base_amount/exchange_rate/account_currency are pre-filled so the
|
||||
# standard client-side distribution (set_total_taxes_and_charges →
|
||||
# set_applicable_charges_for_item) works immediately. PI base_amount is
|
||||
# already in company currency, so amount == base_amount and rate == 1.
|
||||
aggregated: dict[tuple, dict] = {}
|
||||
for r in rows:
|
||||
expense_account = r.default_landed_cost_account or None
|
||||
key = (r.item_code, expense_account or "")
|
||||
if key not in aggregated:
|
||||
aggregated[key] = {
|
||||
"item": r.item_code,
|
||||
"description": r.item_name or r.description or r.item_code,
|
||||
"expense_account": expense_account,
|
||||
"amount": 0.0,
|
||||
"base_amount": 0.0,
|
||||
"exchange_rate": 1,
|
||||
"account_currency": company_currency,
|
||||
}
|
||||
amt = flt(r.base_amount)
|
||||
aggregated[key]["amount"] = flt(aggregated[key]["amount"]) + amt
|
||||
aggregated[key]["base_amount"] = flt(aggregated[key]["base_amount"]) + amt
|
||||
|
||||
return list(aggregated.values())
|
||||
|
||||
|
||||
def validate_taxes_rows(doc, method=None):
|
||||
"""Per-row sanity checks on the Landed Cost Taxes and Charges table.
|
||||
Runs on before_submit so drafts can still be saved with incomplete data.
|
||||
|
||||
Catches:
|
||||
- item missing (defence in depth — also enforced by reqd=1);
|
||||
- item not found / disabled;
|
||||
- item is not a service (is_stock_item == 1);
|
||||
- amount not positive;
|
||||
- expense_account missing (means Default Landed Cost Account was not
|
||||
set on the Item — submit must not silently leave the account empty);
|
||||
- duplicate (item, expense_account) rows.
|
||||
"""
|
||||
rows = doc.get("taxes") or []
|
||||
if not rows:
|
||||
frappe.throw(
|
||||
_("Landed Cost table cannot be empty on submit."),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
|
||||
seen_keys: set[tuple] = set()
|
||||
item_cache: dict[str, dict] = {}
|
||||
|
||||
for r in rows:
|
||||
idx = r.idx
|
||||
|
||||
if not r.item:
|
||||
frappe.throw(
|
||||
_("Row {0}: Item is required.").format(idx),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
|
||||
if r.item not in item_cache:
|
||||
item_cache[r.item] = frappe.db.get_value(
|
||||
"Item", r.item,
|
||||
["is_stock_item", "disabled", "item_name"],
|
||||
as_dict=True,
|
||||
) or {}
|
||||
item_data = item_cache[r.item]
|
||||
|
||||
if not item_data:
|
||||
frappe.throw(
|
||||
_("Row {0}: Item {1} does not exist.").format(idx, frappe.bold(r.item)),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
if cint(item_data.get("is_stock_item")) == 1:
|
||||
frappe.throw(
|
||||
_("Row {0}: Item {1} is not a service. Only items with Maintain Stock disabled are allowed in Landed Cost.").format(
|
||||
idx, frappe.bold(r.item),
|
||||
),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
if cint(item_data.get("disabled")) == 1:
|
||||
frappe.throw(
|
||||
_("Row {0}: Item {1} is disabled.").format(idx, frappe.bold(r.item)),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
|
||||
if flt(r.amount) <= 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Amount must be greater than zero.").format(idx),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
|
||||
if not r.expense_account:
|
||||
frappe.throw(
|
||||
_("Row {0}: Expense Account is missing for item {1}. Set Default Landed Cost Account on the Item or pick the account manually.").format(
|
||||
idx, frappe.bold(r.item),
|
||||
),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
|
||||
key = (r.item, r.expense_account)
|
||||
if key in seen_keys:
|
||||
frappe.throw(
|
||||
_("Row {0}: Duplicate combination of Item {1} and Expense Account {2}. Merge the rows or change one of the values.").format(
|
||||
idx, frappe.bold(r.item), frappe.bold(r.expense_account),
|
||||
),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
seen_keys.add(key)
|
||||
|
||||
|
||||
def validate_total_services_against_invoices(doc, method=None):
|
||||
"""Block submit when the sum of services in the taxes table exceeds
|
||||
the total of vendor invoices. Skipped when no vendor invoices are
|
||||
attached (LCV may be built only from purchase_receipts in that case).
|
||||
"""
|
||||
total_invoices = flt(doc.get("total_vendor_invoices_cost") or 0)
|
||||
if not total_invoices:
|
||||
return
|
||||
|
||||
total_services = sum(flt(r.base_amount) for r in (doc.get("taxes") or []))
|
||||
|
||||
# Tolerance for rounding noise in company currency precision.
|
||||
if total_services <= total_invoices + 0.01:
|
||||
return
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", doc.company, "default_currency")
|
||||
frappe.throw(
|
||||
_("Total services in Landed Cost ({0}) cannot exceed Total Vendor Invoices Cost ({1}).").format(
|
||||
fmt_money(total_services, currency=company_currency),
|
||||
fmt_money(total_invoices, currency=company_currency),
|
||||
),
|
||||
title=_("Landed Cost Validation"),
|
||||
)
|
||||
|
|
@ -60,6 +60,57 @@ def _apply_custom_fields(custom_fields: dict, ignore_validate: bool = False) ->
|
|||
finally:
|
||||
frappe.flags.in_create_custom_fields = False
|
||||
|
||||
|
||||
def _apply_property_setters(property_setters: list[dict]) -> None:
|
||||
"""Idempotent upsert for Property Setters.
|
||||
|
||||
Each entry: {doctype, fieldname, property, value, property_type, for_doctype?}.
|
||||
Set `for_doctype=True` for DocType-level properties (e.g. field_order)
|
||||
where `fieldname` is ignored. Reuses Frappe's make_property_setter
|
||||
(idempotent on (doc_type, field_name, property)) and pre-checks the
|
||||
existing value to avoid version churn when nothing changed.
|
||||
"""
|
||||
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||
|
||||
affected_doctypes: set[str] = set()
|
||||
for ps in property_setters:
|
||||
doctype = ps["doctype"]
|
||||
prop = ps["property"]
|
||||
value = ps["value"]
|
||||
property_type = ps.get("property_type", "Data")
|
||||
for_doctype = bool(ps.get("for_doctype", False))
|
||||
fieldname = ps.get("fieldname") or ""
|
||||
|
||||
ps_filters = {"doc_type": doctype, "property": prop}
|
||||
if for_doctype:
|
||||
ps_filters["doctype_or_field"] = "DocType"
|
||||
else:
|
||||
ps_filters["field_name"] = fieldname
|
||||
ps_filters["doctype_or_field"] = "DocField"
|
||||
|
||||
existing_value = frappe.db.get_value("Property Setter", ps_filters, "value")
|
||||
if existing_value is not None and str(existing_value) == str(value):
|
||||
continue
|
||||
|
||||
try:
|
||||
make_property_setter(
|
||||
doctype,
|
||||
fieldname,
|
||||
prop,
|
||||
value,
|
||||
property_type,
|
||||
for_doctype=for_doctype,
|
||||
validate_fields_for_doctype=False,
|
||||
)
|
||||
affected_doctypes.add(doctype)
|
||||
except Exception as e:
|
||||
target = doctype if for_doctype else f"{doctype}.{fieldname}"
|
||||
print(f"Failed to set {prop}={value} on {target}: {e}")
|
||||
|
||||
for doctype in affected_doctypes:
|
||||
frappe.clear_cache(doctype=doctype)
|
||||
|
||||
|
||||
def create_custom_fields():
|
||||
custom_fields = {
|
||||
"Asset": [
|
||||
|
|
@ -1907,6 +1958,46 @@ def create_custom_fields():
|
|||
insert_after='custom_medical_insurance_column_break'
|
||||
),
|
||||
],
|
||||
"Item": [
|
||||
# Default expense account used by Landed Cost Voucher when this
|
||||
# service item is added to the Landed Cost Taxes and Charges table.
|
||||
# Hidden for stock items because LCV taxes are services only.
|
||||
dict(
|
||||
fieldname='default_landed_cost_account',
|
||||
label='Default Landed Cost Account',
|
||||
fieldtype='Link',
|
||||
options='Account',
|
||||
insert_after='is_stock_item',
|
||||
depends_on='eval:doc.is_stock_item==0',
|
||||
description='Used as the Expense Account when this service item is selected in a Landed Cost Voucher.',
|
||||
),
|
||||
],
|
||||
"Landed Cost Voucher": [
|
||||
# In-form button placed right above the Landed Cost (taxes) table,
|
||||
# mirroring the standard `get_items_from_purchase_receipts` button
|
||||
# that sits above the items table.
|
||||
dict(
|
||||
fieldname='get_items_from_vendor_invoices',
|
||||
label='Get Items from Vendor Invoices',
|
||||
fieldtype='Button',
|
||||
insert_after='sec_break1',
|
||||
),
|
||||
],
|
||||
"Landed Cost Taxes and Charges": [
|
||||
# Replaces the standard `description` field as the user-facing
|
||||
# selector. Standard `description` is hidden via Property Setter
|
||||
# and auto-filled from item_name on the client.
|
||||
dict(
|
||||
fieldname='item',
|
||||
label='Item',
|
||||
fieldtype='Link',
|
||||
options='Item',
|
||||
insert_after='description',
|
||||
in_list_view=1,
|
||||
columns=2,
|
||||
reqd=1,
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
# Rename: remove old Cyrillic-prefixed field if it still exists
|
||||
|
|
@ -1956,6 +2047,46 @@ def create_custom_fields():
|
|||
# We don't need version history for system-managed fields anyway.
|
||||
_apply_custom_fields(sanitized, ignore_validate=True)
|
||||
|
||||
# === Property Setters ===
|
||||
# Hide and de-mandate the standard `description` field on
|
||||
# Landed Cost Taxes and Charges. The new custom `item` field replaces it
|
||||
# in the UI; description is auto-filled from item_name on the client so
|
||||
# ERPNext server logic that reads description still works.
|
||||
#
|
||||
# Also pin the field_order: standard ERPNext order is
|
||||
# description, col_break3, amount, expense_account, ...
|
||||
# We want grid columns to read item | expense_account | amount, so we
|
||||
# rearrange to: description, item, col_break3, expense_account, amount,
|
||||
# account_currency, exchange_rate, base_amount,
|
||||
# has_corrective_cost, has_operating_cost.
|
||||
# Frappe respects field-level insert_after only for custom fields; the
|
||||
# only way to reorder standard fields is a DocType-level field_order
|
||||
# Property Setter (see frappe.model.meta.Meta.sort_fields).
|
||||
import json as _json
|
||||
_lctc_field_order = [
|
||||
"description",
|
||||
"item",
|
||||
"col_break3",
|
||||
"expense_account",
|
||||
"amount",
|
||||
"account_currency",
|
||||
"exchange_rate",
|
||||
"base_amount",
|
||||
"has_corrective_cost",
|
||||
"has_operating_cost",
|
||||
]
|
||||
_apply_property_setters([
|
||||
{"doctype": "Landed Cost Taxes and Charges", "fieldname": "description",
|
||||
"property": "hidden", "value": "1", "property_type": "Check"},
|
||||
{"doctype": "Landed Cost Taxes and Charges", "fieldname": "description",
|
||||
"property": "reqd", "value": "0", "property_type": "Check"},
|
||||
{"doctype": "Landed Cost Taxes and Charges", "fieldname": "description",
|
||||
"property": "in_list_view", "value": "0", "property_type": "Check"},
|
||||
{"doctype": "Landed Cost Taxes and Charges", "for_doctype": True,
|
||||
"property": "field_order", "value": _json.dumps(_lctc_field_order),
|
||||
"property_type": "Small Text"},
|
||||
])
|
||||
|
||||
# === Sub-tab wiring for Salary Slip via custom_subtabs ===
|
||||
# The custom_subtabs app reads `js_parent_subtab` on Tab Break fields in meta.
|
||||
# That property only lives on DocField (added by the app's patch). Custom Fields
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ doctype_js = {
|
|||
"Employee": "public/js/employee.js",
|
||||
"Bank Reconciliation Tool": "public/js/bank_reconciliation_tool.js",
|
||||
"Company": "public/js/company_form.js",
|
||||
"Landed Cost Voucher": "public/js/landed_cost_voucher.js",
|
||||
"Item": "public/js/item_landed_cost.js",
|
||||
}
|
||||
|
||||
doctype_list_js = {
|
||||
|
|
@ -77,6 +79,12 @@ doc_events = {
|
|||
"Asset Category": {
|
||||
"validate": "jey_erp.custom.create_asset_categories.prevent_create_new_asset_categories",
|
||||
"on_trash": "jey_erp.custom.create_asset_categories.prevent_delete_system_asset_categories"
|
||||
},
|
||||
"Landed Cost Voucher": {
|
||||
"before_submit": [
|
||||
"jey_erp.custom.landed_cost_voucher.validate_taxes_rows",
|
||||
"jey_erp.custom.landed_cost_voucher.validate_total_services_against_invoices",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
frappe.ui.form.on('Item', {
|
||||
setup(frm) {
|
||||
// Default Landed Cost Account: only non-group accounts.
|
||||
frm.set_query('default_landed_cost_account', () => ({
|
||||
filters: { is_group: 0 }
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
function recalculate_landed_cost(frm) {
|
||||
// Recompute total_taxes_and_charges from base_amount on each tax row.
|
||||
let total = 0;
|
||||
(frm.doc.taxes || []).forEach(d => { total += flt(d.base_amount); });
|
||||
frm.doc.total_taxes_and_charges = total;
|
||||
frm.refresh_field('total_taxes_and_charges');
|
||||
|
||||
const items = frm.doc.items || [];
|
||||
if (!items.length) return;
|
||||
|
||||
const based_on = (frm.doc.distribute_charges_based_on || '').toLowerCase();
|
||||
// Manual mode — don't touch applicable_charges, user owns them.
|
||||
if (based_on === 'distribute manually') return;
|
||||
|
||||
// Empty taxes → ERPNext's set_applicable_charges_for_item bails out and
|
||||
// leaves stale applicable_charges on items. We zero them ourselves.
|
||||
if (total === 0) {
|
||||
items.forEach(it => { it.applicable_charges = 0; });
|
||||
frm.refresh_field('items');
|
||||
return;
|
||||
}
|
||||
|
||||
let total_item_cost = 0;
|
||||
items.forEach(d => { total_item_cost += flt(d[based_on]); });
|
||||
|
||||
if (total_item_cost === 0) {
|
||||
items.forEach(it => { it.applicable_charges = 0; });
|
||||
frm.refresh_field('items');
|
||||
return;
|
||||
}
|
||||
|
||||
let total_charges = 0;
|
||||
items.forEach(it => {
|
||||
it.applicable_charges = flt(
|
||||
(flt(it[based_on]) * total) / total_item_cost,
|
||||
precision('applicable_charges', it),
|
||||
);
|
||||
total_charges += it.applicable_charges;
|
||||
});
|
||||
// Push the rounding remainder onto the last row to keep the totals aligned.
|
||||
if (total_charges !== total) {
|
||||
items[items.length - 1].applicable_charges += (total - total_charges);
|
||||
}
|
||||
frm.refresh_field('items');
|
||||
}
|
||||
|
||||
frappe.ui.form.on('Landed Cost Voucher', {
|
||||
setup(frm) {
|
||||
// Restrict the new `item` field on the taxes table to service items.
|
||||
frm.set_query('item', 'taxes', () => ({
|
||||
filters: { is_stock_item: 0, disabled: 0 }
|
||||
}));
|
||||
},
|
||||
|
||||
get_items_from_vendor_invoices(frm) {
|
||||
// In-form button placed above the Landed Cost (taxes) table.
|
||||
// Pulls service-item rows from the Purchase Invoices listed in
|
||||
// vendor_invoices and replaces the current taxes rows.
|
||||
const invoices = (frm.doc.vendor_invoices || [])
|
||||
.map(r => r.vendor_invoice)
|
||||
.filter(Boolean);
|
||||
|
||||
if (!invoices.length) {
|
||||
frappe.msgprint(__('Please add Vendor Invoices first.'));
|
||||
return;
|
||||
}
|
||||
if (!frm.doc.company) {
|
||||
frappe.msgprint(__('Please set Company first.'));
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.call({
|
||||
method: 'jey_erp.custom.landed_cost_voucher.get_taxes_from_vendor_invoices',
|
||||
args: { vendor_invoices: invoices, company: frm.doc.company },
|
||||
freeze: true,
|
||||
freeze_message: __('Fetching services from Vendor Invoices...'),
|
||||
callback: (r) => {
|
||||
const data = r.message || [];
|
||||
if (!data.length) {
|
||||
frappe.msgprint(__('No service items found in the selected Vendor Invoices.'));
|
||||
return;
|
||||
}
|
||||
frm.clear_table('taxes');
|
||||
data.forEach(row => {
|
||||
const child = frm.add_child('taxes');
|
||||
Object.assign(child, row);
|
||||
});
|
||||
frm.refresh_field('taxes');
|
||||
recalculate_landed_cost(frm);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// `<tablefield>_remove` fires with the CHILD doctype as target (see
|
||||
// frappe/public/js/frappe/form/grid_row.js → script_manager.trigger), so
|
||||
// the handler must live on the child doctype, not the parent. ERPNext's
|
||||
// own items_remove() on the LCV class is a no-op because it calls a
|
||||
// non-existent `this.trigger`, leaving applicable_charges stale.
|
||||
frappe.ui.form.on('Landed Cost Item', {
|
||||
items_remove(frm) {
|
||||
recalculate_landed_cost(frm);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on('Landed Cost Taxes and Charges', {
|
||||
taxes_remove(frm) {
|
||||
recalculate_landed_cost(frm);
|
||||
},
|
||||
|
||||
item(frm, cdt, cdn) {
|
||||
const row = locals[cdt][cdn];
|
||||
if (!row.item) return;
|
||||
|
||||
frappe.db.get_value('Item', row.item, [
|
||||
'item_name', 'is_stock_item', 'default_landed_cost_account',
|
||||
]).then(({ message }) => {
|
||||
if (!message) return;
|
||||
if (cint(message.is_stock_item) === 1) {
|
||||
frappe.msgprint(__('Item {0} is not a service.', [row.item]));
|
||||
frappe.model.set_value(cdt, cdn, 'item', null);
|
||||
return;
|
||||
}
|
||||
// Description is hidden in the UI but still required by core
|
||||
// server logic, so we always sync it from item_name.
|
||||
if (message.item_name) {
|
||||
frappe.model.set_value(cdt, cdn, 'description', message.item_name);
|
||||
}
|
||||
// expense_account is driven solely by Item.default_landed_cost_account
|
||||
// — overwrite whatever ERPNext auto-populated (item-master/company
|
||||
// default) so the account is always controlled from the Item master.
|
||||
frappe.model.set_value(
|
||||
cdt, cdn, 'expense_account',
|
||||
message.default_landed_cost_account || null,
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue