')
+ .css({
+ overflow: 'auto',
+ minHeight: '200px',
+ })
+ .appendTo(this.container)[0]
+
+ this.resizer = $('
')
+ .css({
+ height: '10px',
+ background: '#e0e0e0',
+ cursor: 'row-resize',
+ margin: '5px 0',
+ '&:hover': {
+ background: '#bdbdbd',
+ },
+ })
+ .appendTo(this.container)
+
+ this.actual = $('
')
+ .css({
+ overflow: 'auto',
+ minHeight: '200px',
+ })
+ .appendTo(this.container)[0]
+
+ $(this.container).css({
+ display: 'grid',
+ 'grid-template-rows': '40vh 10px 40vh',
+ gap: '0',
+ height: '85vh',
+ })
+
+ this.setup_resizer()
+ }
+
+ init_gantt() {
+ const filters = {
+ work_order: this.page.fields_dict.work_order.get_value(),
+ production_item: this.page.fields_dict.production_item.get_value(),
+ }
+
+ frappe
+ .xcall('inventory_tools.inventory_tools.page.optimizer.get_work_order_gantt_data', {
+ ...filters,
+ })
+ .then(r => {
+ this.all_tasks = r
+ this.actual_gantt = new Gantt(this.actual, r, {
+ view_mode: 'Quarter Day',
+ on_click: task => {
+ frappe.set_route('Form', 'Work Order', task.id)
+ },
+ })
+ this.output_gantt = new Gantt(this.output, r, {
+ view_mode: 'Quarter Day',
+ on_click: task => {
+ frappe.set_route('Form', 'Work Order', task.id)
+ },
+ on_date_change: (task, start, end) => {
+ this.update_work_order(task.id, start, end)
+ },
+ })
+ })
+ }
+
+ update_work_order(name, start, end) {
+ frappe.call({
+ method: 'frappe.client.set_value',
+ args: {
+ doctype: 'Work Order',
+ name: name,
+ fieldname: {
+ planned_start_date: start,
+ planned_end_date: end,
+ },
+ },
+ })
+ }
+ setup_resizer() {
+ return
+ let startY, startHeightTop, startHeightBottom
+
+ this.resizer.on('mousedown', e => {
+ startY = e.clientY
+ startHeightTop = $(this.output).height()
+ startHeightBottom = $(this.actual).height()
+
+ $(document).on('mousemove', mousemove)
+ $(document).on('mouseup', mouseup)
+ })
+
+ const mousemove = e => {
+ const diff = e.clientY - startY
+ $(this.output).height(startHeightTop + diff)
+ $(this.actual).height(startHeightBottom - diff)
+ }
+
+ const mouseup = () => {
+ $(document).off('mousemove', mousemove)
+ $(document).off('mouseup', mouseup)
+ }
+ }
+
+ setup_paging_events() {
+ this.list_paging_area.find('.btn-paging').click(e => {
+ const view_mode = $(e.target).data('value')
+ console.log(view_mode)
+ this.output_gantt?.change_view_mode(view_mode)
+ this.actual_gantt?.change_view_mode(view_mode)
+ })
+ }
+}
diff --git a/inventory_tools/inventory_tools/page/optimizer/optimizer.json b/inventory_tools/inventory_tools/page/optimizer/optimizer.json
new file mode 100644
index 00000000..2170611e
--- /dev/null
+++ b/inventory_tools/inventory_tools/page/optimizer/optimizer.json
@@ -0,0 +1,29 @@
+{
+ "content": null,
+ "creation": "2024-12-02 15:01:35.785409",
+ "docstatus": 0,
+ "doctype": "Page",
+ "icon": "gantt",
+ "idx": 0,
+ "modified": "2024-12-02 15:01:35.785409",
+ "modified_by": "Administrator",
+ "module": "Inventory Tools",
+ "name": "optimizer",
+ "owner": "Administrator",
+ "page_name": "optimizer",
+ "roles": [
+ {
+ "role": "Manufacturing Manager"
+ },
+ {
+ "role": "System Manager"
+ },
+ {
+ "role": "Stock Manager"
+ }
+ ],
+ "script": null,
+ "standard": "Yes",
+ "style": null,
+ "system_page": 0
+}
diff --git a/inventory_tools/public/js/custom/work_order_list.js b/inventory_tools/public/js/custom/work_order_list.js
index 4a739212..b795fb7e 100644
--- a/inventory_tools/public/js/custom/work_order_list.js
+++ b/inventory_tools/public/js/custom/work_order_list.js
@@ -1,8 +1,16 @@
-// Copyright (c) 2025, AgriTheory and contributors
+// Copyright (c) 2024, AgriTheory and contributors
// For license information, please see license.txt
frappe.listview_settings['Work Order'] = {
refresh: listview => {
+ listview.page.add_custom_menu_item(
+ $('[data-view]').parent(),
+ __('Optimizer'),
+ () => frappe.set_route('/optimizer'),
+ true,
+ null,
+ 'gantt'
+ )
listview.page.add_custom_menu_item(
$('[data-view]').parent(),
__('Alternative Workstations'),
diff --git a/inventory_tools/tests/fixtures.py b/inventory_tools/tests/fixtures.py
index 6f9eaf42..bec953c3 100644
--- a/inventory_tools/tests/fixtures.py
+++ b/inventory_tools/tests/fixtures.py
@@ -75,25 +75,145 @@
),
]
+
workstations = [
- ("Mix Pie Crust Station", "20", "Table", "mixer.png", "mixer.png"),
- ("Roll Pie Crust Station", "20", "Table", "rolling.png", "rolling.png"),
- ("Make Pie Filling Station", "20", "Table", "table.png", "table.png"),
- ("Cooling Station", "100", "Table", "rack.png", "rack.png"),
- ("Box Pie Station", "100", "Table", "box.png", "box.png"),
- ("Baking Station", "20", "Table", "oven.png", "oven.png"),
- ("Assemble Pie Station", "20", "Table", "table.png", "table.png"),
- ("Mix Pie Filling Station", "20", "Table", "mixer.png", "mixer.png"),
- ("Packaging Station", "2", "Table", "box.png", "box.png"),
- ("Food Prep Table 2", "10", "Table", "table.png", "table.png"),
- ("Food Prep Table 1", "5", "Table", "table.png", "table.png"),
- ("Range Station", "20", "Range", "range.png", "range.png"),
- ("Cooling Racks Station", "80", "Cooling", "rack.png", "rack.png"),
- ("Refrigerator Station", "200", "Refrigerator", "fridge.png", "fridge.png"),
- ("Oven Station", "20", "Oven", "oven.png", "oven.png"),
- ("Mixer Station", "10", "Mixer", "mixer.png", "mixer.png"),
+ {
+ "workstation_name": "Mix Pie Crust Station",
+ "production_capacity": "20",
+ "type": "Table",
+ "off_status_image": "mixer.png",
+ "on_status_image": "mixer.png",
+ "shift_types": ["Day Shift", "Evening Shift"],
+ },
+ {
+ "workstation_name": "Roll Pie Crust Station",
+ "production_capacity": "20",
+ "type": "Table",
+ "off_status_image": "rolling.png",
+ "on_status_image": "rolling.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Make Pie Filling Station",
+ "production_capacity": "20",
+ "type": "Table",
+ "off_status_image": "table.png",
+ "on_status_image": "table.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Cooling Station",
+ "production_capacity": "100",
+ "type": "Table",
+ "off_status_image": "rack.png",
+ "on_status_image": "rack.png",
+ "shift_types": ["Day Shift", "Evening Shift"],
+ },
+ {
+ "workstation_name": "Box Pie Station",
+ "production_capacity": "100",
+ "type": "Table",
+ "off_status_image": "box.png",
+ "on_status_image": "box.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Baking Station",
+ "production_capacity": "20",
+ "type": "Table",
+ "off_status_image": "oven.png",
+ "on_status_image": "oven.png",
+ "shift_types": ["Day Shift", "Evening Shift"],
+ },
+ {
+ "workstation_name": "Assemble Pie Station",
+ "production_capacity": "20",
+ "type": "Table",
+ "off_status_image": "table.png",
+ "on_status_image": "table.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Mix Pie Filling Station",
+ "production_capacity": "20",
+ "type": "Table",
+ "off_status_image": "mixer.png",
+ "on_status_image": "mixer.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Packaging Station",
+ "production_capacity": "2",
+ "type": "Table",
+ "off_status_image": "box.png",
+ "on_status_image": "box.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Food Prep Table 2",
+ "production_capacity": "10",
+ "type": "Table",
+ "off_status_image": "table.png",
+ "on_status_image": "table.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Food Prep Table 1",
+ "production_capacity": "5",
+ "type": "Table",
+ "off_status_image": "table.png",
+ "on_status_image": "table.png",
+ "shift_types": ["Day Shift", "Evening Shift"],
+ },
+ {
+ "workstation_name": "Range Station",
+ "production_capacity": "20",
+ "type": "Range",
+ "off_status_image": "range.png",
+ "on_status_image": "range.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Cooling Racks Station",
+ "production_capacity": "80",
+ "type": "Cooling",
+ "off_status_image": "rack.png",
+ "on_status_image": "rack.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Refrigerator Station",
+ "production_capacity": "200",
+ "type": "Refrigerator",
+ "off_status_image": "fridge.png",
+ "on_status_image": "fridge.png",
+ "shift_types": ["Day Shift"],
+ },
+ {
+ "workstation_name": "Oven Station",
+ "production_capacity": "20",
+ "type": "Oven",
+ "off_status_image": "oven.png",
+ "on_status_image": "oven.png",
+ "shift_types": ["Day Shift", "Evening Shift"],
+ },
+ {
+ "workstation_name": "Mixer Station",
+ "production_capacity": "10",
+ "type": "Mixer",
+ "off_status_image": "mixer.png",
+ "on_status_image": "mixer.png",
+ "shift_types": ["Day Shift"],
+ },
]
+shifts = [
+ {"name": "Day Shift", "start_time": "06:00:00", "end_time": "14:00:00", "color": "Yellow"},
+ {"name": "Evening Shift", "start_time": "14:00:00", "end_time": "22:00:00", "color": "Blue"},
+ {"name": "Night Shift", "start_time": "22:00:00", "end_time": "06:00:00", "color": "Violet"},
+]
+
+
operations = [
(
"Gather Pie Crust Ingredients",
@@ -196,9 +316,10 @@
"Food Prep Table 1",
"5",
"""- Tower: package one pie and one pocket, and one popper
- - Pocketful of Bay: package one pocket with two poppers""",
+ - Pocketful of Bay: package one pocket with two poppers""",
),
]
+
attributes = {
"Ambrosia Pie": {
"Fruits": ["Hairless Rambutan", "Cloudberry", "Tayberry"],
diff --git a/inventory_tools/tests/fixtures/items_stockentry.json b/inventory_tools/tests/fixtures/items_stock_entry.json
similarity index 100%
rename from inventory_tools/tests/fixtures/items_stockentry.json
rename to inventory_tools/tests/fixtures/items_stock_entry.json
diff --git a/inventory_tools/tests/setup.py b/inventory_tools/tests/setup.py
index 69f4fc48..9da51622 100644
--- a/inventory_tools/tests/setup.py
+++ b/inventory_tools/tests/setup.py
@@ -11,13 +11,14 @@
)
from erpnext.setup.utils import set_defaults_for_tests
from frappe.desk.page.setup_wizard.setup_wizard import setup_complete
-from frappe.utils.data import add_months, flt, getdate, nowdate, get_datetime
+from frappe.utils.data import add_months, flt, getdate, nowdate
from webshop.webshop.doctype.website_item.website_item import make_website_item
from inventory_tools.tests.fixtures import (
operations,
suppliers,
workstations,
+ shifts,
)
@@ -30,7 +31,7 @@ def read_json(name):
CUSTOMERS = read_json("customers")
ITEM_DIMENSIONS = read_json("item_dimensions")
ITEMS = read_json("items")
-ITEMS_STOCKENTRY = read_json("items_stockentry")
+ITEMS_STOCK_ENTRY = read_json("items_stock_entry")
SPECIFICATIONS = read_json("specifications")
WAREHOUSE_DIMENSIONS = read_json("warehouse_dimensions")
WAREHOUSE_LOCATIONS = read_json("warehouse_locations")
@@ -107,6 +108,7 @@ def create_test_data():
create_warehouses(settings)
create_warehouse_locations()
setup_manufacturing_settings(settings)
+ create_shift_types()
create_workstations(settings)
create_operations()
create_item_groups(settings)
@@ -230,12 +232,27 @@ def setup_manufacturing_settings(settings):
)
frappe.set_value("Inventory Tools Settings", settings.company, "create_purchase_orders", 0)
frappe.set_value(
- "Inventory Tools Settings", settings.company, "overproduction_percentage_for_work_order", 50
+ "Inventory Tools Settings",
+ settings.company,
+ "overproduction_percentage_for_work_order",
+ 50,
)
frappe.set_value("Inventory Tools Settings", settings.company, "show_on_website", 1)
frappe.set_value("Inventory Tools Settings", settings.company, "show_in_listview", 1)
+def create_shift_types():
+ for shift in shifts:
+ if frappe.db.exists("Shift Type", shift["name"]):
+ continue
+ sh = frappe.new_doc("Shift Type")
+ sh.name = shift["name"]
+ sh.start_time = shift["start_time"]
+ sh.end_time = shift["end_time"]
+ sh.color = shift["color"]
+ sh.save()
+
+
def create_workstations(settings):
if not frappe.db.exists("Plant Floor", "Kitchen"):
pf = frappe.new_doc("Plant Floor")
@@ -244,21 +261,42 @@ def create_workstations(settings):
pf.warehouse = "Kitchen - APC"
pf.plant_floor_layout = "/files/floor_plan.png"
pf.save()
+
for ws in workstations:
- if not frappe.db.exists("Workstation Type", ws[2]):
+ # Create workstation type if it doesn't exist
+ if not frappe.db.exists("Workstation Type", ws["type"]):
wst = frappe.new_doc("Workstation Type")
- wst.workstation_type = ws[2]
+ wst.workstation_type = ws["type"]
wst.save()
- if frappe.db.exists("Workstation", ws[0]):
- work = frappe.get_doc("Workstation", ws[0])
+
+ # Create or update workstation
+ if frappe.db.exists("Workstation", ws["workstation_name"]):
+ work = frappe.get_doc("Workstation", ws["workstation_name"])
else:
work = frappe.new_doc("Workstation")
- work.workstation_name = ws[0]
- work.production_capacity = ws[1]
- work.workstation_type = ws[2]
+
+ work.workstation_name = ws["workstation_name"]
+ work.production_capacity = ws["production_capacity"]
+ work.workstation_type = ws["type"]
work.plant_floor = "Kitchen"
- work.off_status_image = f"/files/{ws[3]}"
- work.on_status_image = f"/files/{ws[4]}"
+ work.off_status_image = f"/files/{ws['off_status_image']}"
+ work.on_status_image = f"/files/{ws['on_status_image']}"
+
+ work.working_hours = []
+ for shift_type in ws["shift_types"]:
+ for sh in shifts:
+ if sh["name"] == shift_type:
+ result = sh
+ break
+ work.append(
+ "working_hours",
+ {
+ "shift_type": shift_type,
+ "start_time": result["start_time"],
+ "end_time": result["end_time"],
+ },
+ )
+
work.save()
@@ -709,22 +747,39 @@ def create_production_plan(settings, prod_plan_from_doc):
},
)
pp.get_mr_items()
- for item in pp.po_items:
- item.planned_start_date = settings.day
+
+ pp.po_items = sorted(pp.po_items, key=lambda x: x.get("item_code"))
+
+ for idx, item in enumerate(pp.po_items):
+ item.planned_start_date = settings.day + datetime.timedelta(days=idx)
+
pp.get_sub_assembly_items()
- for item in pp.sub_assembly_items:
- item.schedule_date = settings.day
+ start_time = datetime.datetime(settings.day.year, settings.day.month, settings.day.day, 6, 0)
+ pp.append(
+ "sub_assembly_items",
+ {
+ "schedule_date": start_time,
+ "production_item": "Pie Crust",
+ "name": None,
+ "type_of_manufacturing": "In House",
+ "idx": 1,
+ "qty": 50,
+ "bom_no": "BOM-Pie Crust-001",
+ "bom_level": 1,
+ "supplier": None,
+ "for_warehouse": "Storeroom - APC",
+ },
+ )
+ for idx, item in enumerate(
+ sorted(pp.sub_assembly_items, key=lambda x: (-abs(x.bom_level), x.idx)), start=1
+ ):
+ item.idx = idx
+ item.schedule_date = start_time
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[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"
- pp.sub_assembly_items[-1].supplier = None
- pp.for_warehouse = "Storeroom - APC"
+ time = frappe.get_value("BOM Operation", {"parent": item.bom_no}, "SUM(time_in_mins) AS time")
+ if time:
+ start_time += datetime.timedelta(minutes=time + 2)
raw_materials = get_items_for_material_requests(
pp.as_dict(), warehouses=None, get_parent_warehouse_data=None
)
@@ -753,10 +808,21 @@ def create_production_plan(settings, prod_plan_from_doc):
for wo in wos:
wo = frappe.get_doc("Work Order", wo)
wo.wip_warehouse = "Kitchen - APC"
+ current_year = str(settings.day.year)
wo.save()
wo.submit()
job_cards = frappe.get_all("Job Card", {"work_order": wo.name})
- start_time = get_datetime()
+ wo.planned_start_date = start_time
+ time = frappe.get_value("BOM Operation", {"parent": item.bom_no}, "SUM(time_in_mins) AS time")
+ if time:
+ wo.planned_end_date = wo.planned_start_date + datetime.timedelta(minutes=time + 2)
+ wo.required_items = sorted(wo.required_items, key=lambda x: x.get("item_code"))
+ for idx, w in enumerate(wo.required_items, start=1):
+ w.idx = idx
+ w.required_qty = flt(w.required_qty, 3)
+ wo.save()
+ wo.submit()
+ frappe.db.set_value("Work Order", wo.name, "creation", start_time)
for job_card in job_cards:
job_card = frappe.get_doc("Job Card", job_card)
batch_size, total_operation_time = frappe.get_value(
@@ -846,7 +912,10 @@ def create_quotations(settings):
"conversion_rate": 1,
"transaction_date": nowdate(),
"valid_till": add_months(nowdate(), 1),
- "items": [{"item_code": "Ambrosia Pie", "qty": 1}, {"item_code": "Gooseberry Pie", "qty": 5}],
+ "items": [
+ {"item_code": "Ambrosia Pie", "qty": 1},
+ {"item_code": "Gooseberry Pie", "qty": 5},
+ ],
"company": settings.company,
}
quotation.update(values)
@@ -863,7 +932,10 @@ def create_quotations(settings):
"conversion_rate": 1,
"transaction_date": nowdate(),
"valid_till": add_months(nowdate(), 1),
- "items": [{"item_code": "Ambrosia Pie", "qty": 1}, {"item_code": "Gooseberry Pie", "qty": 5}],
+ "items": [
+ {"item_code": "Ambrosia Pie", "qty": 1},
+ {"item_code": "Gooseberry Pie", "qty": 5},
+ ],
"company": settings.company,
}
quotation.update(values)
@@ -880,7 +952,10 @@ def create_quotations(settings):
"conversion_rate": 1,
"transaction_date": nowdate(),
"valid_till": add_months(nowdate(), 1),
- "items": [{"item_code": "Ambrosia Pie", "qty": 2}, {"item_code": "Double Plum Pie", "qty": 1}],
+ "items": [
+ {"item_code": "Ambrosia Pie", "qty": 2},
+ {"item_code": "Double Plum Pie", "qty": 1},
+ ],
"company": settings.company,
}
quotation.update(values)
@@ -897,7 +972,10 @@ def create_quotations(settings):
"conversion_rate": 1,
"transaction_date": nowdate(),
"valid_till": add_months(nowdate(), 1),
- "items": [{"item_code": "Ambrosia Pie", "qty": 5}, {"item_code": "Double Plum Pie", "qty": 10}],
+ "items": [
+ {"item_code": "Ambrosia Pie", "qty": 5},
+ {"item_code": "Double Plum Pie", "qty": 10},
+ ],
"company": "Chelsea Fruit Co",
}
quotation.update(values)
@@ -968,7 +1046,7 @@ def create_warehouse_dimensions():
def create_stock_entries():
- j = len(ITEMS_STOCKENTRY) // 2
+ j = len(ITEMS_STOCK_ENTRY) // 2
# Add items to warehouse
se = frappe.new_doc("Stock Entry")
se.company = "Chelsea Fruit Co"
@@ -976,7 +1054,7 @@ def create_stock_entries():
se.set_posting_time = 1
se.stock_entry_type = "Material Receipt"
- for item in ITEMS_STOCKENTRY[0:j]:
+ for item in ITEMS_STOCK_ENTRY[0:j]:
se.append(
"items",
{
@@ -997,7 +1075,7 @@ def create_stock_entries():
se.set_posting_time = 1
se.stock_entry_type = "Material Receipt"
- for item in ITEMS_STOCKENTRY[j:]:
+ for item in ITEMS_STOCK_ENTRY[j:]:
se.append(
"items",
{