diff --git a/.github/validate_customizations.py b/.github/validate_customizations.py index f942698..cd67c22 100644 --- a/.github/validate_customizations.py +++ b/.github/validate_customizations.py @@ -39,7 +39,7 @@ def validate_module(customized_doctypes, set_module=False): this_app = app_dir.stem for doctype, customize_files in customized_doctypes.items(): for customize_file in customize_files: - if not this_app in str(customize_file): + if not this_app == customize_file.parent.parent.parent.parent.stem: continue module = customize_file.parent.parent.stem file_contents = json.loads(customize_file.read_text()) @@ -85,7 +85,7 @@ def validate_no_custom_perms(customized_doctypes): this_app = pathlib.Path(__file__).resolve().parent.parent.stem for doctype, customize_files in customized_doctypes.items(): for customize_file in customize_files: - if not this_app in str(customize_file): + if not this_app == customize_file.parent.parent.parent.parent.stem: continue file_contents = json.loads(customize_file.read_text()) if file_contents.get("custom_perms"): diff --git a/inventory_tools/docs/assets/manufacturing_capacity_report.png b/inventory_tools/docs/assets/manufacturing_capacity_report.png new file mode 100644 index 0000000..2495bb2 Binary files /dev/null and b/inventory_tools/docs/assets/manufacturing_capacity_report.png differ diff --git a/inventory_tools/docs/index.md b/inventory_tools/docs/index.md index 5e41a69..01a0a61 100644 --- a/inventory_tools/docs/index.md +++ b/inventory_tools/docs/index.md @@ -7,6 +7,7 @@ The Inventory Tools application enhances and extends inventory-related functiona - **[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 +- **[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 Any feature in Inventory Tools may be toggled on or off via the Inventory Tools Settings document. The only exception to this is the Material Demand report, which is generally available upon installation of the app. There may be one settings document for each company in ERPNext to enable features on a per-company basis. Follow the links above for further details around feature-specific configuration. diff --git a/inventory_tools/docs/manufacturing_capacity.md b/inventory_tools/docs/manufacturing_capacity.md new file mode 100644 index 0000000..841a2d3 --- /dev/null +++ b/inventory_tools/docs/manufacturing_capacity.md @@ -0,0 +1,11 @@ +# Manufacturing Capacity Report + +Manufacturing Capacity is a report-based interface that, given a BOM and Warehouse, displays the demand and in-stock quantities for the entire hierarchy of any BOM tree containing that BOM. + +Once the filters are set, the report traverses the BOM tree to find the top-level parents of the given BOM. From there, it finds total demand based on outstanding Sales Orders, Material Requests (of type "Manufacture"), and Work Orders, adjusting for any overlap. In stock quantities for each level are determined based on the selected Warehouse. The Parts Can Build quantity is based on what is in stock (for non-BOM/raw material rows) or the minimum Parts can Build of sub-levels for BOM rows. + +The Parts Can Build Qty is slightly different for non-BOM vs BOM rows. For non-BOM (raw material) rows, it's the In Stock Qty divided by the Qty per Parent BOM. For BOM rows, it's the minimum of the Parts Can Build for all sub-assemblies. So if a BOM row requires a raw material that isn't in stock, it will show 0 Parts Can Build Qty, even if there are other sub assemblies in stock. + +The Difference Qty calculation is also different for non-BOM and BOM rows. Since non-BOM rows account for the In Stock Qty in the Parts Can Build Qty number, the Difference Qty is the Parts Can Build less the Demanded Qty. For BOM rows, since the Parts Can Build Qty is based off available sub-assembly item quantities (and doesn't use the In Stock Qty in that calculation), the Difference Qty is the In Stock Qty plus Parts Can Build Qty less the Demanded Qty. + +![Screen shot showing the Manufacturing Capacity report output for the Ambrosia Pie BOM and all Warehouses. There are rows for all levels of the BOM hierarchy - the Pie itself, sub-level rows for each sub-assembly of the Pie Crust and Pie Filling, with rows below each of those for the raw materials comprising each BOM. Columns include the BOM, Item, Description, Quantity per Parent BOM, BOM UoM, Demanded Quantity, In Stock Quantity, Parts Can Build quantity, and the Difference Quantity (demanded quantity less parts can build quantity).](./assets/manufacturing_capacity_report.png) diff --git a/inventory_tools/inventory_tools/overrides/work_order.py b/inventory_tools/inventory_tools/overrides/work_order.py index 3b93170..c093fa6 100644 --- a/inventory_tools/inventory_tools/overrides/work_order.py +++ b/inventory_tools/inventory_tools/overrides/work_order.py @@ -1,5 +1,4 @@ import frappe -from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children from erpnext.manufacturing.doctype.work_order.work_order import ( OverProductionError, StockOverProductionError, @@ -426,33 +425,6 @@ def add_to_existing_purchase_order(wo_name, po_name): return -def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, company, indent=0): - """ - Recursively collects sub-assembly item BOM data for a given 'parent' BOM (`bom_no`) - """ - data = get_bom_children(parent=bom_no) - for d in data: - if d.expandable: - parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") - stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) - - bom_data.append( - frappe._dict( - { - "parent_item_code": parent_item_code, - "production_item": d.item_code, - "bom_no": d.value, - "is_sub_contracted_item": d.is_sub_contracted_item, - "bom_level": indent, - "indent": indent, - } - ) - ) - - if d.value: - get_sub_assembly_items(d.value, bom_data, stock_qty, company, indent=indent + 1) - - @frappe.whitelist() def make_stock_entry(work_order_id, purpose, qty=None): se = _make_stock_entry(work_order_id, purpose, qty) diff --git a/inventory_tools/inventory_tools/report/__init__.py b/inventory_tools/inventory_tools/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inventory_tools/inventory_tools/report/manufacturing_capacity/__init__.py b/inventory_tools/inventory_tools/report/manufacturing_capacity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.js b/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.js new file mode 100644 index 0000000..ea848c3 --- /dev/null +++ b/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.js @@ -0,0 +1,30 @@ +// Copyright (c) 2024, AgriTheory and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports['Manufacturing Capacity'] = { + filters: [ + { + fieldname: 'bom', + label: __('BOM'), + fieldtype: 'Link', + options: 'BOM', + reqd: 1, + }, + { + fieldname: 'warehouse', + label: __('Warehouse'), + fieldtype: 'Link', + options: 'Warehouse', + reqd: 1, + }, + ], + formatter: function (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data) + + if (data && data.is_selected_bom) { + value = value.bold() + } + return value + }, +} diff --git a/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.json b/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.json new file mode 100644 index 0000000..147e169 --- /dev/null +++ b/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.json @@ -0,0 +1,30 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2024-02-16 12:17:16.951700", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2024-02-16 12:17:16.951700", + "modified_by": "Administrator", + "module": "Inventory Tools", + "name": "Manufacturing Capacity", + "owner": "Administrator", + "prepared_report": 0, + "query": "", + "ref_doctype": "BOM", + "report_name": "Manufacturing Capacity", + "report_type": "Script Report", + "roles": [ + { + "role": "Manufacturing Manager" + }, + { + "role": "Manufacturing User" + } + ] +} \ No newline at end of file diff --git a/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.py b/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.py new file mode 100644 index 0000000..f263199 --- /dev/null +++ b/inventory_tools/inventory_tools/report/manufacturing_capacity/manufacturing_capacity.py @@ -0,0 +1,337 @@ +# Copyright (c) 2024, AgriTheory and contributors +# For license information, please see license.txt + +import frappe +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Sum +from pypika.terms import ExistsCriterion + + +def execute(filters=None): + data = get_data(filters) + return get_columns(filters), data + + +def get_columns(filters=None): + return [ + { + "label": frappe._("BOM"), + "fieldname": "bom", + "fieldtype": "Link", + "options": "BOM", + "width": 200, + }, + { + "label": frappe._("Item"), + "fieldname": "item", + "fieldtype": "Link", + "options": "Item", + "width": 120, + }, + { + "label": frappe._("Description"), + "fieldname": "description", + "fieldtype": "Data", + "width": 150, + }, + { + "label": frappe._("Qty Per Parent BOM"), + "fieldname": "qty_per_parent_bom", + "fieldtype": "Float", + "width": 120, + }, + { + "label": frappe._("BOM UoM"), + "fieldname": "bom_uom", + "fieldtype": "Data", + "width": 100, + }, + { + "label": frappe._("Demanded Qty"), + "fieldname": "demanded_qty", + "fieldtype": "Float", + "width": 110, + }, + { + "label": frappe._("In Stock Qty"), + "fieldname": "in_stock_qty", + "fieldtype": "Float", + "width": 120, + }, + { + "label": frappe._("Parts Can Build"), + "fieldname": "parts_can_build_qty", + "fieldtype": "Float", + "width": 130, + }, + { + "label": frappe._("Difference Qty"), + "fieldname": "difference_qty", + "fieldtype": "Float", + "width": 130, + }, + { + "label": frappe._("Is Selected BOM"), + "fieldname": "is_selected_bom", + "fieldtype": "Int", + "width": 10, + "hidden": 1, + }, + ] + + +def get_data(filters=None): + parent_set = set() + get_bom_parents(filters.get("bom"), parent_set) + bom_data = [] + + for idx, bom_no in enumerate(parent_set, 1): + filters["root_parent_index"] = idx + demanded_qty = get_total_demand(bom_no) + indent = 0 + + # Append the root parent-level BOM data + parent_data = get_bom_data(bom_no, demanded_qty, filters, indent, is_root=True)[0] + bom_data.append(parent_data) + parent_index = len(bom_data) - 1 + + # Append sub-level BOM data + get_child_bom_data(bom_no, bom_data, demanded_qty, filters, indent=indent + 1, is_root=False) + + # Find the parts_can_build_qty for BOM levels - calculated as min of that BOM's children's parts_can_build_qty + bom_data[parent_index]["parts_can_build_qty"] = set_min_can_build( + idx, indent + 1, parent_data.bom, bom_data + ) + + # Recalculate the difference quantity for all rows + # - BOM rows: in_stock_qty + parts_can_build_qty - demanded_qty (parts can build based off sub-assembly availability, NOT what's already in stock, so need to include that separately) + # - Raw materials rows: parts_can_build_qty - demanded_qty (parts can build based off what's in stock, so already accounted for) + for row in bom_data: + if row.bom: + row.difference_qty = row.in_stock_qty + row.parts_can_build_qty - row.demanded_qty + else: + row.difference_qty = row.parts_can_build_qty - row.demanded_qty + + return bom_data + + +def get_bom_parents(bom_no, parent_set): + """ + Given the name of a BOM and a set, recursively looks for the top-level parent BOM and collects + them in parent_set + + :param bom_name: str, name of a BOM + :param parent_set: set + :return: None; set is manipulated in place + """ + item = frappe.get_value("BOM", bom_no, "item") + parents = frappe.get_all("BOM Item", {"item_name": item}, "parent") + if not parents: + parent_set.add(bom_no) + else: + for p in parents: + get_bom_parents(p["parent"], parent_set) + + +def get_total_demand(bom_no): + """ + For a given BOM, collects the manufacturing demand for the BOM item across outstanding Sales + Orders, Material Requests of type "Manufacture", and Work Orders. For SOs and MRs, nets out + the ordered quantity (accounted for in Work Orders created off them) and for WOs, nets out + any produced quantity. + + :param bom_no: str; BOM name for whose item to find demand for + :return: int | float + """ + item = frappe.get_value("BOM", bom_no, "item") + + so = frappe.qb.DocType("Sales Order") + so_item = frappe.qb.DocType("Sales Order Item") + so_status_criteria = [so.status == s for s in ("To Deliver and Bill", "To Deliver")] + + so_query = ( + frappe.qb.from_(so) + .inner_join(so_item) + .on(so_item.parent == so.name) + .select( + (Sum(so_item.stock_qty - so_item.work_order_qty)).as_( + "total" + ) # removes anything accounted for on a Work Order + ) + .where(so.docstatus == 1) + .where(Criterion.any(so_status_criteria)) + .where(so_item.item_code == item) + .groupby(so_item.item_code) + ).run(as_dict=True) + + mr = frappe.qb.DocType("Material Request") + mr_item = frappe.qb.DocType("Material Request Item") + + mr_query = ( + frappe.qb.from_(mr) + .inner_join(mr_item) + .on(mr_item.parent == mr.name) + .select( + (Sum(mr_item.stock_qty - mr_item.ordered_qty)).as_( + "total" + ) # removes ordered-qty (accounted for in a Work Order) + ) + .where(mr.docstatus == 1) + .where(mr.material_request_type == "Manufacture") + .where(mr_item.item_code == item) + .groupby(mr_item.item_code) + ).run(as_dict=True) + + wo = frappe.qb.DocType("Work Order") + wo_status_criteria = [wo.status == s for s in ("Submitted", "Not Started", "In Process")] + + wo_query = ( + frappe.qb.from_(wo) + .select((Sum(wo.qty - wo.produced_qty)).as_("total")) + .where(wo.docstatus == 1) + .where(wo.production_item == item) + .where(Criterion.any(wo_status_criteria)) + .groupby(wo.production_item) + ).run(as_dict=True) + + all_totals = so_query + mr_query + wo_query + return sum(d["total"] for d in all_totals) + + +def get_bom_data(bom_no, demanded_qty, filters, indent, is_root=False): + """ + Collects column data for either parent BOM (top-level) if is_root is True, or the BOM Item + data for all of the given BOM's children + + :param bom_no: str; BOM name to collect data for + :demanded_qty: int | float; the total demand for given BOM's item + :filters: dict; contains the data (BOM and Warehouse) passed on by user + :indent: int; current level in BOM hierarchy + :is_root: bool; True if the given bom_no is the top-level parent in a hierarchy, + False if not + """ + warehouse_details = frappe.db.get_value( + "Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1 + ) + + BOM = frappe.qb.DocType("BOM") + ITEM = frappe.qb.DocType("Item") + BOM_ITEM = frappe.qb.DocType("BOM Item") + BIN = frappe.qb.DocType("Bin") + WH = frappe.qb.DocType("Warehouse") + CONDITIONS = () + + if warehouse_details: + CONDITIONS = ExistsCriterion( + frappe.qb.from_(WH) + .select(WH.name) + .where( + (WH.lft >= warehouse_details.lft) + & (WH.rgt <= warehouse_details.rgt) + & (BIN.warehouse == WH.name) + ) + ) + else: + CONDITIONS = BIN.warehouse == filters.get("warehouse") + + if is_root: + query = ( + frappe.qb.from_(BOM) + .inner_join(ITEM) + .on(BOM.item == ITEM.item_code) + .left_join(BIN) + .on((ITEM.item_code == BIN.item_code) & (CONDITIONS)) + .select( + (BOM.name).as_("bom"), + (BOM.item).as_("item"), + ITEM.description, + (BOM.quantity).as_("qty_per_parent_bom"), + (ITEM.stock_uom).as_("bom_uom"), + (BOM.quantity * demanded_qty / BOM.quantity).as_( + "demanded_qty" + ), # redundant calc but needs cols and avoid errors + Sum(BIN.actual_qty).as_("in_stock_qty"), + Sum(BIN.actual_qty).as_("orig_parts_can_build_qty"), + ) + .where(BOM.name == bom_no) + .groupby(BOM.item) + ) + else: + query = ( + frappe.qb.from_(BOM) + .inner_join(BOM_ITEM) + .on(BOM.name == BOM_ITEM.parent) + .left_join(BIN) + .on((BOM_ITEM.item_code == BIN.item_code) & (CONDITIONS)) + .select( + (BOM_ITEM.bom_no).as_("bom"), + (BOM_ITEM.item_code).as_("item"), + BOM_ITEM.description, + (BOM_ITEM.stock_qty).as_("qty_per_parent_bom"), + (BOM_ITEM.stock_uom).as_("bom_uom"), + (BOM_ITEM.stock_qty * demanded_qty / BOM.quantity).as_("demanded_qty"), + Sum(BIN.actual_qty).as_("in_stock_qty"), + Sum(BIN.actual_qty / (BOM_ITEM.stock_qty / BOM.quantity)).as_("orig_parts_can_build_qty"), + ) + .where((BOM_ITEM.parent == bom_no) & (BOM_ITEM.parenttype == "BOM")) + .groupby(BOM_ITEM.item_code) + ) + + results = query.run(as_dict=True) + for r in results: + r.update( + { + "in_stock_qty": r.in_stock_qty or 0, + "orig_parts_can_build_qty": int(r.orig_parts_can_build_qty) + if r.orig_parts_can_build_qty + else 0, + "is_selected_bom": int(r.bom == filters.get("bom")), + "parent_bom": "" if is_root else bom_no, + "root_parent_index": filters.get("root_parent_index") or 0, + "indent": indent, + } + ) + + return results + + +def get_child_bom_data(bom_no, bom_data, demanded_qty, filters, indent=0, is_root=False): + """ + Recursively collects BOM tree data for a given 'parent' BOM, mutates bom_data in place + + :param bom_no: str; parent BOM name + :param bom_data: list + :param demanded_qty: int | float; the demanded quantity (to produce) of parent BOM + :filters: dict; holds report inputs + :indent: int; tracks the BOM levels + :is_root: bool; whether or not the current bom_no is the root parent BOM + :return: None; appends children to bom_data list in place + """ + children_data = get_bom_data(bom_no, demanded_qty, filters, indent=indent, is_root=is_root) + for child in children_data: + bom_data.append(child) + if child.bom: + demanded_qty = child.demanded_qty + get_child_bom_data(child.bom, bom_data, demanded_qty, filters, indent=indent + 1) + + +def set_min_can_build(root_parent_index, indent, parent_bom, bom_data): + """ + Recursively finds and sets the parts_can_build_qty by finding the direct children of the current + level in the hierarchy, and taking the minimum of their parts_can_build_qty + """ + sub_bom_list = [ + item + for item in bom_data + if item.root_parent_index == root_parent_index + and item.indent == indent + and item.parent_bom == parent_bom + ] + for item in sub_bom_list: + if not item.bom: + item["parts_can_build_qty"] = item.orig_parts_can_build_qty + else: + item["parts_can_build_qty"] = set_min_can_build( + item.root_parent_index, item.indent + 1, item.bom, bom_data + ) + return min(item.parts_can_build_qty for item in sub_bom_list) diff --git a/inventory_tools/tests/fixtures.py b/inventory_tools/tests/fixtures.py index 007c456..d7c44d3 100644 --- a/inventory_tools/tests/fixtures.py +++ b/inventory_tools/tests/fixtures.py @@ -117,7 +117,7 @@ - Store in refrigerator if not using immediately""", ), ( - "Mix Pie Crust Op", + "Mix Dough Op", "Mixer Station", "5", """- Combine flour, butter, salt, and ice water in mixer @@ -161,6 +161,25 @@ "Cool baked pies for at least 30 minutes before boxing", ["Cooling Station", "Refrigerator Station"], ), + ( + "Assemble Pocket Op", + "Food Prep Table 1", + "5", + """- Fold 3 poppers into dough pocket""", + ), + ( + "Assemble Popper Op", + "Food Prep Table 1", + "5", + """- Top dough bite with fruit""", + ), + ( + "Assemble Combination Product", + "Food Prep Table 1", + "5", + """- Tower: package one pie and one pocket, and one popper + - Pocketful of Bay: package one pocket with two poppers""", + ), ] items = [ @@ -196,6 +215,46 @@ "default_warehouse": "Refrigerated Display - APC", "description": "

Take your tastebuds on an adventure with this whimsical twist on the classic Key Lime pie. Made with kaduka limes and the exotic limequat, this seasonal pie is sure to satisfy even the most weary culinary explorer. Grab it when you can - it's only available April through September.

", }, + { + "item_code": "Tower of Bay-bel", + "uom": "Nos", + "item_group": "Baked Goods", + "item_price": 20.00, + "default_warehouse": "Refrigerated Display - APC", + "description": "

Reach for the stars with this epic all-things-bayberry dessert that stacks a Bayberry Pocket on top of our Bayberry Pie.

", + }, + { + "item_code": "Pocketful of Bay", + "uom": "Nos", + "item_group": "Baked Goods", + "item_price": 12.00, + "default_warehouse": "Refrigerated Display - APC", + "description": "

Try this delightful combination of a Bayberry Pocket and two additional Bayberry Poppers.

", + }, + { + "item_code": "Bayberry Pie", + "uom": "Nos", + "item_group": "Sub Assemblies", + # "item_price": 11.00, # can a finished good be included as sub-assembly for another good? + "default_warehouse": "Refrigerated Display - APC", + "description": "

This pie features the sweet and scrumptious bayberry and is sure to be a crowd-pleaser.

", + }, + { + "item_code": "Bayberry Pocket", + "uom": "Nos", + "item_group": "Sub Assemblies", + # "item_price": 8.00, + "default_warehouse": "Refrigerated Display - APC", + "description": "

Need a little more than one popper? The Bayberry Pocket is a tasty dough pocket stuffed with several Bayberry Poppers.

", + }, + { + "item_code": "Bayberry Popper", + "uom": "Nos", + "item_group": "Sub Assemblies", + # "item_price": 3.00, + "default_warehouse": "Refrigerated Display - APC", + "description": "

Part cookie, part tart, these bite-sized treats will bring a little sweetness to your day.

", + }, { "item_code": "Ambrosia Pie Filling", "uom": "Cup", @@ -217,6 +276,13 @@ "item_group": "Sub Assemblies", "default_warehouse": "Refrigerator - APC", }, + { + "item_code": "Bayberry Pie Filling", + "uom": "Cup", + "description": "Bayberry Pie Filling", + "item_group": "Sub Assemblies", + "default_warehouse": "Refrigerator - APC", + }, { "item_code": "Kaduka Key Lime Pie Filling", "item_group": "Sub Assemblies", @@ -264,7 +330,7 @@ { "item_code": "Cloudberry", "uom": "Pound", - "description": "Our Own Cloudberry", + "description": "Cloudberry", "item_group": "Ingredients", "item_price": 0.65, "default_warehouse": "Refrigerator - APC", @@ -327,12 +393,21 @@ { "item_code": "Tayberry", "uom": "Pound", - "description": "Tayberry - Box", + "description": "Tayberry", "item_group": "Ingredients", "item_price": 0.85, "default_warehouse": "Refrigerator - APC", "supplier": "Chelsea Fruit Co", }, + { + "item_code": "Bayberry", + "uom": "Pound", + "description": "Bayberry", + "item_group": "Ingredients", + "item_price": 0.45, + "default_warehouse": "Refrigerator - APC", + "supplier": "Chelsea Fruit Co", + }, { "item_code": "Butter", "uom": "Pound", @@ -428,6 +503,127 @@ ] boms = [ + { + "item": "Tower of Bay-bel", + "quantity": 5.0, + "uom": "Nos", + "items": [ + {"item_code": "Bayberry Pie", "qty": 5.0, "qty_consumed_per_unit": 1.0, "uom": "Nos"}, + {"item_code": "Bayberry Pocket", "qty": 5.0, "qty_consumed_per_unit": 1.0, "uom": "Nos"}, + ], + "operations": [ + { + "batch_size": 5, + "operation": "Assemble Combination Product", + "time_in_mins": 2.0, + "workstation": "Food Prep Table 1", + }, + ], + }, + { + "item": "Pocketful of Bay", + "quantity": 5.0, + "uom": "Nos", + "items": [ + {"item_code": "Bayberry Pocket", "qty": 5.0, "qty_consumed_per_unit": 1.0, "uom": "Nos"}, + {"item_code": "Bayberry Popper", "qty": 10.0, "qty_consumed_per_unit": 2.0, "uom": "Nos"}, + {"item_code": "Pie Box", "qty": 5.0, "qty_consumed_per_unit": 1.0, "uom": "Nos"}, + ], + "operations": [ + { + "batch_size": 5, + "operation": "Assemble Combination Product", + "time_in_mins": 2.0, + "workstation": "Food Prep Table 1", + }, + ], + }, + { + "item": "Bayberry Pocket", + "quantity": 5.0, + "uom": "Nos", + "items": [ + {"item_code": "Flour", "qty": 1.5, "qty_consumed_per_unit": 0.3, "uom": "Pound"}, + {"item_code": "Butter", "qty": 0.75, "qty_consumed_per_unit": 0.15, "uom": "Pound"}, + {"item_code": "Sugar", "qty": 0.1, "qty_consumed_per_unit": 0.02, "uom": "Pound"}, + {"item_code": "Bayberry Popper", "qty": 15.0, "qty_consumed_per_unit": 3.0, "uom": "Nos"}, + ], + "operations": [ + { + "batch_size": 5, + "operation": "Mix Dough Op", + "time_in_mins": 5.0, + "workstation": "Mixer Station", + }, + { + "batch_size": 5, + "operation": "Assemble Pocket Op", + "time_in_mins": 2.0, + "workstation": "Food Prep Table 1", + }, + ], + }, + { + "item": "Bayberry Popper", + "quantity": 5.0, + "uom": "Nos", + "items": [ + {"item_code": "Flour", "qty": 0.5, "qty_consumed_per_unit": 0.1, "uom": "Pound"}, + {"item_code": "Butter", "qty": 0.25, "qty_consumed_per_unit": 0.05, "uom": "Pound"}, + {"item_code": "Sugar", "qty": 0.05, "qty_consumed_per_unit": 0.01, "uom": "Pound"}, + {"item_code": "Bayberry", "qty": 1.0, "qty_consumed_per_unit": 0.2, "uom": "Pound"}, + ], + "operations": [ + { + "batch_size": 5, + "operation": "Mix Dough Op", + "time_in_mins": 5.0, + "workstation": "Mixer Station", + }, + { + "batch_size": 5, + "operation": "Assemble Popper Op", + "time_in_mins": 1.0, + "workstation": "Food Prep Table 1", + }, + ], + }, + { + "item": "Bayberry Pie", + "quantity": 5.0, + "uom": "Nos", + "items": [ + {"item_code": "Pie Crust", "qty": 5.0, "qty_consumed_per_unit": 1.0, "uom": "Nos"}, + { + "item_code": "Bayberry Pie Filling", + "qty": 20.0, + "qty_consumed_per_unit": 4.0, + "uom": "Cup", + }, + {"item_code": "Pie Box", "qty": 5.0, "qty_consumed_per_unit": 1.0, "uom": "Nos"}, + ], + "operations": [ + { + "batch_size": 5, + "operation": "Assemble Pie Op", + "time_in_mins": 10.0, + "workstation": "Food Prep Table 2", + }, + {"batch_size": 1, "operation": "Bake Op", "time_in_mins": 50.0, "workstation": "Oven Station"}, + { + "batch_size": 1, + "operation": "Cool Pie Op", + "time_in_mins": 30.0, + "workstation": "Cooling Racks Station", + }, + { + "batch_size": 5, + "operation": "Box Pie Op", + "time_in_mins": 5.0, + "workstation": "Packaging Station", + }, + ], + }, { "item": "Double Plum Pie", "quantity": 5.0, @@ -568,6 +764,32 @@ }, ], }, + { + "item": "Bayberry Pie Filling", + "quantity": 20.0, + "uom": "Cup", + "items": [ + {"item_code": "Sugar", "qty": 0.5, "qty_consumed_per_unit": 0.025, "uom": "Pound"}, + {"item_code": "Cornstarch", "qty": 0.1, "qty_consumed_per_unit": 0.005, "uom": "Pound"}, + {"item_code": "Water", "qty": 1.25, "qty_consumed_per_unit": 0.0625, "uom": "Cup"}, + {"item_code": "Butter", "qty": 0.313, "qty_consumed_per_unit": 0.01565, "uom": "Pound"}, + {"item_code": "Bayberry", "qty": 15.0, "qty_consumed_per_unit": 0.05025, "uom": "Pound"}, + ], + "operations": [ + { + "batch_size": 5, + "operation": "Gather Pie Filling Ingredients", + "time_in_mins": 5.0, + "workstation": "Food Prep Table 1", + }, + { + "batch_size": 5, + "operation": "Cook Pie Filling Operation", + "time_in_mins": 15.0, + "workstation": "Range Station", + }, + ], + }, { "item": "Double Plum Pie Filling", "quantity": 20.0, @@ -719,7 +941,7 @@ }, { "batch_size": 5, - "operation": "Mix Pie Crust Op", + "operation": "Mix Dough Op", "time_in_mins": 5.0, "workstation": "Mixer Station", }, diff --git a/inventory_tools/tests/setup.py b/inventory_tools/tests/setup.py index 23dbf81..30be1b6 100644 --- a/inventory_tools/tests/setup.py +++ b/inventory_tools/tests/setup.py @@ -413,7 +413,7 @@ def create_sales_order(settings): { "item_code": "Ambrosia Pie", "delivery_date": so.transaction_date, - "qty": 40, + "qty": 30, "warehouse": "Refrigerated Display - APC", }, ) @@ -422,7 +422,7 @@ def create_sales_order(settings): { "item_code": "Double Plum Pie", "delivery_date": so.transaction_date, - "qty": 40, + "qty": 30, "warehouse": "Refrigerated Display - APC", }, ) @@ -444,6 +444,24 @@ def create_sales_order(settings): "warehouse": "Refrigerated Display - APC", }, ) + so.append( + "items", + { + "item_code": "Pocketful of Bay", + "delivery_date": so.transaction_date, + "qty": 10, + "warehouse": "Refrigerated Display - APC", + }, + ) + so.append( + "items", + { + "item_code": "Tower of Bay-bel", + "delivery_date": so.transaction_date, + "qty": 20, + "warehouse": "Refrigerated Display - APC", + }, + ) so.save() so.submit() @@ -490,6 +508,24 @@ def create_material_request(settings): "warehouse": "Refrigerated Display - APC", }, ) + mr.append( + "items", + { + "item_code": "Pocketful of Bay", + "delivery_date": mr.schedule_date, + "qty": 10, + "warehouse": "Refrigerated Display - APC", + }, + ) + mr.append( + "items", + { + "item_code": "Tower of Bay-bel", + "delivery_date": mr.schedule_date, + "qty": 20, + "warehouse": "Refrigerated Display - APC", + }, + ) mr.save() mr.submit() @@ -523,10 +559,11 @@ def create_production_plan(settings, prod_plan_from_doc): for item in pp.sub_assembly_items: item.schedule_date = settings.day if item.production_item == "Pie Crust": + idx = item.idx item.type_of_manufacturing = "Subcontract" item.supplier = "Credible Contract Baking" item.qty = 50 - pp.append("sub_assembly_items", pp.sub_assembly_items[0].as_dict()) + pp.append("sub_assembly_items", pp.sub_assembly_items[idx - 1].as_dict()) pp.sub_assembly_items[-1].name = None pp.sub_assembly_items[-1].type_of_manufacturing = "In House" pp.sub_assembly_items[-1].bom_no = "BOM-Pie Crust-001" diff --git a/inventory_tools/tests/test_manufacturing_capacity.py b/inventory_tools/tests/test_manufacturing_capacity.py new file mode 100644 index 0000000..4ce3533 --- /dev/null +++ b/inventory_tools/tests/test_manufacturing_capacity.py @@ -0,0 +1,85 @@ +import frappe +import pytest +from erpnext.manufacturing.doctype.work_order.work_order import ( + create_job_card, + make_stock_entry, + make_work_order, +) +from frappe.exceptions import ValidationError +from frappe.utils import getdate + +from inventory_tools.inventory_tools.report.manufacturing_capacity.manufacturing_capacity import ( + get_total_demand, +) +from inventory_tools.tests.fixtures import customers + + +def test_total_demand(): + company = frappe.defaults.get_defaults().get("company") + pocketful_item = "Pocketful of Bay" + pocketful_bom_no = frappe.get_value( + "BOM", {"item": pocketful_item, "is_active": 1, "is_default": 1} + ) + tower_item = "Tower of Bay-bel" + tower_bom_no = frappe.get_value("BOM", {"item": tower_item, "is_active": 1, "is_default": 1}) + + # Create a Sales Order that hasn't generated a Work Order + so = frappe.new_doc("Sales Order") + so.transaction_date = getdate() + so.customer = customers[-1] + so.order_type = "Sales" + so.currency = "USD" + so.selling_price_list = "Bakery Wholesale" + so.append( + "items", + { + "item_code": "Pocketful of Bay", + "delivery_date": so.transaction_date, + "qty": 5, + "warehouse": "Refrigerated Display - APC", + }, + ) + so.append( + "items", + { + "item_code": "Tower of Bay-bel", + "delivery_date": so.transaction_date, + "qty": 10, + "warehouse": "Refrigerated Display - APC", + }, + ) + so.save() + so.submit() + + # Create a Material Request for Manufacture + mr = frappe.new_doc("Material Request") + mr.transaction_date = mr.schedule_date = getdate() + mr.material_request_type == "Manufacture" + mr.title = "Tower and Pocketful" + mr.company = company + mr.append( + "items", + { + "item_code": pocketful_item, + "delivery_date": mr.schedule_date, + "qty": 15, + "warehouse": "Refrigerated Display - APC", + }, + ) + mr.append( + "items", + { + "item_code": tower_item, + "delivery_date": mr.schedule_date, + "qty": 5, + "warehouse": "Refrigerated Display - APC", + }, + ) + mr.save() + mr.submit() + + pocketful_demand = get_total_demand(pocketful_bom_no) + assert pocketful_demand == 30 # test data of 10 + SO of 5 + MR of 15 + + tower_demand = get_total_demand(tower_bom_no) + assert tower_demand == 35 # test data of 20 + SO of 10 + MR of 5