diff --git a/jey_erp/custom/landed_cost_voucher.py b/jey_erp/custom/landed_cost_voucher.py new file mode 100644 index 0000000..db81ac3 --- /dev/null +++ b/jey_erp/custom/landed_cost_voucher.py @@ -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"), + ) diff --git a/jey_erp/custom_fields.py b/jey_erp/custom_fields.py index f91a371..97a5690 100644 --- a/jey_erp/custom_fields.py +++ b/jey_erp/custom_fields.py @@ -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 diff --git a/jey_erp/hooks.py b/jey_erp/hooks.py index 60ccdd1..ca62f7c 100644 --- a/jey_erp/hooks.py +++ b/jey_erp/hooks.py @@ -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", + ] } } diff --git a/jey_erp/public/js/item_landed_cost.js b/jey_erp/public/js/item_landed_cost.js new file mode 100644 index 0000000..60651c8 --- /dev/null +++ b/jey_erp/public/js/item_landed_cost.js @@ -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 } + })); + }, +}); diff --git a/jey_erp/public/js/landed_cost_voucher.js b/jey_erp/public/js/landed_cost_voucher.js new file mode 100644 index 0000000..194c0e8 --- /dev/null +++ b/jey_erp/public/js/landed_cost_voucher.js @@ -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); + }, + }); + }, +}); + +// `_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, + ); + }); + }, +});