From 3515504526ac7c65f0e8b60468100105fdf23d85 Mon Sep 17 00:00:00 2001 From: Tyler Matteson Date: Wed, 7 Aug 2024 10:45:20 -0400 Subject: [PATCH] Fix multi-company PO aggregation workflow frontend (#82) * wip: fix multi-company PO aggregation workflow frontend * wip: purchase receipt generation * test: test downstream effects of partial PO aggregation * ci: track overrides for purchase cycle (PO/PI/PR) (#89) * ci: track overrides for stock and manufacturing cycles (#90) Co-authored-by: Rohan Bansal * Test alternative workstation (#80) * test: add test for alternative workstation * feat: make alternative workstations configurable * fix: uncomment js code for testing * feat: search alternative workstation names * refactor: pop filters that cause error for Workstation * chore: update override commit hash --------- Co-authored-by: Heather Kusmierz * fix: rm unused variable * fix: frappe.boot.inventory_tools_settings * chore: track overrides --------- Co-authored-by: Heather Kusmierz Co-authored-by: Rohan Co-authored-by: Rohan Bansal Co-authored-by: fproldan --- inventory_tools/hooks.py | 14 +- .../custom/purchase_order_item.json | 70 ++++++ .../overrides/purchase_invoice.py | 2 +- .../overrides/purchase_order.py | 99 +++++--- .../overrides/purchase_receipt.py | 1 + .../report/material_demand/material_demand.py | 11 +- .../public/js/{ => custom}/item.js | 0 .../public/js/{ => custom}/job_card_custom.js | 0 .../js/{ => custom}/operation_custom.js | 0 .../{ => custom}/purchase_invoice_custom.js | 42 ++-- .../public/js/custom/purchase_order_custom.js | 211 ++++++++++++++---- .../js/{ => custom}/stock_entry_custom.js | 0 .../js/{ => custom}/work_order_custom.js | 21 +- .../public/js/inventory_tools.bundle.js | 2 +- .../public/js/purchase_order_custom.js | 93 -------- .../public/js/{custom => }/utils.js | 0 .../tests/test_aggregated_purchasing.py | 58 +++++ inventory_tools/tests/test_material_demand.py | 40 ++-- inventory_tools/www/__init__.py | 0 19 files changed, 438 insertions(+), 226 deletions(-) create mode 100644 inventory_tools/inventory_tools/custom/purchase_order_item.json rename inventory_tools/public/js/{ => custom}/item.js (100%) rename inventory_tools/public/js/{ => custom}/job_card_custom.js (100%) rename inventory_tools/public/js/{ => custom}/operation_custom.js (100%) rename inventory_tools/public/js/{ => custom}/purchase_invoice_custom.js (88%) rename inventory_tools/public/js/{ => custom}/stock_entry_custom.js (100%) rename inventory_tools/public/js/{ => custom}/work_order_custom.js (82%) delete mode 100644 inventory_tools/public/js/purchase_order_custom.js rename inventory_tools/public/js/{custom => }/utils.js (100%) create mode 100644 inventory_tools/tests/test_aggregated_purchasing.py create mode 100644 inventory_tools/www/__init__.py diff --git a/inventory_tools/hooks.py b/inventory_tools/hooks.py index 1ca9f65..f0639bc 100644 --- a/inventory_tools/hooks.py +++ b/inventory_tools/hooks.py @@ -31,13 +31,13 @@ # include js in doctype views doctype_js = { - "Item": "public/js/item.js", - "Job Card": "public/js/job_card_custom.js", - "Purchase Invoice": "public/js/purchase_invoice_custom.js", - "Purchase Order": "public/js/purchase_order_custom.js", - "Operation": "public/js/operation_custom.js", - "Stock Entry": "public/js/stock_entry_custom.js", - "Work Order": "public/js/work_order_custom.js", + "Item": "public/js/custom/item.js", + "Job Card": "public/js/custom/job_card_custom.js", + "Purchase Invoice": "public/js/custom/purchase_invoice_custom.js", + "Purchase Order": "public/js/custom/purchase_order_custom.js", + "Operation": "public/js/custom/operation_custom.js", + "Stock Entry": "public/js/custom/stock_entry_custom.js", + "Work Order": "public/js/custom/work_order_custom.js", } # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} diff --git a/inventory_tools/inventory_tools/custom/purchase_order_item.json b/inventory_tools/inventory_tools/custom/purchase_order_item.json new file mode 100644 index 0000000..d5e08db --- /dev/null +++ b/inventory_tools/inventory_tools/custom/purchase_order_item.json @@ -0,0 +1,70 @@ +{ + "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": "2024-06-04 14:06:25.331707", + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "dt": "Purchase Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "material_request_company", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "idx": 60, + "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": "references_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Material Request Company", + "length": 0, + "mandatory_depends_on": "", + "modified": "2024-06-04 15:44:22.874371", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Purchase Order Item-material_request_company", + "no_copy": 0, + "non_negative": 0, + "options": "Company", + "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, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } + ], + "custom_perms": [], + "doctype": "Purchase Order Item", + "links": [], + "property_setters": [], + "sync_on_migrate": 1 +} \ No newline at end of file diff --git a/inventory_tools/inventory_tools/overrides/purchase_invoice.py b/inventory_tools/inventory_tools/overrides/purchase_invoice.py index a8ad3c3..45a459a 100644 --- a/inventory_tools/inventory_tools/overrides/purchase_invoice.py +++ b/inventory_tools/inventory_tools/overrides/purchase_invoice.py @@ -13,7 +13,7 @@ class InventoryToolsPurchaseInvoice(PurchaseInvoice): def validate_with_previous_doc(self): """ - HASH: 4668a2d7d825450818e04a1b785deb61d861ed29 + HASH: e7432fc60d4b5b82363212ae003cb7d2d4e8f294 REPO: https://github.com/frappe/erpnext/ PATH: erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py METHOD: validate_with_previous_doc diff --git a/inventory_tools/inventory_tools/overrides/purchase_order.py b/inventory_tools/inventory_tools/overrides/purchase_order.py index 7d43d81..ffde525 100644 --- a/inventory_tools/inventory_tools/overrides/purchase_order.py +++ b/inventory_tools/inventory_tools/overrides/purchase_order.py @@ -61,15 +61,22 @@ def validate_with_previous_doc(self): super(PurchaseOrder, self).validate_with_previous_doc(config) def validate_warehouse(self): - warehouses = list({d.warehouse for d in self.get("items") if getattr(d, "warehouse", None)}) - - warehouses.extend( - list({d.target_warehouse for d in self.get("items") if getattr(d, "target_warehouse", None)}) - ) - - warehouses.extend( - list({d.from_warehouse for d in self.get("items") if getattr(d, "from_warehouse", None)}) - ) + warehouses = [] + inventory_tools_settings = frappe.get_doc("Inventory Tools Settings", self.company) + + for d in self.get("items"): + if ( + self.multi_company_purchase_order + and not inventory_tools_settings.aggregated_purchasing_warehouse + ): + validate_warehouse_company(d.warehouse, d.material_request_company) + continue + if getattr(d, "warehouse", None): + warehouses.append(d.warehouse) + if getattr(d, "target_warehouse", None): + warehouses.append(d.warehouse) + if getattr(d, "from_warehouse", None): + warehouses.append(d.warehouse) for w in warehouses: validate_disabled_warehouse(w) @@ -111,23 +118,37 @@ def validate_subcontracting_fg_qty(self): def make_purchase_invoices(docname: str, rows: Union[list, str]) -> None: rows = json.loads(rows) if isinstance(rows, str) else rows doc = frappe.get_doc("Purchase Order", docname) + inventory_tools_settings = frappe.get_doc("Inventory Tools Settings", doc.company) forwarding = frappe._dict() - for row in doc.items: - if row.name in rows: - if row.company in forwarding: - forwarding[row.company].append(row.name) - else: - forwarding[row.company] = [row.name] + for row_name in rows: + for row in doc.items: + if row_name == row.name: + company = frappe.get_value("Material Request", row.material_request, "company") + if company in forwarding: + forwarding[company].append(row.name) + else: + forwarding[company] = [row.name] - for company, rows in forwarding.items(): + for company, _rows in forwarding.items(): pi = make_purchase_invoice(docname) pi.company = company pi.credit_to = frappe.get_value("Company", pi.company, "default_payable_account") + filtered_rows = [] for row in pi.items: - if row.po_detail in rows: - continue - else: - pi.items.remove(row) + if row.po_detail in _rows: + if inventory_tools_settings.aggregated_purchasing_warehouse is not None: + row.warehouse = inventory_tools_settings.aggregated_purchasing_warehouse + else: + material_request_item = frappe.get_value( + "Purchase Order Item", row.po_detail, "material_request_item" + ) + warehouse, cost_center = frappe.get_value( + "Material Request Item", material_request_item, ["warehouse", "cost_center"] + ) + row.warehouse = warehouse + row.cost_center = cost_center + filtered_rows.append(row) + pi.items = filtered_rows pi.save() @@ -135,22 +156,34 @@ def make_purchase_invoices(docname: str, rows: Union[list, str]) -> None: def make_purchase_receipts(docname: str, rows: Union[list, str]) -> None: rows = json.loads(rows) if isinstance(rows, str) else rows doc = frappe.get_doc("Purchase Order", docname) + inventory_tools_settings = frappe.get_doc("Inventory Tools Settings", doc.company) + forwarding = frappe._dict() - for row in doc.items: - if row.name in rows: - if row.company in forwarding: - forwarding[row.company].append(row.name) - else: - forwarding[row.company] = [row.name] + for row_name in rows: + for row in doc.items: + if row_name == row.name: + company = frappe.get_value("Material Request", row.material_request, "company") + if company in forwarding: + forwarding[company].append(row.name) + else: + forwarding[company] = [row.name] - for company, rows in forwarding.items(): + for company, _rows in forwarding.items(): pr = make_purchase_receipt(docname) pr.company = company + filtered_rows = [] for row in pr.items: - if row.purchase_order_item in rows: - continue - else: - pr.items.remove(row) + if row.purchase_order_item in _rows: + if inventory_tools_settings.aggregated_purchasing_warehouse is not None: + row.warehouse = inventory_tools_settings.aggregated_purchasing_warehouse + else: + warehouse, cost_center = frappe.get_value( + "Material Request Item", row.material_request_item, ["warehouse", "cost_center"] + ) + row.warehouse = warehouse + row.cost_center = cost_center + filtered_rows.append(row) + pr.items = filtered_rows pr.save() @@ -225,7 +258,7 @@ def make_sales_invoices(docname: str, rows: Union[list, str]) -> None: @frappe.whitelist() def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=True): """ - HASH: 4d6a71ab4ba0f008e1a6816ed99e890fae347016 + HASH: 53034c332b929a3e40759c9fc9bae82b8365aa57 REPO: https://github.com/frappe/erpnext/ PATH: erpnext/stock/get_item_details.py METHOD: get_item_details @@ -242,7 +275,7 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru @frappe.whitelist() def validate_item_details(args, item): """ - HASH: 4d6a71ab4ba0f008e1a6816ed99e890fae347016 + HASH: 53034c332b929a3e40759c9fc9bae82b8365aa57 REPO: https://github.com/frappe/erpnext/ PATH: erpnext/stock/get_item_details.py METHOD: validate_item_details diff --git a/inventory_tools/inventory_tools/overrides/purchase_receipt.py b/inventory_tools/inventory_tools/overrides/purchase_receipt.py index 14a49f4..51c6284 100644 --- a/inventory_tools/inventory_tools/overrides/purchase_receipt.py +++ b/inventory_tools/inventory_tools/overrides/purchase_receipt.py @@ -5,6 +5,7 @@ import frappe from erpnext.stock.doctype.purchase_receipt.purchase_receipt import PurchaseReceipt +from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company from frappe.utils.data import cint diff --git a/inventory_tools/inventory_tools/report/material_demand/material_demand.py b/inventory_tools/inventory_tools/report/material_demand/material_demand.py index f1d313a..ecddaa0 100644 --- a/inventory_tools/inventory_tools/report/material_demand/material_demand.py +++ b/inventory_tools/inventory_tools/report/material_demand/material_demand.py @@ -315,7 +315,8 @@ def create_pos(company, filters, rows): return counter = 0 settings = frappe.get_doc("Inventory Tools Settings", company) - requesting_companies = list({row.company for row in rows}) + requesting_companies = list({row.company for row in rows if row.company}) + if settings.purchase_order_aggregation_company == company: requesting_companies = [company] for requesting_company in requesting_companies: @@ -325,9 +326,12 @@ def create_pos(company, filters, rows): po.schedule_date = po.posting_date = getdate() po.supplier = supplier po.buying_price_list = filters.price_list - if len(requesting_companies) == 1: - po.multi_company_purchase_order = True + if ( + len(requesting_companies) == 1 + and requesting_company == settings.purchase_order_aggregation_company + ): po.company = settings.purchase_order_aggregation_company + po.multi_company_purchase_order = True else: po.company = requesting_company for row in rows: @@ -349,6 +353,7 @@ def create_pos(company, filters, rows): "qty": row.get("qty"), "rate": row.get("supplier_price"), "uom": row.get("uom"), + "material_request_company": row.get("company"), "material_request": row.get("material_request"), "material_request_item": row.get("material_request_item"), "warehouse": warehouse, diff --git a/inventory_tools/public/js/item.js b/inventory_tools/public/js/custom/item.js similarity index 100% rename from inventory_tools/public/js/item.js rename to inventory_tools/public/js/custom/item.js diff --git a/inventory_tools/public/js/job_card_custom.js b/inventory_tools/public/js/custom/job_card_custom.js similarity index 100% rename from inventory_tools/public/js/job_card_custom.js rename to inventory_tools/public/js/custom/job_card_custom.js diff --git a/inventory_tools/public/js/operation_custom.js b/inventory_tools/public/js/custom/operation_custom.js similarity index 100% rename from inventory_tools/public/js/operation_custom.js rename to inventory_tools/public/js/custom/operation_custom.js diff --git a/inventory_tools/public/js/purchase_invoice_custom.js b/inventory_tools/public/js/custom/purchase_invoice_custom.js similarity index 88% rename from inventory_tools/public/js/purchase_invoice_custom.js rename to inventory_tools/public/js/custom/purchase_invoice_custom.js index 7c1c63a..80052ad 100644 --- a/inventory_tools/public/js/purchase_invoice_custom.js +++ b/inventory_tools/public/js/custom/purchase_invoice_custom.js @@ -25,21 +25,21 @@ function show_subcontracting_fields(frm) { hide_field('subcontracting') return } - frappe.db - .get_value('Inventory Tools Settings', { company: frm.doc.company }, 'enable_work_order_subcontracting') - .then(r => { - if (r && r.message && r.message.enable_work_order_subcontracting) { - unhide_field('subcontracting') - hide_field('update_stock') - setTimeout(() => { - frm.remove_custom_button('Purchase Receipt', 'Create') - }, 1000) - } else { - hide_field('subcontracting') - unhide_field('update_stock') - } - toggle_subcontracting_columns(frm) - }) + if ( + frappe.boot.inventory_tools_settings && + frappe.boot.inventory_tools_settings[frm.doc.company] && + frappe.boot.inventory_tools_settings[frm.doc.company].enable_work_order_subcontracting + ) { + unhide_field('subcontracting') + hide_field('update_stock') + setTimeout(() => { + frm.remove_custom_button('Purchase Receipt', 'Create') + }, 1000) + } else { + hide_field('subcontracting') + unhide_field('update_stock') + } + toggle_subcontracting_columns(frm) } function add_stock_entry_row(frm, row) { @@ -188,11 +188,13 @@ function setup_item_queries(frm) { if (me.frm.doc.is_old_subcontracting_flow) { filters['is_sub_contracted_item'] = 1 } else { - frappe.db.get_value('Inventory Tools Settings', frm.doc.company, 'enable_work_order_subcontracting').then(r => { - if (!r.message.enable_work_order_subcontracting) { - filters['is_stock_item'] = 0 - } - }) + if ( + frappe.boot.inventory_tools_settings && + frappe.boot.inventory_tools_settings[frm.doc.company] && + frappe.boot.inventory_tools_settings[frm.doc.company].enable_work_order_subcontracting + ) { + filters['is_stock_item'] = 0 + } } return { query: 'erpnext.controllers.queries.item_query', diff --git a/inventory_tools/public/js/custom/purchase_order_custom.js b/inventory_tools/public/js/custom/purchase_order_custom.js index 7a287d8..a8ac3e1 100644 --- a/inventory_tools/public/js/custom/purchase_order_custom.js +++ b/inventory_tools/public/js/custom/purchase_order_custom.js @@ -1,55 +1,148 @@ -// Copyright (c) 2023, AgriTheory and contributors -// For license information, please see license.txt - frappe.ui.form.on('Purchase Order', { onload_post_render: frm => { override_create_buttons(frm) }, refresh: frm => { + show_subcontracting_fields(frm) + setup_item_queries(frm) + fetch_supplier_warehouse(frm) override_create_buttons(frm) }, + is_subcontracted: frm => { + if (frm.doc.is_subcontracted) { + show_subcontracting_fields(frm) + } + }, + company: frm => { + setup_item_queries(frm) + fetch_supplier_warehouse(frm) + }, + supplier: frm => { + fetch_supplier_warehouse(frm) + }, }) -function override_create_buttons(frm) { - if (!frm.doc.multi_company_purchase_order || frm.doc.docstatus != 1) { +// TODO: override when a qty changes in item table, it changes the fg_item_qty (assumes same UOM) +// TODO: subcontracting table: autofill fields when a row is manually added and user selects the WO +// TODO: when subcontracting table row removed, adjust Item row fg_item_qty + +function show_subcontracting_fields(frm) { + if (!frm.doc.company || !frm.doc.is_subcontracted) { + hide_field('subcontracting') return } - - let aggregated_purchasing_warehouse = undefined - frappe.db.get_value('Buying Settings', 'Buying Settings', 'aggregated_purchasing_warehouse').then(r => { - aggregated_purchasing_warehouse = r.message.aggregated_purchasing_warehouse - if (!aggregated_purchasing_warehouse) { - frm.remove_custom_button('Purchase Invoice', 'Create') + if ( + frappe.boot.inventory_tools_settings && + frappe.boot.inventory_tools_settings[frm.doc.company] && + frappe.boot.inventory_tools_settings[frm.doc.company].enable_work_order_subcontracting + ) { + unhide_field('subcontracting') + setTimeout(() => { frm.remove_custom_button('Purchase Receipt', 'Create') - frm.remove_custom_button('Payment', 'Create') - frm.remove_custom_button('Payment Request', 'Create') - frm.remove_custom_button('Subscription', 'Create') - frm.add_custom_button( - 'Create Purchase Invoices', - async () => { - await create_pis(frm) - }, - 'Create' - ) - frm.add_custom_button( - 'Create Purchase Receipts', - async () => { - await create_prs(frm) - }, - 'Create' - ) + frm.remove_custom_button('Subcontracting Order', 'Create') + }, 1000) + } else { + hide_field('subcontracting') + } +} + +function setup_item_queries(frm) { + frm.set_query('item_code', 'items', () => { + if (me.frm.doc.is_subcontracted) { + var filters = { supplier: me.frm.doc.supplier } + if (me.frm.doc.is_old_subcontracting_flow) { + filters['is_sub_contracted_item'] = 1 + } else { + if ( + frappe.boot.inventory_tools_settings && + frappe.boot.inventory_tools_settings[frm.doc.company] && + frappe.boot.inventory_tools_settings[frm.doc.company].enable_work_order_subcontracting + ) { + filters['is_stock_item'] = 0 + } + } + return { + query: 'erpnext.controllers.queries.item_query', + filters: filters, + } } else { - frm.add_custom_button( - 'Intercompany Sale and Transfer', - async () => { - await create_sis(frm) - }, - 'Create' - ) + return { + query: 'erpnext.controllers.queries.item_query', + filters: { supplier: me.frm.doc.supplier, is_purchase_item: 1, has_variants: 0 }, + } } }) } +function setup_supplier_warehouse_query(frm) { + frm.set_query('supplier_warehouse', () => { + return { + filters: { is_group: 0 }, + } + }) +} + +function fetch_supplier_warehouse(frm) { + if (!frm.doc.company || !frm.doc.supplier) { + return + } + frappe + .xcall('inventory_tools.inventory_tools.overrides.purchase_invoice.fetch_supplier_warehouse', { + company: frm.doc.company, + supplier: frm.doc.supplier, + }) + .then(r => { + if (r && r.message) { + frm.set_value('supplier_warehouse', r.message.supplier_warehouse) + } + }) +} + +function override_create_buttons(frm) { + if (!frm.doc.multi_company_purchase_order || frm.doc.docstatus != 1) { + return + } + let aggregated_purchasing_warehouse = undefined + if ( + frm.doc.docstatus && + frappe.boot.inventory_tools_settings && + frappe.boot.inventory_tools_settings[frm.doc.company] && + frappe.boot.inventory_tools_settings[frm.doc.company].aggregated_purchasing_warehouse + ) { + aggregated_purchasing_warehouse = + frappe.boot.inventory_tools_settings[frm.doc.company].aggregated_purchasing_warehouse + } + if (!aggregated_purchasing_warehouse) { + frm.remove_custom_button('Purchase Invoice', 'Create') + frm.remove_custom_button('Purchase Receipt', 'Create') + frm.remove_custom_button('Payment', 'Create') + frm.remove_custom_button('Payment Request', 'Create') + frm.remove_custom_button('Subscription', 'Create') + frm.add_custom_button( + 'Create Purchase Invoices', + async () => { + await create_pis(frm) + }, + 'Create' + ) + frm.add_custom_button( + 'Create Purchase Receipts', + async () => { + await create_prs(frm) + }, + 'Create' + ) + } else { + frm.add_custom_button( + 'Intercompany Sale and Transfer', + async () => { + await create_sis(frm) + }, + 'Create' + ) + } +} + async function create_pis(frm) { await create_dialog( frm, @@ -81,9 +174,7 @@ async function create_sis(frm) { } async function create_dialog(frm, title, label, method, primary_action_label) { - let items_data = frm.doc.items.filter(r => { - return r.company != frm.doc.company && r.rate != 0.0 && r.stock_qty > 0.0 - }) + let items_data = await get_items_data(frm) return new Promise(resolve => { let table_fields = { fieldname: 'locations', @@ -128,8 +219,8 @@ async function create_dialog(frm, title, label, method, primary_action_label) { }, ], data: items_data, - get_data: () => { - return items_data + get_data: async () => { + return await get_items_data(frm) }, } let dialog = new frappe.ui.Dialog({ @@ -149,3 +240,43 @@ async function create_dialog(frm, title, label, method, primary_action_label) { // dialog.get_close_btn() }) } + +async function get_items_data(frm) { + let items_data = [] + if ( + frm.doc.docstatus && + frappe.boot.inventory_tools_settings && + frappe.boot.inventory_tools_settings[frm.doc.company] && + frappe.boot.inventory_tools_settings[frm.doc.company].aggregated_purchasing_warehouse + ) { + items_data = frm.doc.items + items_data.forEach(row => { + row.company = frm.doc.company + }) + return items_data + } else { + let items_data = frm.doc.items.filter(r => { + return r.company != frm.doc.company && r.rate != 0.0 && r.stock_qty > 0.0 + }) + let mrs = Array.from( + new Set( + frm.doc.items.map(r => { + return r.material_request + }) + ) + ) + await frappe.db + .get_list('Material Request', { filters: { name: ['in', mrs] }, fields: ['name', 'company'] }) + .then(r => { + console.log(r) + r.forEach(mr => { + items_data.forEach(row => { + if (row.material_request == mr.name) { + row.company = mr.company + } + }) + }) + }) + return items_data + } +} diff --git a/inventory_tools/public/js/stock_entry_custom.js b/inventory_tools/public/js/custom/stock_entry_custom.js similarity index 100% rename from inventory_tools/public/js/stock_entry_custom.js rename to inventory_tools/public/js/custom/stock_entry_custom.js diff --git a/inventory_tools/public/js/work_order_custom.js b/inventory_tools/public/js/custom/work_order_custom.js similarity index 82% rename from inventory_tools/public/js/work_order_custom.js rename to inventory_tools/public/js/custom/work_order_custom.js index 0a26a8a..46171ae 100644 --- a/inventory_tools/public/js/work_order_custom.js +++ b/inventory_tools/public/js/custom/work_order_custom.js @@ -34,18 +34,15 @@ function manage_subcontracting_buttons(frm) { if (frm.doc.company) { frappe.db.get_value('BOM', { name: frm.doc.bom_no }, 'is_subcontracted').then(r => { if (r && r.message && r.message.is_subcontracted) { - frappe.db - .get_value('Inventory Tools Settings', { company: frm.doc.company }, 'enable_work_order_subcontracting') - .then(r => { - if (r && r.message && r.message.enable_work_order_subcontracting && frm.doc.docstatus == 1) { - frm.add_custom_button( - __('Create Subcontract PO'), - () => make_subcontracting_po(frm), - __('Subcontracting') - ) - frm.add_custom_button(__('Add to Existing PO'), () => add_to_existing_po(frm), __('Subcontracting')) - } - }) + if ( + frm.doc.docstatus && + frappe.boot.inventory_tools_settings && + frappe.boot.inventory_tools_settings[frm.doc.company] && + frappe.boot.inventory_tools_settings[frm.doc.company].enable_work_order_subcontracting + ) { + frm.add_custom_button(__('Create Subcontract PO'), () => make_subcontracting_po(frm), __('Subcontracting')) + frm.add_custom_button(__('Add to Existing PO'), () => add_to_existing_po(frm), __('Subcontracting')) + } } }) } diff --git a/inventory_tools/public/js/inventory_tools.bundle.js b/inventory_tools/public/js/inventory_tools.bundle.js index 6992c47..0365ef1 100644 --- a/inventory_tools/public/js/inventory_tools.bundle.js +++ b/inventory_tools/public/js/inventory_tools.bundle.js @@ -1,2 +1,2 @@ import './uom_enforcement.js' -import './custom/utils.js' +import './utils.js' diff --git a/inventory_tools/public/js/purchase_order_custom.js b/inventory_tools/public/js/purchase_order_custom.js deleted file mode 100644 index 3142a70..0000000 --- a/inventory_tools/public/js/purchase_order_custom.js +++ /dev/null @@ -1,93 +0,0 @@ -frappe.ui.form.on('Purchase Order', { - refresh: frm => { - show_subcontracting_fields(frm) - setup_item_queries(frm) - fetch_supplier_warehouse(frm) - }, - is_subcontracted: frm => { - if (frm.doc.is_subcontracted) { - show_subcontracting_fields(frm) - } - }, - company: frm => { - setup_item_queries(frm) - fetch_supplier_warehouse(frm) - }, - supplier: frm => { - fetch_supplier_warehouse(frm) - }, -}) - -// TODO: override when a qty changes in item table, it changes the fg_item_qty (assumes same UOM) -// TODO: subcontracting table: autofill fields when a row is manually added and user selects the WO -// TODO: when subcontracting table row removed, adjust Item row fg_item_qty - -function show_subcontracting_fields(frm) { - if (!frm.doc.company || !frm.doc.is_subcontracted) { - hide_field('subcontracting') - return - } - frappe.db - .get_value('Inventory Tools Settings', { company: frm.doc.company }, 'enable_work_order_subcontracting') - .then(r => { - if (r && r.message && r.message.enable_work_order_subcontracting) { - unhide_field('subcontracting') - setTimeout(() => { - frm.remove_custom_button('Purchase Receipt', 'Create') - frm.remove_custom_button('Subcontracting Order', 'Create') - }, 1000) - } else { - hide_field('subcontracting') - } - }) -} - -function setup_item_queries(frm) { - frm.set_query('item_code', 'items', () => { - if (me.frm.doc.is_subcontracted) { - var filters = { supplier: me.frm.doc.supplier } - if (me.frm.doc.is_old_subcontracting_flow) { - filters['is_sub_contracted_item'] = 1 - } else { - frappe.db.get_value('Inventory Tools Settings', frm.doc.company, 'enable_work_order_subcontracting').then(r => { - if (!r.message.enable_work_order_subcontracting) { - filters['is_stock_item'] = 0 - } - }) - } - return { - query: 'erpnext.controllers.queries.item_query', - filters: filters, - } - } else { - return { - query: 'erpnext.controllers.queries.item_query', - filters: { supplier: me.frm.doc.supplier, is_purchase_item: 1, has_variants: 0 }, - } - } - }) -} - -function setup_supplier_warehouse_query(frm) { - frm.set_query('supplier_warehouse', () => { - return { - filters: { is_group: 0 }, - } - }) -} - -function fetch_supplier_warehouse(frm) { - if (!frm.doc.company || !frm.doc.supplier) { - return - } - frappe - .xcall('inventory_tools.inventory_tools.overrides.purchase_invoice.fetch_supplier_warehouse', { - company: frm.doc.company, - supplier: frm.doc.supplier, - }) - .then(r => { - if (r && r.message) { - frm.set_value('supplier_warehouse', r.message.supplier_warehouse) - } - }) -} diff --git a/inventory_tools/public/js/custom/utils.js b/inventory_tools/public/js/utils.js similarity index 100% rename from inventory_tools/public/js/custom/utils.js rename to inventory_tools/public/js/utils.js diff --git a/inventory_tools/tests/test_aggregated_purchasing.py b/inventory_tools/tests/test_aggregated_purchasing.py new file mode 100644 index 0000000..6b6c9b4 --- /dev/null +++ b/inventory_tools/tests/test_aggregated_purchasing.py @@ -0,0 +1,58 @@ +import json + +import frappe +import pytest + +from inventory_tools.inventory_tools.overrides.purchase_order import ( + make_purchase_invoices, + make_purchase_receipts, +) + + +@pytest.mark.order(25) +def test_purchase_receipt_aggregation(): + # this should be called immediately after 'test_report_po_with_aggregation_and_no_aggregation_warehouse' + settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") + + pos = [ + frappe.get_doc("Purchase Order", p) for p in frappe.get_all("Purchase Order", pluck="name") + ] + for po in pos: + items = [row.name for row in po.items] + make_purchase_receipts(po.name, frappe.as_json(items)) + + prs = [ + frappe.get_doc("Purchase Receipt", p) for p in frappe.get_all("Purchase Receipt", pluck="name") + ] + for pr in prs: + pr.submit() + for row in pr.items: + mr_company = frappe.get_value("Material Request", row.material_request, "company") + po_company = frappe.get_value("Purchase Order", row.purchase_order, "company") + assert mr_company == pr.company + assert po.company == settings.purchase_order_aggregation_company + + +@pytest.mark.order(26) +def test_purchase_invoice_aggregation(): + settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") + + pos = [ + frappe.get_doc("Purchase Order", p) for p in frappe.get_all("Purchase Order", pluck="name") + ] + for po in pos: + items = [row.name for row in po.items] + make_purchase_invoices(po.name, frappe.as_json(items)) + + pis = [ + frappe.get_doc("Purchase Invoice", p) for p in frappe.get_all("Purchase Invoice", pluck="name") + ] + for pi in pis: + pi.submit() + for row in pi.items: + material_request = frappe.get_value("Purchase Order Item", row.po_detail, "material_request") + mr_company = frappe.get_value("Material Request", material_request, "company") + po_company = frappe.get_value("Purchase Order", row.purchase_order, "company") + assert mr_company == pi.company + assert po.company == settings.purchase_order_aggregation_company + # NOTE: PO company MAY BE different from MR and PI diff --git a/inventory_tools/tests/test_material_demand.py b/inventory_tools/tests/test_material_demand.py index 7606d09..e60cb59 100644 --- a/inventory_tools/tests/test_material_demand.py +++ b/inventory_tools/tests/test_material_demand.py @@ -45,6 +45,7 @@ def test_report_po_without_aggregation(): frappe.delete_doc("Purchase Order", po.name) +@pytest.mark.order(21) def test_report_rfq_without_aggregation(): filters = frappe._dict( {"end_date": getdate(), "price_list": "Bakery Buying", "company": "Ambrosia Pie Company"} @@ -90,7 +91,7 @@ def test_report_rfq_without_aggregation(): rfq.delete() -@pytest.mark.order(21) +@pytest.mark.order(22) def test_report_item_based_without_aggregation(): filters = frappe._dict( {"end_date": getdate(), "price_list": "Bakery Buying", "company": "Ambrosia Pie Company"} @@ -118,6 +119,7 @@ def test_report_item_based_without_aggregation(): pos = frappe.get_all("Purchase Order", ["name", "supplier", "grand_total"]) assert "Unity Bakery Supply" not in [p.get("supplier") for p in pos] for po in pos: + assert not po.multi_company_purchase_order if po.supplier == "Chelsea Fruit Co": assert po.grand_total == flt(501.07, 2) elif po.supplier == "Freedom Provisions": @@ -135,11 +137,11 @@ def test_report_item_based_without_aggregation(): rfq.delete() -@pytest.mark.order(22) -def test_report_po_with_aggregation_and_no_aggregation_warehouse(): +@pytest.mark.order(23) +def test_report_po_with_aggregation_and_aggregation_warehouse(): settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") settings.purchase_order_aggregation_company = settings.name - settings.aggregated_purchasing_warehouse = None + settings.aggregated_purchasing_warehouse = "Stores - CFC" settings.update_warehouse_path = True settings.save() @@ -166,28 +168,29 @@ def test_report_po_with_aggregation_and_no_aggregation_warehouse(): pos = [frappe.get_doc("Purchase Order", p) for p in frappe.get_all("Purchase Order")] assert "Unity Bakery Supply" not in [p.get("supplier") for p in pos] for po in pos: + assert po.multi_company_purchase_order if po.supplier == "Southern Fruit Supply": assert po.grand_total == flt(765.90, 2) for item in po.items: - mr_wh = frappe.get_value("Material Request Item", item.material_request_item, "warehouse") - assert item.warehouse == mr_wh + wh_company = frappe.get_value("Warehouse", item.warehouse, "company") + assert wh_company == po.company elif po.supplier == "Freedom Provisions": assert po.grand_total == flt(439.89, 2) for item in po.items: - mr_wh = frappe.get_value("Material Request Item", item.material_request_item, "warehouse") - assert item.warehouse == mr_wh + wh_company = frappe.get_value("Warehouse", item.warehouse, "company") + assert wh_company == po.company else: raise AssertionError(f"{po.supplier} should not be in this test") frappe.delete_doc("Purchase Order", po.name) -@pytest.mark.order(23) -def test_report_po_with_aggregation_and_aggregation_warehouse(): +@pytest.mark.order(24) +def test_report_po_with_aggregation_and_no_aggregation_warehouse(): settings = frappe.get_doc("Inventory Tools Settings", "Chelsea Fruit Co") settings.purchase_order_aggregation_company = settings.name - settings.aggregated_purchasing_warehouse = "Stores - CFC" + settings.aggregated_purchasing_warehouse = None settings.update_warehouse_path = True settings.save() @@ -214,18 +217,23 @@ def test_report_po_with_aggregation_and_aggregation_warehouse(): pos = [frappe.get_doc("Purchase Order", p) for p in frappe.get_all("Purchase Order")] assert "Unity Bakery Supply" not in [p.get("supplier") for p in pos] for po in pos: + assert po.multi_company_purchase_order if po.supplier == "Southern Fruit Supply": assert po.grand_total == flt(765.90, 2) for item in po.items: - wh_company = frappe.get_value("Warehouse", item.warehouse, "company") - assert wh_company == po.company + mr_wh = frappe.get_value("Material Request Item", item.material_request_item, "warehouse") + assert item.warehouse == mr_wh elif po.supplier == "Freedom Provisions": assert po.grand_total == flt(439.89, 2) for item in po.items: - wh_company = frappe.get_value("Warehouse", item.warehouse, "company") - assert wh_company == po.company + mr_wh = frappe.get_value("Material Request Item", item.material_request_item, "warehouse") + assert item.warehouse == mr_wh else: raise AssertionError(f"{po.supplier} should not be in this test") - frappe.delete_doc("Purchase Order", po.name) + + # NOTE: Don't delete so Purchase Receipt / aggregation workflows can be tested + # frappe.delete_doc("Purchase Order", po.name) + + po.submit() diff --git a/inventory_tools/www/__init__.py b/inventory_tools/www/__init__.py new file mode 100644 index 0000000..e69de29