diff --git a/inventory_tools/docs/assets/inline_lc_change_category_of_non_lc.png b/inventory_tools/docs/assets/inline_lc_change_category_of_non_lc.png new file mode 100644 index 00000000..5eeb4e5a Binary files /dev/null and b/inventory_tools/docs/assets/inline_lc_change_category_of_non_lc.png differ diff --git a/inventory_tools/docs/assets/inline_lc_dont_distribute.png b/inventory_tools/docs/assets/inline_lc_dont_distribute.png new file mode 100644 index 00000000..547ef557 Binary files /dev/null and b/inventory_tools/docs/assets/inline_lc_dont_distribute.png differ diff --git a/inventory_tools/docs/assets/inline_lc_pr_with_lc.png b/inventory_tools/docs/assets/inline_lc_pr_with_lc.png new file mode 100644 index 00000000..5b1e5fc9 Binary files /dev/null and b/inventory_tools/docs/assets/inline_lc_pr_with_lc.png differ diff --git a/inventory_tools/docs/assets/inventory_tools_settings_inline_lc.png b/inventory_tools/docs/assets/inventory_tools_settings_inline_lc.png new file mode 100644 index 00000000..05e9ee23 Binary files /dev/null and b/inventory_tools/docs/assets/inventory_tools_settings_inline_lc.png differ diff --git a/inventory_tools/docs/index.md b/inventory_tools/docs/index.md index 01a0a619..5809595a 100644 --- a/inventory_tools/docs/index.md +++ b/inventory_tools/docs/index.md @@ -6,7 +6,7 @@ The Inventory Tools application enhances and extends inventory-related functiona - **[UOM Enforcement](./uom_enforcement.md)**: for doctypes that have an Items table or Unit of Measure (UOM) fields, this feature restricts the user's options from arbitrary selections to only UOMs defined in the Item master with a specified conversion factor - **[Warehouse Path](./warehouse_path.md)**: for any warehouse selection field, this features helps clearly identify warehouses by creating a warehouse path and adding a human-readable string under the warehouse name in the format "parent warehouse(s)->warehouse" - **[Subcontracting Workflow via Work Order](./wo_subcontracting.md)**: an alternative to ERPNext's subcontracting workflow that enables a user to employ Work Orders, subcontracting Purchase Orders, and manufacturing Stock Entries in lieu of Purchase Receipts or Subcontracting Orders/Receipts. Enhancements to the subcontracting Purchase Invoice allow a user to quickly reconcile what Items have been received with what is being invoiced -- **[Inline Landed Costing](./landed_costing.md)**: Coming soon! This features enables a user to include any additional costs to be capitalized into an Item's valuation directly in a Purchase Receipt or Purchase Invoice without needing to create a separate Landed Cost Voucher +- **[Inline Landed Costing](./landed_costing.md)**: This features enables a user to directly include any additional costs to be capitalized into an Item's valuation in a Purchase Receipt or Purchase Invoice without needing to create a separate Landed Cost Voucher - **[Manufacturing Capacity](./manufacturing_capacity.md)**: a report-based interface to show, for a given BOM, the entire hierarchy of any BOM tree containing that BOM with demand and in-stock quantities for all levels ## Configuration diff --git a/inventory_tools/docs/landed_costing.md b/inventory_tools/docs/landed_costing.md index 99f316fe..2c607e5f 100644 --- a/inventory_tools/docs/landed_costing.md +++ b/inventory_tools/docs/landed_costing.md @@ -1,3 +1,28 @@ # Inline Landed Costing -Coming soon! +This features enables a user to directly include any additional costs to be capitalized into an Item's valuation in a Purchase Receipt or Purchase Invoice without needing to create a separate Landed Cost Voucher. + +By default, this feature is turned off, but may be toggled on by checking the Enable Inline Landed Costing checkbox in the Landed Costing Section in the Inventory Tools Settings document. As with all Inventory Tools Settings, these are set on a per-company basis. + +![Screen shot of the Inventory Tools Settings document for Ambrosia Pie Company showing the Enable Landed Costing box checked.](./assets/inventory_tools_settings_inline_lc.png) + +When the feature is on, the Purchase Receipt and Purchase Invoice documents will show an additional dropdown field called Distribute Landed Cost Charges Based On above the Items table. If there are no landed costs in the document, then the default Don't Distribute should remain selected. To include and distribute landed costs, the landed costs should be entered as row(s) into the Purchase Taxes and Charges table. The method to distribute landed costs may be based on the relative items' Qty or Amount. + +![Screen shot of a Purchase Receipt showing Distribute Charges Based On selection of Amount. There's one row in the Purchase Taxes and Charges table for landed costs of $10.00. The items table has additional columns showing the split of the landed costs based on the item amounts.](./assets/inline_lc_pr_with_lc.png) + +Note that including landed costs in an item's valuation only works when it is marked as an asset or stock item in its Item master. + +The feature assumes all rows in the Purchase Taxes and Charges table should be included and distributed as landed costs. If there are any rows that should be excluded (such as sales tax or a discount), then the user can click on the Edit field for the row, and change the Consider Tax or Charge For field to "Total". + +![Screen shot of the edit form for a row in the Purchase Taxes and Charges table where the Consider Tax or Charge For field dropdown selection is being changed from Valuation and Total to Total since that row is not a landed cost.](./assets/inline_lc_change_category_of_non_lc.png) + + +## Avoid Double-Counting Landed Costs in a Purchase Invoice + +In the event that a user includes landed costs in a Purchase Receipt, then creates a Purchase Invoice from that document, some adjustments are necessary to make sure the landed costs from the Purchase Receipt aren't included a second time in the item's valuation in the Purchase Invoice. + +Set the Distribute Landed Cost Charges Based On selection to Don't Distribute. This change will remove landed costs from the items since they're already included via the Purchase Receipt. Under the hood, it resets the Consider Tax or Charge For field for each row in the Purchase Taxes and Charges table to Total. If that value were Valuation and Total, it would flag the row's amount to be included in the items' valuation rates. If this change isn't made, then the landed costs would be included a second time in the item valuation rates via the Purchase Invoice, thus double-counting them. + +Note that changing the selection in the Distribute Landed Cost Charges Based On field back to either Qty or Amount will update all rows in the Purchase Taxes and Charges table so they're included in item landed costs and flipping it back to Don't Distribute will excludes all rows from item landed costs. + +![Screen shot of the Distribute Landed Cost Charges Based On drop down selection set to Don't Distribute.](./assets/inline_lc_dont_distribute.png) diff --git a/inventory_tools/hooks.py b/inventory_tools/hooks.py index a794150b..4db87146 100644 --- a/inventory_tools/hooks.py +++ b/inventory_tools/hooks.py @@ -34,6 +34,7 @@ "Work Order": "public/js/work_order_custom.js", "Purchase Order": "public/js/purchase_order_custom.js", "Purchase Invoice": "public/js/purchase_invoice_custom.js", + "Purchase Receipt": "public/js/purchase_receipt_custom.js", "Stock Entry": "public/js/stock_entry_custom.js", "Job Card": "public/js/job_card_custom.js", "Operation": "public/js/operation_custom.js", diff --git a/inventory_tools/inventory_tools/custom/purchase_invoice.json b/inventory_tools/inventory_tools/custom/purchase_invoice.json index 8a52ad1b..f2ac7a83 100644 --- a/inventory_tools/inventory_tools/custom/purchase_invoice.json +++ b/inventory_tools/inventory_tools/custom/purchase_invoice.json @@ -1,5 +1,125 @@ { "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:44:28.153477", + "default": "Don't Distribute", + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "distribute_charges_based_on", + "fieldtype": "Select", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 37, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "scan_barcode", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Distribute Landed Cost Charges Based On", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:44:28.153477", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Invoice-distribute_charges_based_on", + "no_copy": 0, + "non_negative": 0, + "options": "Don't Distribute\nQty\nAmount", + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 1, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:44:28.612035", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "total_with_landed_costs", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 55, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "total", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Total with Landed Costs", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:44:28.612035", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Invoice-total_with_landed_costs", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null + }, { "_assign": null, "_comments": null, diff --git a/inventory_tools/inventory_tools/custom/purchase_invoice_item.json b/inventory_tools/inventory_tools/custom/purchase_invoice_item.json index 985fae4d..bd0d56ea 100644 --- a/inventory_tools/inventory_tools/custom/purchase_invoice_item.json +++ b/inventory_tools/inventory_tools/custom/purchase_invoice_item.json @@ -1,32 +1,213 @@ { - "custom_fields": [], - "custom_perms": [], - "doctype": "Purchase Invoice Item", - "links": [], - "property_setters": [ + "custom_fields": [ { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "creation": "2023-06-26 15:52:33.804721", - "default_value": null, - "doc_type": "Purchase Invoice Item", - "docstatus": 0, - "doctype_or_field": "DocField", - "field_name": "from_warehouse", - "idx": 0, - "is_system_generated": 0, - "modified": "2023-06-26 15:52:33.804721", - "modified_by": "Administrator", - "module": "Inventory Tools", - "name": "Purchase Invoice Item-from_warehouse-hidden", - "owner": "Administrator", - "property": "hidden", - "property_type": "Check", - "row_name": null, - "value": "1" + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:48:15.824952", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "landed_costs", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 35, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Landed Costs", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:48:15.824952", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Invoice Item-landed_costs", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:48:16.126952", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "item_total", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 36, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "landed_costs", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Item Total", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:48:16.126952", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Invoice Item-item_total", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:48:16.377764", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "is_stock_item", + "fieldtype": "Check", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 72, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "is_fixed_asset", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Is Stock Item", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:48:16.377764", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Invoice Item-is_stock_item", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null } - ], - "sync_on_migrate": 1 + ], + "custom_perms": [], + "doctype": "Purchase Invoice Item", + "links": [], + "property_setters": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:17.032365", + "default_value": null, + "doc_type": "Purchase Invoice Item", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "from_warehouse", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:17.032365", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Invoice Item-from_warehouse-hidden", + "owner": "Administrator", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "1" + } + ], + "sync_on_migrate": 1 } \ No newline at end of file diff --git a/inventory_tools/inventory_tools/custom/purchase_receipt.json b/inventory_tools/inventory_tools/custom/purchase_receipt.json new file mode 100644 index 00000000..55acbf40 --- /dev/null +++ b/inventory_tools/inventory_tools/custom/purchase_receipt.json @@ -0,0 +1,337 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:26:26.032004", + "default": "Don't Distribute", + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Receipt", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "distribute_charges_based_on", + "fieldtype": "Select", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 31, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "scan_barcode", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Distribute Landed Cost Charges Based On", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:26:26.032004", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-distribute_charges_based_on", + "no_copy": 0, + "non_negative": 0, + "options": "Don't Distribute\nQty\nAmount", + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 1, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:29:12.180959", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Receipt", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "total_with_landed_costs", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 49, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "total", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Total with Landed Costs", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:29:12.180959", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-total_with_landed_costs", + "no_copy": 0, + "non_negative": 0, + "options": "currency", + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Purchase Receipt", + "links": [], + "property_setters": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:16.682805", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "scan_barcode", + "idx": 0, + "is_system_generated": 1, + "modified": "2023-08-23 12:37:16.682805", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-scan_barcode-hidden", + "owner": "Administrator", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "0" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:13.730920", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "provisional_expense_account", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:13.730920", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-provisional_expense_account-hidden", + "owner": "Administrator", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "1" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:06.860515", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "in_words", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:06.860515", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-in_words-print_hide", + "owner": "Administrator", + "property": "print_hide", + "property_type": "Check", + "row_name": null, + "value": "0" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:06.847176", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "in_words", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:06.847176", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-in_words-hidden", + "owner": "Administrator", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "0" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:06.664487", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "disable_rounded_total", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:06.664487", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-disable_rounded_total-default", + "owner": "Administrator", + "property": "default", + "property_type": "Text", + "row_name": null, + "value": "0" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:06.652381", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "rounded_total", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:06.652381", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-rounded_total-print_hide", + "owner": "Administrator", + "property": "print_hide", + "property_type": "Check", + "row_name": null, + "value": "0" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:06.642880", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "rounded_total", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:06.642880", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-rounded_total-hidden", + "owner": "Administrator", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "0" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:06.633184", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "base_rounded_total", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:06.633184", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-base_rounded_total-print_hide", + "owner": "Administrator", + "property": "print_hide", + "property_type": "Check", + "row_name": null, + "value": "1" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:06.623195", + "default_value": null, + "doc_type": "Purchase Receipt", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "base_rounded_total", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:06.623195", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt-base_rounded_total-hidden", + "owner": "Administrator", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "0" + } + ], + "sync_on_migrate": 1 +} \ No newline at end of file diff --git a/inventory_tools/inventory_tools/custom/purchase_receipt_item.json b/inventory_tools/inventory_tools/custom/purchase_receipt_item.json new file mode 100644 index 00000000..42ef6ca6 --- /dev/null +++ b/inventory_tools/inventory_tools/custom/purchase_receipt_item.json @@ -0,0 +1,236 @@ +{ + "custom_fields": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:35:33.763123", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Receipt Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "landed_costs", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 45, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "amount", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Landed Costs", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:35:33.763123", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt Item-landed_costs", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:35:34.083410", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Receipt Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "item_total", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 46, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "landed_costs", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Item Total", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:35:34.083410", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt Item-item_total", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "creation": "2023-08-25 14:35:34.350108", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Receipt Item", + "fetch_from": "item_code.is_stock_item", + "fetch_if_empty": 0, + "fieldname": "is_stock_item", + "fieldtype": "Check", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 75, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "is_fixed_asset", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Is Stock Item", + "length": 0, + "mandatory_depends_on": null, + "modified": "2023-08-25 14:35:34.350108", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt Item-is_stock_item", + "no_copy": 0, + "non_negative": 0, + "options": null, + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Purchase Receipt Item", + "links": [], + "property_setters": [ + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:17.066347", + "default_value": null, + "doc_type": "Purchase Receipt Item", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "from_warehouse", + "idx": 0, + "is_system_generated": 0, + "modified": "2023-08-23 12:37:17.066347", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt Item-from_warehouse-hidden", + "owner": "Administrator", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "1" + }, + { + "_assign": null, + "_comments": null, + "_liked_by": null, + "_user_tags": null, + "creation": "2023-08-23 12:37:16.378153", + "default_value": null, + "doc_type": "Purchase Receipt Item", + "docstatus": 0, + "doctype_or_field": "DocField", + "field_name": "barcode", + "idx": 0, + "is_system_generated": 1, + "modified": "2023-08-23 12:37:16.378153", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Receipt Item-barcode-hidden", + "owner": "Administrator", + "property": "hidden", + "property_type": "Check", + "row_name": null, + "value": "0" + } + ], + "sync_on_migrate": 1 +} \ No newline at end of file diff --git a/inventory_tools/inventory_tools/doctype/inventory_tools_settings/inventory_tools_settings.json b/inventory_tools/inventory_tools/doctype/inventory_tools_settings/inventory_tools_settings.json index aa7da3a7..12dc0573 100644 --- a/inventory_tools/inventory_tools/doctype/inventory_tools_settings/inventory_tools_settings.json +++ b/inventory_tools/inventory_tools/doctype/inventory_tools_settings/inventory_tools_settings.json @@ -26,8 +26,11 @@ "section_break_0", "update_warehouse_path", "section_break_gzcbr", + "update_warehouse_path", "uoms_section", - "enforce_uoms" + "enforce_uoms", + "landed_costing_section", + "enable_inline_landed_costing" ], "fields": [ { @@ -78,20 +81,16 @@ "label": "Create Purchase Orders in Production Plan" }, { - "fieldname": "section_break_0", - "fieldtype": "Section Break" - }, + "fieldname": "section_break_gzcbr", + "fieldtype": "Section Break", + "label": "Warehouses" + }, { "default": "0", "fieldname": "update_warehouse_path", "fieldtype": "Check", "label": "Update Warehouse Path" }, - { - "fieldname": "section_break_gzcbr", - "fieldtype": "Section Break", - "label": "Warehouses" - }, { "fieldname": "uoms_section", "fieldtype": "Section Break", @@ -146,6 +145,17 @@ "fieldtype": "Link", "label": "Aggregated Sales Warehouse", "options": "Warehouse" + }, + { + "fieldname": "landed_costing_section", + "fieldtype": "Section Break", + "label": "Landed Costing" + }, + { + "default": "0", + "fieldname": "enable_inline_landed_costing", + "fieldtype": "Check", + "label": "Enable Inline Landed Costing" } ], "index_web_pages_for_search": 1, diff --git a/inventory_tools/inventory_tools/overrides/landed_costing.py b/inventory_tools/inventory_tools/overrides/landed_costing.py new file mode 100644 index 00000000..eed25ce3 --- /dev/null +++ b/inventory_tools/inventory_tools/overrides/landed_costing.py @@ -0,0 +1,98 @@ +import frappe +from erpnext.stock.get_item_details import get_conversion_factor +from frappe import _ +from frappe.utils import flt + + +@frappe.whitelist() +def update_valuation_rate(self, reset_outgoing_rate=True): + """ + Common code for InventoryToolsPurchaseReceipt and InventoryToolsPurchaseInvoice. This function + overrides the BuyingController's (mutual parent to PR and PI) class function, called in + `validate()`. + + item_tax_amount is the total allocated tax amount applied on item and stored for valuation. Tax + amounts are only allocated when the tax.category attribute is either 'Valuation' or 'Valuation + and Total' + + :param self: expects class instance of either a Purchase Receipt or Purchase Invoice document + :param reset_outgoing_rate: bool + :return: Nonetype + """ + stock_and_asset_items = [] + stock_and_asset_items = self.get_stock_items() + self.get_asset_items() + + stock_and_asset_items_qty, stock_and_asset_items_amount = 0, 0 + last_item_idx = 1 + for d in self.get("items"): + if d.item_code and d.item_code in stock_and_asset_items: + stock_and_asset_items_qty += flt(d.qty) + stock_and_asset_items_amount += flt(d.base_net_amount) + last_item_idx = d.idx + + # CUSTOM CODE BEGIN + based_on = self.get("distribute_charges_based_on") + total_valuation_amount = sum( + flt(d.base_tax_amount_after_discount_amount) + for d in self.get("taxes") + if d.category in ["Valuation", "Valuation and Total"] + ) + div_by_zero_flag = (based_on == "Qty" and not stock_and_asset_items_qty) or ( + based_on == "Amount" and not stock_and_asset_items_amount + ) + + valuation_amount_adjustment = total_valuation_amount + for i, item in enumerate(self.get("items")): + if item.item_code and item.qty and item.item_code in stock_and_asset_items: + if div_by_zero_flag: + field = "Accepted Quantity" if based_on == "Qty" else "Amount" + frappe.throw( + _(f"{field} values can't total zero when distributing charges based on {based_on}") + ) + if based_on == "Don't Distribute": + item.item_tax_amount = 0.0 + else: + item_proportion = ( + flt(item.qty) / stock_and_asset_items_qty + if based_on == "Qty" + else flt(item.base_net_amount) / stock_and_asset_items_amount + ) + + if i == (last_item_idx - 1): + item.item_tax_amount = flt( + valuation_amount_adjustment, self.precision("item_tax_amount", item) + ) + else: + item.item_tax_amount = flt( + item_proportion * total_valuation_amount, self.precision("item_tax_amount", item) + ) + valuation_amount_adjustment -= item.item_tax_amount + + self.round_floats_in(item) + if flt(item.conversion_factor) == 0.0: + item.conversion_factor = ( + get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 + ) + + qty_in_stock_uom = flt(item.qty * item.conversion_factor) + if self.get("is_old_subcontracting_flow"): + item.rm_supp_cost = self.get_supplied_items_cost( + item.name, reset_outgoing_rate + ) # in subcontracting_controller.py + item.valuation_rate = ( + item.base_net_amount + + item.item_tax_amount + + item.rm_supp_cost + + flt(item.landed_cost_voucher_amount) + ) / qty_in_stock_uom + # TODO: add branch to handle subcontracting workflow to update valuation depending on settings [include raw materials value / get from stock entry?] + # CUSTOM CODE END + else: + item.valuation_rate = ( + item.base_net_amount + + item.item_tax_amount + + flt(item.landed_cost_voucher_amount) + + flt(item.get("rate_difference_with_purchase_invoice")) + ) / qty_in_stock_uom + else: + item.valuation_rate = 0.0 diff --git a/inventory_tools/inventory_tools/overrides/purchase_invoice.py b/inventory_tools/inventory_tools/overrides/purchase_invoice.py index 5c645256..09e4460a 100644 --- a/inventory_tools/inventory_tools/overrides/purchase_invoice.py +++ b/inventory_tools/inventory_tools/overrides/purchase_invoice.py @@ -6,9 +6,17 @@ import frappe from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice +from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + check_if_return_invoice_linked_with_payment_entry, + unlink_inter_company_doc, + update_linked_doc, +) +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from frappe import _ from frappe.utils.data import cint +from inventory_tools.inventory_tools.overrides.landed_costing import update_valuation_rate + class InventoryToolsPurchaseInvoice(PurchaseInvoice): def validate_with_previous_doc(self): @@ -61,17 +69,27 @@ def validate(self): def on_submit(self): if self.is_work_order_subcontracting_enabled() and self.is_subcontracted: self.on_submit_save_se_paid_qty() - return super().on_submit() + if not self.is_inline_lc_enabled(): + return super().on_submit() + else: + self.on_submit_parent_with_lc_changes() def on_cancel(self): if self.is_work_order_subcontracting_enabled() and self.is_subcontracted: self.on_cancel_revert_se_paid_qty() - return super().on_cancel() + if not self.is_inline_lc_enabled(): + return super().on_cancel() + else: + self.on_cancel_parent_with_lc_changes() def is_work_order_subcontracting_enabled(self): settings = frappe.get_doc("Inventory Tools Settings", {"company": self.company}) return bool(settings and settings.enable_work_order_subcontracting) + def is_inline_lc_enabled(self): + settings = frappe.get_doc("Inventory Tools Settings", {"company": self.company}) + return bool(settings and settings.enable_inline_landed_costing) + def validate_subcontracting_to_pay_qty(self): # Checks the qty the invoice will cover is not more than the outstanding qty for subc in self.get("subcontracting"): @@ -89,6 +107,71 @@ def on_submit_save_se_paid_qty(self): "Stock Entry Detail", ste.se_detail_name, "paid_qty", ste.paid_qty + ste.to_pay_qty ) + def on_submit_parent_with_lc_changes(self): + """ + Function is a copy/modification of ERPNext's PurchaseInvoice on_submit class function (to + accommodate the Inline Landed Costing feature changes) and why super calls the current + class's parent + """ + super(PurchaseInvoice, self).on_submit() + + self.check_prev_docstatus() + self.update_status_updater_args() + self.update_prevdoc_status() + + frappe.get_doc("Authorization Control").validate_approving_authority( + self.doctype, self.company, self.base_grand_total + ) + + if not self.is_return: + self.update_against_document_in_jv() + self.update_billing_status_for_zero_amount_refdoc("Purchase Receipt") + self.update_billing_status_for_zero_amount_refdoc("Purchase Order") + + self.update_billing_status_in_pr() + + # Updating stock ledger should always be called after updating prevdoc status, + # because updating ordered qty in bin depends upon updated ordered qty in PO + if self.update_stock == 1: + self.update_stock_ledger() + + if self.is_old_subcontracting_flow: + self.set_consumed_qty_in_subcontract_order() + + from erpnext.stock.doctype.serial_no.serial_no import update_serial_nos_after_submit + + update_serial_nos_after_submit(self, "items") + + # CUSTOM CODE START + # There are additional landed costs to update item valuations, but 'Update Stock' is unchecked + # (because items were received via Purchase Receipts) + # Collect unique purchase receipt names from items (in case more than one PR covered by PI) + prs = {item.purchase_receipt for item in self.get("items") if item.purchase_receipt} + if ( + (not self.update_stock) + and prs + and (self.distribute_charges_based_on != "Don't Distribute") + and (self.total_taxes_and_charges) + ): + self.update_landed_cost(prs) + # CUSTOM CODE END + + # this sequence because outstanding may get -negative + self.make_gl_entries() + + if self.update_stock == 1: + self.repost_future_sle_and_gle() + + if ( + frappe.db.get_single_value("Buying Settings", "project_update_frequency") == "Each Transaction" + ): + self.update_project() + + update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference) + self.update_advance_tax_references() + + self.process_common_party_accounting() + def on_cancel_revert_se_paid_qty(self): # Reduces the Stock Entry Detail item's paid_qty by the to_pay_qty amount in the invoice for ste in self.get("subcontracting"): @@ -97,6 +180,209 @@ def on_cancel_revert_se_paid_qty(self): "Stock Entry Detail", ste.se_detail_name, "paid_qty", cur_paid - ste.to_pay_qty ) + def on_cancel_parent_with_lc_changes(self): + """ + Function is a copy/modification of ERPNext's PurchaseInvoice on_cancel class function (to + accommodate the Inline Landed Costing feature changes) and why super calls the current + class's parent + """ + check_if_return_invoice_linked_with_payment_entry(self) + + super(PurchaseInvoice, self).on_cancel() + + self.check_on_hold_or_closed_status() + + self.update_status_updater_args() + self.update_prevdoc_status() + + if not self.is_return: + self.update_billing_status_for_zero_amount_refdoc("Purchase Receipt") + self.update_billing_status_for_zero_amount_refdoc("Purchase Order") + + self.update_billing_status_in_pr() + + # Updating stock ledger should always be called after updating prevdoc status, + # because updating ordered qty in bin depends upon updated ordered qty in PO + if self.update_stock == 1: + self.update_stock_ledger() + self.delete_auto_created_batches() + + if self.is_old_subcontracting_flow: + self.set_consumed_qty_in_subcontract_order() + + # CUSTOM CODE START + prs = {item.purchase_receipt for item in self.get("items") if item.purchase_receipt} + if ( + (not self.update_stock) + and prs + and (self.distribute_charges_based_on != "Don't Distribute") + and (self.total_taxes_and_charges) + ): + self.update_landed_cost(prs, on_cancel=True) + # CUSTOM CODE END + + self.make_gl_entries_on_cancel() + + if self.update_stock == 1: + self.repost_future_sle_and_gle() + + if ( + frappe.db.get_single_value("Buying Settings", "project_update_frequency") == "Each Transaction" + ): + self.update_project() + self.db_set("status", "Cancelled") + + unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference) + self.ignore_linked_doctypes = ( + "GL Entry", + "Stock Ledger Entry", + "Repost Item Valuation", + "Repost Payment Ledger", + "Repost Payment Ledger Items", + "Repost Accounting Ledger", + "Repost Accounting Ledger Items", + "Unreconcile Payment", + "Unreconcile Payment Entries", + "Payment Ledger Entry", + "Tax Withheld Vouchers", + ) + self.update_advance_tax_references(cancel=1) + + def update_valuation_rate(self, reset_outgoing_rate=True): + if self.is_inline_lc_enabled(): + update_valuation_rate(self, reset_outgoing_rate) + else: + super().update_valuation_rate(reset_outgoing_rate) + + def update_landed_cost(self, prs, on_cancel=False): + """ + Function is a copy/modification of ERPNext's LandedCostVoucher update_landed_cost class + function (to accommodate the Inline Landed Costing feature changes). Adds landed costs to + underlying Purchase Receipts. + """ + # Create a lookup dictionary by PR and item code to tax amount (field used to save LC/item): {PR_name: {item_code: item.item_tax_amount}} + lc_per_item_dict = {pr: {} for pr in prs} + for item in self.get("items"): + if item.purchase_receipt: + lc_per_item_dict[item.purchase_receipt][item.item_code] = item.item_tax_amount + + # Save LC/item into each PR item, update item valuation rate, update database + for pr in prs: + doc = frappe.get_doc("Purchase Receipt", pr) + # Set (on_submit) or remove (on_cancel) landed_cost_voucher_amount in item + for item in doc.get("items"): + lcvamt = lc_per_item_dict[pr][item.item_code] + if not on_cancel: + item.landed_cost_voucher_amount = lc_per_item_dict[pr][item.item_code] + else: + item.landed_cost_voucher_amount = 0.0 + + doc.update_valuation_rate(reset_outgoing_rate=False) + + for item in doc.get("items"): + item.db_update() + + # asset rate will be updated while creating asset gl entries from PI or PY + + # update latest valuation rate in serial no + self.update_rate_in_serial_no_for_non_asset_items(doc) + + # Store landed cost charges in GL dict for PR + lc_from_pi = self.get_pr_lc_gl_entries(prs, on_cancel=on_cancel) + + # Update stock ledger and GL entries + for pr in prs: + # Replicate LCV flow + doc = frappe.get_doc("Purchase Receipt", pr) + # update stock & gl entries for cancelled state of PR + doc.docstatus = 2 + doc.update_stock_ledger( + allow_negative_stock=True, via_landed_cost_voucher=False + ) # In buying_controller.py, ultimately calls make_sl_entries in stock_ledger.py + doc.make_gl_entries_on_cancel() + + # update stock & gl entries for submit state of PR + doc.docstatus = 1 + doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=False) + doc.make_gl_entries(lc_from_pi=lc_from_pi[pr]) + doc.repost_future_sle_and_gle() + + def get_pr_lc_gl_entries(self, prs, on_cancel=False): + """ + Builds dict to hold GL-formatted entries for the landed costs to pass along to PR to + properly capture tax account credit(s) against the debit to Stock on Hand + + :param pr_dict: dict, holds names of purchase receipt documents covered by invoice + return: dict, formatted as follows: + + { + pr1_name: {lc_gl_format}, + pr2_name: {lc_gl_format} + } + + Where lc_gl_format matches what's returned by PR's get_item_account_wise_additional_cost: + { + ('item code', 'item name'): {'Account for LC charges': {'amount': 10.0, 'base_amount': 10.0}} + } + amount and base_amount per item is prorated for each account used in the taxes table + + Example: + {'PREC-RET-10000': {('6201', '6f89743482'): {'Expenses Included In Valuation - *': {'amount': 10.0, 'base_amount': 10.0}}}} + """ + if on_cancel: + return {pr: {} for pr in prs} + + lc_from_pi = {} + based_on_field = frappe.scrub(self.distribute_charges_based_on) + + # Collect total Amount or Qty from PI to prorate LC charges by account head (if multiple) by PR + total_item_cost = sum( + item.get(based_on_field) for item in self.get("items") if item.item_tax_amount + ) + + for pr in prs: + doc = frappe.get_doc("Purchase Receipt", pr) + item_account_wise_cost = {} + + for item in doc.items: # Loop over PR items but then PI taxes + if not item.landed_cost_voucher_amount: + continue + + for account in self.taxes: + # For each tax account in PI taxes, allocate same ratio as how total LCs were split by item + item_account_wise_cost.setdefault((item.item_code, item.name), {}) + item_account_wise_cost[(item.item_code, item.name)].setdefault( + account.account_head, {"amount": 0.0, "base_amount": 0.0} + ) + + item_account_wise_cost[(item.item_code, item.name)][account.account_head]["amount"] += ( + account.tax_amount_after_discount_amount * item.get(based_on_field) / total_item_cost + ) + + item_account_wise_cost[(item.item_code, item.name)][account.account_head]["base_amount"] += ( + account.base_tax_amount_after_discount_amount * item.get(based_on_field) / total_item_cost + ) + + lc_from_pi[pr] = item_account_wise_cost + + return lc_from_pi + + def update_rate_in_serial_no_for_non_asset_items(self, receipt_document): + """ + Function copied from LandedCostVoucher class in landed_cost_voucher.py to accommodate + inline landed costs in a Purchase Invoice + """ + for item in receipt_document.get("items"): + if not item.is_fixed_asset and item.serial_no: + serial_nos = get_serial_nos(item.serial_no) + if serial_nos: + frappe.db.sql( + "update `tabSerial No` set purchase_rate=%s where name in ({})".format( + ", ".join(["%s"] * len(serial_nos)) + ), + tuple([item.valuation_rate] + serial_nos), + ) + @frappe.whitelist() def get_stock_entries(purchase_orders, from_date=None, to_date=None): diff --git a/inventory_tools/inventory_tools/overrides/purchase_receipt.py b/inventory_tools/inventory_tools/overrides/purchase_receipt.py index b6a2d2cb..74e72c45 100644 --- a/inventory_tools/inventory_tools/overrides/purchase_receipt.py +++ b/inventory_tools/inventory_tools/overrides/purchase_receipt.py @@ -3,9 +3,21 @@ import json +import erpnext import frappe -from erpnext.stock.doctype.purchase_receipt.purchase_receipt import PurchaseReceipt -from frappe.utils.data import cint +from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries +from erpnext.accounts.utils import get_account_currency +from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled +from erpnext.stock import get_warehouse_account_map +from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( + PurchaseReceipt, + get_stock_value_difference, + update_regional_gl_entries, +) +from frappe import _ +from frappe.utils.data import cint, flt + +from inventory_tools.inventory_tools.overrides.landed_costing import update_valuation_rate class InventoryToolsPurchaseReceipt(PurchaseReceipt): @@ -35,3 +47,464 @@ def validate_with_previous_doc(self): self.validate_rate_with_reference_doc( [["Purchase Order", "purchase_order", "purchase_order_item"]] ) + + def is_work_order_subcontracting_enabled(self): + settings = frappe.get_doc("Inventory Tools Settings", {"company": self.company}) + return bool(settings and settings.enable_work_order_subcontracting) + + def is_inline_lc_enabled(self): + settings = frappe.get_doc("Inventory Tools Settings", {"company": self.company}) + return bool(settings and settings.enable_inline_landed_costing) + + def update_valuation_rate(self, reset_outgoing_rate=True): + if self.is_inline_lc_enabled(): + update_valuation_rate(self, reset_outgoing_rate) + else: + super().update_valuation_rate(reset_outgoing_rate) + + def set_landed_cost_voucher_amount(self): + """ + Function modified from buying_controller.py to to accommodate inline landed costs in a + linked Purchase Invoice + """ + if not self.is_inline_lc_enabled(): + super().set_landed_cost_voucher_amount() + else: + for d in self.get("items"): + lc_voucher_data = frappe.db.sql( + """select sum(applicable_charges), cost_center + from `tabLanded Cost Item` + where docstatus = 1 and purchase_receipt_item = %s""", + d.name, + ) + # CUSTOM CODE START + d.landed_cost_voucher_amount = ( + lc_voucher_data[0][0] if (lc_voucher_data and lc_voucher_data[0][0]) else 0.0 + ) + # CUSTOM CODE END + if not d.cost_center and lc_voucher_data and lc_voucher_data[0][1]: + d.db_set("cost_center", lc_voucher_data[0][1]) + + def make_gl_entries(self, gl_entries=None, from_repost=False, lc_from_pi=None): + """ + Function modified from stock_controller.py to accommodate revised function signature. + New parameter lc_from_pi is None unless Enable Inline Landed Costing feature is set + and landed costs are included in a Purchase Invoice. + """ + if self.docstatus == 2: + make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) + + provisional_accounting_for_non_stock_items = cint( + frappe.get_cached_value( + "Company", self.company, "enable_provisional_accounting_for_non_stock_items" + ) + ) + + is_asset_pr = any(d.get("is_fixed_asset") for d in self.get("items")) + + if ( + cint(erpnext.is_perpetual_inventory_enabled(self.company)) + or provisional_accounting_for_non_stock_items + or is_asset_pr + ): + warehouse_account = get_warehouse_account_map(self.company) + + if self.docstatus == 1: + if not gl_entries: + # CUSTOM CODE START + gl_entries = self.get_gl_entries(warehouse_account=warehouse_account, lc_from_pi=lc_from_pi) + # CUSTOM CODE END + make_gl_entries(gl_entries, from_repost=from_repost) + + def get_gl_entries(self, warehouse_account=None, lc_from_pi=None): + """ + Function modified from ERPNext's PurchaseReceipt class in purchase_receipt.py to + accommodate revised function signature. + New parameter lc_from_pi is None unless Enable Inline Landed Costing feature is set and + landed costs are included in a Purchase Invoice. + """ + from erpnext.accounts.general_ledger import process_gl_map + + gl_entries = [] + + # CUSTOM CODE START + self.make_item_gl_entries(gl_entries, warehouse_account=warehouse_account, lc_from_pi=lc_from_pi) + # CUSTOM CODE END + self.make_tax_gl_entries(gl_entries) + update_regional_gl_entries(gl_entries, self) + + return process_gl_map(gl_entries) + + def make_item_gl_entries(self, gl_entries, warehouse_account=None, lc_from_pi=None): + """ + Function copied/modified from ERPNext's PurchaseReceipt class in purchase_receipt.py to + accommodate revised function signature. + New parameter lc_from_pi is None unless Enable Inline Landed Costing feature is set and + landed costs are included in a Purchase Invoice + """ + from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import ( + get_purchase_document_details, + ) + + provisional_accounting_for_non_stock_items = cint( + frappe.db.get_value( + "Company", self.company, "enable_provisional_accounting_for_non_stock_items" + ) + ) + + exchange_rate_map, net_rate_map = get_purchase_document_details(self) + + def validate_account(account_type): + frappe.throw(_("{0} account not found while submitting purchase receipt").format(account_type)) + + def make_item_asset_inward_gl_entry(item, stock_value_diff, stock_asset_account_name): + account_currency = get_account_currency(stock_asset_account_name) + + if not stock_asset_account_name: + validate_account("Asset or warehouse account") + + self.add_gl_entry( + gl_entries=gl_entries, + account=stock_asset_account_name, + cost_center=d.cost_center, + debit=stock_value_diff, + credit=0.0, + remarks=remarks, + against_account=stock_asset_rbnb, + account_currency=account_currency, + item=item, + ) + + def make_stock_received_but_not_billed_entry(item): + account = ( + warehouse_account[item.from_warehouse]["account"] if item.from_warehouse else stock_asset_rbnb + ) + account_currency = get_account_currency(account) + + # GL Entry for from warehouse or Stock Received but not billed + # Intentionally passed negative debit amount to avoid incorrect GL Entry validation + credit_amount = ( + flt(item.base_net_amount, item.precision("base_net_amount")) + if account_currency == self.company_currency + else flt(item.net_amount, item.precision("net_amount")) + ) + + outgoing_amount = item.base_net_amount + if self.is_internal_transfer() and item.valuation_rate: + outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse)) + credit_amount = outgoing_amount + + if credit_amount: + if not account: + validate_account("Stock or Asset Received But Not Billed") + + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=-1 * flt(outgoing_amount, item.precision("base_net_amount")), + credit=0.0, + remarks=remarks, + against_account=stock_asset_account_name, + debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")), + account_currency=account_currency, + item=item, + ) + + # check if the exchange rate has changed + if d.get("purchase_invoice"): + if ( + exchange_rate_map[item.purchase_invoice] + and self.conversion_rate != exchange_rate_map[item.purchase_invoice] + and item.net_rate == net_rate_map[item.purchase_invoice_item] + ): + + discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * ( + exchange_rate_map[item.purchase_invoice] - self.conversion_rate + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=0.0, + credit=discrepancy_caused_by_exchange_rate_difference, + remarks=remarks, + against_account=self.supplier, + debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, + account_currency=account_currency, + item=item, + ) + + self.add_gl_entry( + gl_entries=gl_entries, + account=self.get_company_default("exchange_gain_loss_account"), + cost_center=d.cost_center, + debit=discrepancy_caused_by_exchange_rate_difference, + credit=0.0, + remarks=remarks, + against_account=self.supplier, + debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, + account_currency=account_currency, + item=item, + ) + + return outgoing_amount + + def make_landed_cost_gl_entries(item): + # Amount added through landed-cost-voucher + if item.landed_cost_voucher_amount and landed_cost_entries: + if (item.item_code, item.name) in landed_cost_entries: + for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): + account_currency = get_account_currency(account) + credit_amount = ( + flt(amount["base_amount"]) + if (amount["base_amount"] or account_currency != self.company_currency) + else flt(amount["amount"]) + ) + + if not account: + validate_account("Landed Cost Account") + + self.add_gl_entry( + gl_entries=gl_entries, + account=account, + cost_center=item.cost_center, + debit=0.0, + credit=credit_amount, + remarks=remarks, + against_account=stock_asset_account_name, + credit_in_account_currency=flt(amount["amount"]), + account_currency=account_currency, + project=item.project, + item=item, + ) + + def make_rate_difference_entry(item): + if item.rate_difference_with_purchase_invoice and stock_asset_rbnb: + account_currency = get_account_currency(stock_asset_rbnb) + self.add_gl_entry( + gl_entries=gl_entries, + account=stock_asset_rbnb, + cost_center=item.cost_center, + debit=0.0, + credit=flt(item.rate_difference_with_purchase_invoice), + remarks=_("Adjustment based on Purchase Invoice rate"), + against_account=stock_asset_account_name, + account_currency=account_currency, + project=item.project, + item=item, + ) + + def make_sub_contracting_gl_entries(item): + # sub-contracting warehouse + if flt(item.rm_supp_cost) and warehouse_account.get(self.supplier_warehouse): + self.add_gl_entry( + gl_entries=gl_entries, + account=supplier_warehouse_account, + cost_center=item.cost_center, + debit=0.0, + credit=flt(item.rm_supp_cost), + remarks=remarks, + against_account=stock_asset_account_name, + account_currency=supplier_warehouse_account_currency, + item=item, + ) + + def make_divisional_loss_gl_entry(item, outgoing_amount): + if item.is_fixed_asset: + return + + # divisional loss adjustment + valuation_amount_as_per_doc = ( + flt(outgoing_amount, d.precision("base_net_amount")) + + flt(item.landed_cost_voucher_amount) + + flt(item.rm_supp_cost) + + flt(item.item_tax_amount) + + flt(item.rate_difference_with_purchase_invoice) + ) + + divisional_loss = flt( + valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount") + ) + + if divisional_loss: + loss_account = ( + self.get_company_default("default_expense_account", ignore_validation=True) + or stock_asset_rbnb + ) + + cost_center = item.cost_center or frappe.get_cached_value( + "Company", self.company, "cost_center" + ) + account_currency = get_account_currency(loss_account) + self.add_gl_entry( + gl_entries=gl_entries, + account=loss_account, + cost_center=cost_center, + debit=divisional_loss, + credit=0.0, + remarks=remarks, + against_account=stock_asset_account_name, + account_currency=account_currency, + project=item.project, + item=item, + ) + + stock_items = self.get_stock_items() + warehouse_with_no_account = [] + + for d in self.get("items"): + if ( + provisional_accounting_for_non_stock_items + and d.item_code not in stock_items + and flt(d.qty) + and d.get("provisional_expense_account") + and not d.is_fixed_asset + ): + self.add_provisional_gl_entry( + d, gl_entries, self.posting_date, d.get("provisional_expense_account") + ) + elif flt(d.qty) and (flt(d.valuation_rate) or self.is_return): + remarks = self.get("remarks") or _("Accounting Entry for {0}").format( + "Asset" if d.is_fixed_asset else "Stock" + ) + + if not ( + (erpnext.is_perpetual_inventory_enabled(self.company) and d.item_code in stock_items) + or d.is_fixed_asset + ): + continue + + stock_asset_rbnb = ( + self.get_company_default("asset_received_but_not_billed") + if d.is_fixed_asset + else self.get_company_default("stock_received_but_not_billed") + ) + # CUSTOM CODE START + landed_cost_entries = get_item_account_wise_additional_cost(self.name, lc_from_pi=lc_from_pi) + # CUSTOM CODE END + + if d.is_fixed_asset: + account_type = ( + "capital_work_in_progress_account" + if is_cwip_accounting_enabled(d.asset_category) + else "fixed_asset_account" + ) + + stock_asset_account_name = get_asset_account( + account_type, asset_category=d.asset_category, company=self.company + ) + + stock_value_diff = ( + flt(d.base_net_amount) + + flt(d.item_tax_amount / self.conversion_rate) + + flt(d.landed_cost_voucher_amount) + ) + elif warehouse_account.get(d.warehouse): + stock_value_diff = get_stock_value_difference(self.name, d.name, d.warehouse) + stock_asset_account_name = warehouse_account[d.warehouse]["account"] + supplier_warehouse_account = warehouse_account.get(self.supplier_warehouse, {}).get("account") + supplier_warehouse_account_currency = warehouse_account.get(self.supplier_warehouse, {}).get( + "account_currency" + ) + + # If PR is sub-contracted and fg item rate is zero + # in that case if account for source and target warehouse are same, + # then GL entries should not be posted + if ( + flt(stock_value_diff) == flt(d.rm_supp_cost) + and warehouse_account.get(self.supplier_warehouse) + and stock_asset_account_name == supplier_warehouse_account + ): + continue + + if (flt(d.valuation_rate) or self.is_return or d.is_fixed_asset) and flt(d.qty): + make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name) + outgoing_amount = make_stock_received_but_not_billed_entry(d) + make_landed_cost_gl_entries(d) + make_rate_difference_entry(d) + make_sub_contracting_gl_entries(d) + make_divisional_loss_gl_entry(d, outgoing_amount) + elif (d.warehouse and d.warehouse not in warehouse_with_no_account) or ( + d.rejected_warehouse and d.rejected_warehouse not in warehouse_with_no_account + ): + warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse) + + if d.is_fixed_asset and d.landed_cost_voucher_amount: + self.update_assets(d, d.valuation_rate) + + if warehouse_with_no_account: + frappe.msgprint( + _("No accounting entries for the following warehouses") + + ": \n" + + "\n".join(warehouse_with_no_account) + ) + + +def get_item_account_wise_additional_cost(purchase_document, lc_from_pi=None): + """ + Function copied/modified from ERPNext's PurchaseReceipt class in purchase_receipt.py to + accommodate revised function signature and inline landed costs in a linked Purchase Invoice. + New parameter lc_from_pi is None unless Enable Inline Landed Costing feature is set and landed + costs are included in a Purchase Invoice. + """ + landed_cost_vouchers = frappe.get_all( + "Landed Cost Purchase Receipt", + fields=["parent"], + filters={"receipt_document": purchase_document, "docstatus": 1}, + ) + + if not landed_cost_vouchers and not lc_from_pi: # CUSTOM CODE + return + + item_account_wise_cost = {} + + for lcv in landed_cost_vouchers: + landed_cost_voucher_doc = frappe.get_doc("Landed Cost Voucher", lcv.parent) + + # Use amount field for total item cost for manually cost distributed LCVs + if landed_cost_voucher_doc.distribute_charges_based_on == "Distribute Manually": + based_on_field = "amount" + else: + based_on_field = frappe.scrub(landed_cost_voucher_doc.distribute_charges_based_on) + + total_item_cost = 0 + + for item in landed_cost_voucher_doc.items: + total_item_cost += item.get(based_on_field) + + for item in landed_cost_voucher_doc.items: + if item.receipt_document == purchase_document: + for account in landed_cost_voucher_doc.taxes: + item_account_wise_cost.setdefault((item.item_code, item.purchase_receipt_item), {}) + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)].setdefault( + account.expense_account, {"amount": 0.0, "base_amount": 0.0} + ) + + if total_item_cost > 0: + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][ + account.expense_account + ]["amount"] += ( + account.amount * item.get(based_on_field) / total_item_cost + ) + + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][ + account.expense_account + ]["base_amount"] += ( + account.base_amount * item.get(based_on_field) / total_item_cost + ) + else: + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][ + account.expense_account + ]["amount"] += item.applicable_charges + item_account_wise_cost[(item.item_code, item.purchase_receipt_item)][ + account.expense_account + ]["base_amount"] += item.applicable_charges + + # CUSTOM CODE START + if lc_from_pi: + item_account_wise_cost.update(lc_from_pi) + # CUSTOM CODE END + + return item_account_wise_cost diff --git a/inventory_tools/public/js/purchase_invoice_custom.js b/inventory_tools/public/js/purchase_invoice_custom.js index 7c1c63a5..051168ab 100644 --- a/inventory_tools/public/js/purchase_invoice_custom.js +++ b/inventory_tools/public/js/purchase_invoice_custom.js @@ -1,11 +1,75 @@ +// Code for Purchase Taxes and Charges ("taxes") child table +frappe.ui.form.on('Purchase Taxes and Charges', { + taxes_add: function (frm, cdt, cdn) { + // Set the tax category based on "distribute_charges_based_on" selection + let child = locals[cdt][cdn] + let category = 'Total' + + if (frm.doc.distribute_charges_based_on != "Don't Distribute") { + category = 'Valuation and Total' + } + frappe.model.set_value(child.doctype, child.name, 'category', category) + }, + taxes_remove: function (frm, cdt, cdn) { + calc_landed_costs(frm) + }, + category: function (frm, cdt, cdn) { + calc_landed_costs(frm) + }, + tax_amount: function (frm, cdt, cdn) { + let child = locals[cdt][cdn] + + // Temporarily set value so functions have updated information when called + child.tax_amount_after_discount_amount = child.tax_amount + calc_landed_costs(frm) + }, +}) + +// Code for Purchase Invoice Item ("items") child table +frappe.ui.form.on('Purchase Invoice Item', { + qty: function (frm, cdt, cdn) { + // Re-calculate landed_costs and item_total fields on item quantity change + let child = locals[cdt][cdn] + let updated_base_net_amount = child.qty * child.rate + + // Temporarily set value so functions have updated information when called + child.base_net_amount = updated_base_net_amount + calc_landed_costs(frm) + }, + items_add: function (frm, cdt, cdn) { + frappe.run_serially([ + async () => { + await fetch_asset_or_stock(frm) + }, + () => { + calc_landed_costs(frm) + }, + ]) + }, + items_remove: function (frm, cdt, cdn) { + calc_landed_costs(frm) + }, +}) + frappe.ui.form.on('Purchase Invoice', { refresh: function (frm) { show_subcontracting_fields(frm) + show_landed_cost_fields(frm) frm.remove_custom_button(__('Fetch Stock Entries')) fetch_stock_entry_dialog(frm) setup_item_queries(frm) fetch_supplier_warehouse(frm) }, + validate: frm => { + frappe.run_serially([ + async () => { + await fetch_asset_or_stock(frm) + }, + () => { + calc_landed_costs(frm) + }, + ]) + }, is_subcontracted: function (frm) { if (frm.doc.is_subcontracted) { show_subcontracting_fields(frm) @@ -18,6 +82,37 @@ frappe.ui.form.on('Purchase Invoice', { supplier: frm => { fetch_supplier_warehouse(frm) }, + distribute_charges_based_on: frm => { + toggle_landed_cost_columns(frm) + if (!frm.doc.taxes || frm.doc.distribute_charges_based_on == "Don't Distribute") { + // No tax items or not distributing them, reset landed_costs and item_total fields + reset_landed_costs(frm) + set_tax_category(frm) + } else { + // Tax items exist, set each tax's category to align with dropdown selection + frappe.run_serially([ + async () => { + await fetch_asset_or_stock(frm) + }, + () => { + set_tax_category(frm) + }, + () => { + calc_landed_costs(frm) + }, + ]) + } + }, + after_save: frm => { + // Recalculate item totals to incorporate any item rate changes + let total_with_landed_costs = 0.0 + frm.doc.items.forEach(item => { + item.item_total = item.base_net_amount + item.landed_costs + total_with_landed_costs = total_with_landed_costs + item.item_total + }) + frm.set_value('total_with_landed_costs', total_with_landed_costs) + frm.refresh_fields(['items', 'total_with_landed_costs']) + }, }) function show_subcontracting_fields(frm) { @@ -42,6 +137,27 @@ function show_subcontracting_fields(frm) { }) } +function show_landed_cost_fields(frm) { + if (!frm.doc.company) { + hide_field('distribute_charges_based_on') + hide_field('total_with_landed_costs') + toggle_landed_cost_columns(frm) + return + } + frappe.db + .get_value('Inventory Tools Settings', { company: frm.doc.company }, 'enable_inline_landed_costing') + .then(r => { + if (r && r.message && r.message.enable_inline_landed_costing) { + unhide_field('distribute_charges_based_on') + unhide_field('total_with_landed_costs') + } else { + hide_field('distribute_charges_based_on') + hide_field('total_with_landed_costs') + } + toggle_landed_cost_columns(frm) + }) +} + function add_stock_entry_row(frm, row) { frm.add_child('subcontracting', { work_order: row.work_order, @@ -230,3 +346,175 @@ function setup_supplier_warehouse_query(frm) { } }) } + +function set_tax_category(frm) { + if (frm.doc.taxes.length) { + // Set tax category based on dropdown selection + frm.doc.taxes.forEach(tax => { + tax.category = frm.doc.distribute_charges_based_on == "Don't Distribute" ? 'Total' : 'Valuation and Total' + }) + } +} + +function reset_landed_costs(frm) { + let total_with_landed_costs = 0.0 + if (frm.doc.items.length) { + frm.doc.items.forEach(item => { + item.landed_costs = 0 + item.item_total = item.base_net_amount + total_with_landed_costs = total_with_landed_costs + item.base_net_amount + }) + } + frm.set_value('total_with_landed_costs', total_with_landed_costs) + frm.refresh_fields(['items', 'total_with_landed_costs']) +} + +function calc_landed_costs(frm) { + let stock_or_asset_items = frm.doc.items.filter(item => item.is_stock_item || item.is_fixed_asset) + if (stock_or_asset_items.length == 0) { + reset_landed_costs(frm) + frappe.throw( + __( + 'No items are stock or asset items (a requirement to update item valuations). ' + + "Please change 'Distribute Landed Cost Charges Based On' to Don't Distribute." + + "If an item should be a stock item, select 'Maintain Stock' in its Item Master." + ) + ) + } + + if (stock_or_asset_items.length && frm.doc.taxes.length) { + // There are stock items as well as taxes to distribute to them + let last_idx = stock_or_asset_items[stock_or_asset_items.length - 1].idx + let total_taxes = frm.doc.taxes.reduce((init_val, tax) => { + return init_val + (tax.category == 'Total' ? 0.0 : tax.tax_amount_after_discount_amount) // only include where category is Valuation-related + }, 0.0) + let total_qty = stock_or_asset_items.reduce((init_val, item) => { + return init_val + item.qty + }, 0.0) + let total_amount = stock_or_asset_items.reduce((init_val, item) => { + return init_val + item.base_net_amount + }, 0.0) + let div_by_zero_flag = (frm.doc.based_on == 'Qty' && !total_qty) || (frm.doc.based_on == 'Amount' && !total_amount) + let valuation_amount_adjustment = total_taxes + let total_lc = 0.0 + + if (frm.doc.distribute_charges_based_on == "Don't Distribute") { + reset_landed_costs(frm) + } else { + if (div_by_zero_flag) { + let field = frm.doc.distribute_charges_based_on == 'Qty' ? 'Accepted Quantity' : 'Amount' + frappe.throw(__(field + " values can't total zero when distributing charges based on " + frm.doc.based_on)) + } else { + // Loop over items, set landed costs for stock items (0 for non-stock) and update item total + frm.doc.items.forEach(item => { + if (!item.is_stock_item && !item.is_fixed_asset) { + item.landed_costs = 0.0 + } else if (item.idx == last_idx) { + // Last stock item, set landed_costs to remaining tax amount + let lc = valuation_amount_adjustment >= 0 ? valuation_amount_adjustment : 0 + item.landed_costs = Number(lc.toFixed(2)) + } else { + let p = + frm.doc.distribute_charges_based_on == 'Qty' ? item.qty / total_qty : item.base_net_amount / total_amount + item.landed_costs = Number((p * total_taxes).toFixed(2)) + valuation_amount_adjustment = valuation_amount_adjustment - item.landed_costs + } + total_lc = total_lc + item.landed_costs + item.item_total = item.base_net_amount + item.landed_costs + }) + + // Calculate any rounding difference and add to last stock item + let diff = Number((total_taxes - total_lc).toFixed(2)) + if (Math.abs(diff) >= 0.01) { + frm.doc.items.forEach(item => { + if (item.idx == last_idx) { + item.landed_costs = item.landed_costs + diff + } + }) + } + let total_with_landed_costs = frm.doc.items.reduce((init_val, item) => { + return init_val + item.item_total + }, 0.0) + frm.set_value('total_with_landed_costs', total_with_landed_costs) + frm.refresh_fields(['items', 'total_with_landed_costs']) + } + } + } else { + // No taxes and/or no stock items + reset_landed_costs(frm) + } +} + +async function fetch_asset_or_stock(frm) { + for await (const item of frm.doc.items) { + await frappe.db.get_value('Item', item.item_code, ['is_stock_item', 'is_fixed_asset']).then(r => { + item.is_stock_item = r.message.is_stock_item + item.is_fixed_asset = r.message.is_fixed_asset + }) + } + frm.refresh_field('items') +} + +function toggle_landed_cost_columns(frm) { + if (frm.doc.distribute_charges_based_on == "Don't Distribute") { + // hide columns + frm.get_field('items').grid.reset_grid() + frm.get_field('items').grid.visible_columns.forEach((column, index) => { + if (index >= frm.get_field('items').grid.visible_columns.length - 2) { + column[0].columns = 2 + column[1] = 2 + } + }) + for (let row of frm.get_field('items').grid.grid_rows) { + if (row.open_form_button) { + row.open_form_button.parent().remove() + delete row.open_form_button + } + + for (let field in row.columns) { + if (row.columns[field] !== undefined) { + row.columns[field].remove() + } + } + delete row.columns + row.columns = [] + row.render_row() + } + } else { + // show landed cost + frm.get_field('items').grid.reset_grid() + let user_defined_columns = frm.get_field('items').grid.visible_columns.map(col => { + return col[0] + }) + user_defined_columns.forEach((column, index) => { + if (index > 0) { + column.columns = 1 + } + }) + let landed_costs = frappe.meta.get_docfield(frm.get_field('items').grid.doctype, 'landed_costs') + landed_costs.in_list_view = 1 + user_defined_columns.push(landed_costs) + let item_total = frappe.meta.get_docfield(frm.get_field('items').grid.doctype, 'item_total') + item_total.in_list_view = 1 + user_defined_columns.push(item_total) + frm.get_field('items').grid.visible_columns = user_defined_columns.map(col => { + return [col, col.columns] + }) + for (let row of frm.get_field('items').grid.grid_rows) { + if (row.open_form_button) { + row.open_form_button.parent().remove() + delete row.open_form_button + } + + for (let field in row.columns) { + if (row.columns[field] !== undefined) { + row.columns[field].remove() + } + } + delete row.columns + row.columns = [] + row.render_row() + } + } + frm.get_field('items').refresh() +} diff --git a/inventory_tools/public/js/purchase_receipt_custom.js b/inventory_tools/public/js/purchase_receipt_custom.js new file mode 100644 index 00000000..a97a631b --- /dev/null +++ b/inventory_tools/public/js/purchase_receipt_custom.js @@ -0,0 +1,282 @@ +// Code for Purchase Taxes and Charges ("taxes") child table +frappe.ui.form.on('Purchase Taxes and Charges', { + taxes_add: function (frm, cdt, cdn) { + // Set the tax category based on "distribute_charges_based_on" selection + let child = locals[cdt][cdn] + let category = 'Total' + + if (frm.doc.distribute_charges_based_on != "Don't Distribute") { + category = 'Valuation and Total' + } + frappe.model.set_value(child.doctype, child.name, 'category', category) + }, + taxes_remove: function (frm, cdt, cdn) { + calc_landed_costs(frm) + }, + category: function (frm, cdt, cdn) { + calc_landed_costs(frm) + }, + tax_amount: function (frm, cdt, cdn) { + let child = locals[cdt][cdn] + + // Temporarily set value so functions have updated information when called + child.tax_amount_after_discount_amount = child.tax_amount + calc_landed_costs(frm) + }, +}) + +// Code for Purchase Receipt Item ("items") child table +frappe.ui.form.on('Purchase Receipt Item', { + qty: function (frm, cdt, cdn) { + // Re-calculate landed_costs and item_total fields on item quantity change + let child = locals[cdt][cdn] + let updated_base_net_amount = child.qty * child.rate + + // Temporarily set value so functions have updated information when called + child.base_net_amount = updated_base_net_amount + calc_landed_costs(frm) + }, + items_add: function (frm, cdt, cdn) { + frappe.run_serially([ + async () => { + await fetch_asset_or_stock(frm) + }, + () => { + calc_landed_costs(frm) + }, + ]) + }, + items_remove: function (frm, cdt, cdn) { + calc_landed_costs(frm) + }, +}) + +frappe.ui.form.on('Purchase Receipt', { + refresh: frm => { + show_landed_cost_fields(frm) + }, + validate: frm => { + frappe.run_serially([ + async () => { + await fetch_asset_or_stock(frm) + }, + () => { + calc_landed_costs(frm) + }, + ]) + }, + distribute_charges_based_on: frm => { + toggle_landed_cost_columns(frm) + if (!frm.doc.taxes || frm.doc.distribute_charges_based_on == "Don't Distribute") { + // No tax items or not distributing them, reset landed_costs and item_total fields + reset_landed_costs(frm) + set_tax_category(frm) + } else { + // Tax items exist, set each tax's category to align with dropdown selection + frappe.run_serially([ + async () => { + await fetch_asset_or_stock(frm) + }, + () => { + set_tax_category(frm) + }, + () => { + calc_landed_costs(frm) + }, + ]) + } + }, +}) + +function show_landed_cost_fields(frm) { + if (!frm.doc.company) { + hide_field('distribute_charges_based_on') + hide_field('total_with_landed_costs') + toggle_landed_cost_columns(frm) + return + } + frappe.db + .get_value('Inventory Tools Settings', { company: frm.doc.company }, 'enable_inline_landed_costing') + .then(r => { + if (r && r.message && r.message.enable_inline_landed_costing) { + unhide_field('distribute_charges_based_on') + unhide_field('total_with_landed_costs') + } else { + hide_field('distribute_charges_based_on') + hide_field('total_with_landed_costs') + } + toggle_landed_cost_columns(frm) + }) +} + +function set_tax_category(frm) { + if (frm.doc.taxes.length) { + // Set tax category based on dropdown selection + frm.doc.taxes.forEach(tax => { + tax.category = frm.doc.distribute_charges_based_on == "Don't Distribute" ? 'Total' : 'Valuation and Total' + }) + } +} + +function reset_landed_costs(frm) { + let total_with_landed_costs = 0.0 + if (frm.doc.items.length) { + frm.doc.items.forEach(item => { + item.landed_costs = 0 + item.item_total = item.base_net_amount + total_with_landed_costs = total_with_landed_costs + item.base_net_amount + }) + } + frm.set_value('total_with_landed_costs', total_with_landed_costs) + frm.refresh_fields(['items', 'total_with_landed_costs']) +} + +function calc_landed_costs(frm) { + let stock_or_asset_items = frm.doc.items.filter(item => item.is_stock_item || item.is_fixed_asset) + if (stock_or_asset_items.length == 0) { + reset_landed_costs(frm) + frappe.throw( + __( + 'No items are stock or asset items (a requirement to update item valuations). ' + + "Please change 'Distribute Landed Cost Charges Based On' to Don't Distribute." + + "If an item should be a stock item, select 'Maintain Stock' in its Item Master." + ) + ) + } + + if (stock_or_asset_items.length && frm.doc.taxes.length) { + // There are stock items as well as taxes to distribute to them + let last_idx = stock_or_asset_items[stock_or_asset_items.length - 1].idx + let total_taxes = frm.doc.taxes.reduce((init_val, tax) => { + return init_val + (tax.category == 'Total' ? 0.0 : tax.tax_amount_after_discount_amount) // only include where category is Valuation-related + }, 0.0) + let total_qty = stock_or_asset_items.reduce((init_val, item) => { + return init_val + item.qty + }, 0.0) + let total_amount = stock_or_asset_items.reduce((init_val, item) => { + return init_val + item.base_net_amount + }, 0.0) + let div_by_zero_flag = (frm.doc.based_on == 'Qty' && !total_qty) || (frm.doc.based_on == 'Amount' && !total_amount) + let valuation_amount_adjustment = total_taxes + let total_lc = 0.0 + + if (frm.doc.distribute_charges_based_on == "Don't Distribute") { + reset_landed_costs(frm) + } else { + if (div_by_zero_flag) { + let field = frm.doc.distribute_charges_based_on == 'Qty' ? 'Accepted Quantity' : 'Amount' + frappe.throw(__(field + " values can't total zero when distributing charges based on " + frm.doc.based_on)) + } else { + // Loop over items, set landed costs for stock items (0 for non-stock) and update item total + frm.doc.items.forEach(item => { + if (!item.is_stock_item && !item.is_fixed_asset) { + item.landed_costs = 0.0 + } else if (item.idx == last_idx) { + // Last stock item, set landed_costs to remaining tax amount + let lc = valuation_amount_adjustment >= 0 ? valuation_amount_adjustment : 0 + item.landed_costs = Number(lc.toFixed(2)) + } else { + let p = + frm.doc.distribute_charges_based_on == 'Qty' ? item.qty / total_qty : item.base_net_amount / total_amount + item.landed_costs = Number((p * total_taxes).toFixed(2)) + valuation_amount_adjustment = valuation_amount_adjustment - item.landed_costs + } + total_lc = total_lc + item.landed_costs + item.item_total = item.base_net_amount + item.landed_costs + }) + + // Calculate any rounding difference and add to last stock item + let diff = Number((total_taxes - total_lc).toFixed(2)) + if (Math.abs(diff) >= 0.01) { + frm.doc.items.forEach(item => { + if (item.idx == last_idx) { + item.landed_costs = item.landed_costs + diff + } + }) + } + let total_with_landed_costs = frm.doc.items.reduce((init_val, item) => { + return init_val + item.item_total + }, 0.0) + frm.set_value('total_with_landed_costs', total_with_landed_costs) + frm.refresh_fields(['items', 'total_with_landed_costs']) + } + } + } else { + // No taxes and/or no stock items + reset_landed_costs(frm) + } +} + +async function fetch_asset_or_stock(frm) { + for await (const item of frm.doc.items) { + await frappe.db.get_value('Item', item.item_code, ['is_stock_item', 'is_fixed_asset']).then(r => { + item.is_stock_item = r.message.is_stock_item + item.is_fixed_asset = r.message.is_fixed_asset + }) + } + frm.refresh_field('items') +} + +function toggle_landed_cost_columns(frm) { + if (frm.doc.distribute_charges_based_on == "Don't Distribute") { + // hide columns + frm.get_field('items').grid.reset_grid() + frm.get_field('items').grid.visible_columns.forEach((column, index) => { + if (index >= frm.get_field('items').grid.visible_columns.length - 2) { + column[0].columns = 2 + column[1] = 2 + } + }) + for (let row of frm.get_field('items').grid.grid_rows) { + if (row.open_form_button) { + row.open_form_button.parent().remove() + delete row.open_form_button + } + + for (let field in row.columns) { + if (row.columns[field] !== undefined) { + row.columns[field].remove() + } + } + delete row.columns + row.columns = [] + row.render_row() + } + } else { + // show landed cost + frm.get_field('items').grid.reset_grid() + let user_defined_columns = frm.get_field('items').grid.visible_columns.map(col => { + return col[0] + }) + user_defined_columns.forEach((column, index) => { + if (index > 0) { + column.columns = 1 + } + }) + let landed_costs = frappe.meta.get_docfield(frm.get_field('items').grid.doctype, 'landed_costs') + landed_costs.in_list_view = 1 + user_defined_columns.push(landed_costs) + let item_total = frappe.meta.get_docfield(frm.get_field('items').grid.doctype, 'item_total') + item_total.in_list_view = 1 + user_defined_columns.push(item_total) + frm.get_field('items').grid.visible_columns = user_defined_columns.map(col => { + return [col, col.columns] + }) + for (let row of frm.get_field('items').grid.grid_rows) { + if (row.open_form_button) { + row.open_form_button.parent().remove() + delete row.open_form_button + } + + for (let field in row.columns) { + if (row.columns[field] !== undefined) { + row.columns[field].remove() + } + } + delete row.columns + row.columns = [] + row.render_row() + } + } + frm.get_field('items').refresh() +}