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:
Ali 2026-05-06 16:01:41 +00:00
parent 91268cc466
commit cae1de7682
5 changed files with 462 additions and 0 deletions

View File

@ -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"),
)

View File

@ -60,6 +60,57 @@ def _apply_custom_fields(custom_fields: dict, ignore_validate: bool = False) ->
finally: finally:
frappe.flags.in_create_custom_fields = False 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(): def create_custom_fields():
custom_fields = { custom_fields = {
"Asset": [ "Asset": [
@ -1907,6 +1958,46 @@ def create_custom_fields():
insert_after='custom_medical_insurance_column_break' 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 # 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. # We don't need version history for system-managed fields anyway.
_apply_custom_fields(sanitized, ignore_validate=True) _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 === # === Sub-tab wiring for Salary Slip via custom_subtabs ===
# The custom_subtabs app reads `js_parent_subtab` on Tab Break fields in meta. # 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 # That property only lives on DocField (added by the app's patch). Custom Fields

View File

@ -28,6 +28,8 @@ doctype_js = {
"Employee": "public/js/employee.js", "Employee": "public/js/employee.js",
"Bank Reconciliation Tool": "public/js/bank_reconciliation_tool.js", "Bank Reconciliation Tool": "public/js/bank_reconciliation_tool.js",
"Company": "public/js/company_form.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 = { doctype_list_js = {
@ -77,6 +79,12 @@ doc_events = {
"Asset Category": { "Asset Category": {
"validate": "jey_erp.custom.create_asset_categories.prevent_create_new_asset_categories", "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" "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",
]
} }
} }

View File

@ -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 }
}));
},
});

View File

@ -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,
);
});
},
});