diff --git a/erpnext/manufacturing/doctype/production_order/production_order.py b/erpnext/manufacturing/doctype/production_order/production_order.py deleted file mode 100644 index 67930629f5..0000000000 --- a/erpnext/manufacturing/doctype/production_order/production_order.py +++ /dev/null @@ -1,648 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -from __future__ import unicode_literals -import frappe -import json -from six import text_type -from frappe import _ -from frappe.utils import flt, get_datetime, getdate, date_diff, cint, nowdate -from frappe.model.document import Document -from erpnext.manufacturing.doctype.bom.bom import validate_bom_no, get_bom_items_as_dict -from dateutil.relativedelta import relativedelta -from erpnext.stock.doctype.item.item import validate_end_of_life -from erpnext.manufacturing.doctype.workstation.workstation import WorkstationHolidayError -from erpnext.projects.doctype.timesheet.timesheet import OverlapError -from erpnext.stock.doctype.stock_entry.stock_entry import get_additional_costs -from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import get_mins_between_operations -from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty -from frappe.utils.csvutils import getlink -from erpnext.stock.utils import get_bin, validate_warehouse_company, get_latest_stock_qty -from erpnext.utilities.transaction_base import validate_uom_is_integer - -class OverProductionError(frappe.ValidationError): pass -class StockOverProductionError(frappe.ValidationError): pass -class OperationTooLongError(frappe.ValidationError): pass -class ItemHasVariantError(frappe.ValidationError): pass - -form_grid_templates = { - "operations": "templates/form_grid/production_order_grid.html" -} - -class ProductionOrder(Document): - def validate(self): - self.validate_production_item() - if self.bom_no: - validate_bom_no(self.production_item, self.bom_no) - - self.validate_sales_order() - self.set_default_warehouse() - self.validate_warehouse_belongs_to_company() - self.calculate_operating_cost() - self.validate_qty() - self.validate_operation_time() - self.status = self.get_status() - - validate_uom_is_integer(self, "stock_uom", ["qty", "produced_qty"]) - - self.set_required_items(reset_only_qty = len(self.get("required_items"))) - - def validate_sales_order(self): - if self.sales_order: - so = frappe.db.sql(""" - select so.name, so_item.delivery_date, so.project - from `tabSales Order` so - inner join `tabSales Order Item` so_item on so_item.parent = so.name - left join `tabProduct Bundle Item` pk_item on so_item.item_code = pk_item.parent - where so.name=%s and so.docstatus = 1 and ( - so_item.item_code=%s or - pk_item.item_code=%s ) - """, (self.sales_order, self.production_item, self.production_item), as_dict=1) - - if not so: - so = frappe.db.sql(""" - select - so.name, so_item.delivery_date, so.project - from - `tabSales Order` so, `tabSales Order Item` so_item, `tabPacked Item` packed_item - where so.name=%s - and so.name=so_item.parent - and so.name=packed_item.parent - and so_item.item_code = packed_item.parent_item - and so.docstatus = 1 and packed_item.item_code=%s - """, (self.sales_order, self.production_item), as_dict=1) - - if len(so): - if not self.expected_delivery_date: - self.expected_delivery_date = so[0].delivery_date - - if so[0].project: - self.project = so[0].project - - if not self.material_request: - self.validate_production_order_against_so() - else: - frappe.throw(_("Sales Order {0} is not valid").format(self.sales_order)) - - def set_default_warehouse(self): - if not self.wip_warehouse: - self.wip_warehouse = frappe.db.get_single_value("Manufacturing Settings", "default_wip_warehouse") - if not self.fg_warehouse: - self.fg_warehouse = frappe.db.get_single_value("Manufacturing Settings", "default_fg_warehouse") - - def validate_warehouse_belongs_to_company(self): - warehouses = [self.fg_warehouse, self.wip_warehouse] - for d in self.get("required_items"): - if d.source_warehouse not in warehouses: - warehouses.append(d.source_warehouse) - - for wh in warehouses: - validate_warehouse_company(wh, self.company) - - def calculate_operating_cost(self): - self.planned_operating_cost, self.actual_operating_cost = 0.0, 0.0 - for d in self.get("operations"): - d.planned_operating_cost = flt(d.hour_rate) * (flt(d.time_in_mins) / 60.0) - d.actual_operating_cost = flt(d.hour_rate) * (flt(d.actual_operation_time) / 60.0) - - self.planned_operating_cost += flt(d.planned_operating_cost) - self.actual_operating_cost += flt(d.actual_operating_cost) - - variable_cost = self.actual_operating_cost if self.actual_operating_cost \ - else self.planned_operating_cost - self.total_operating_cost = flt(self.additional_operating_cost) + flt(variable_cost) - - def validate_production_order_against_so(self): - # already ordered qty - ordered_qty_against_so = frappe.db.sql("""select sum(qty) from `tabProduction Order` - where production_item = %s and sales_order = %s and docstatus < 2 and name != %s""", - (self.production_item, self.sales_order, self.name))[0][0] - - total_qty = flt(ordered_qty_against_so) + flt(self.qty) - - # get qty from Sales Order Item table - so_item_qty = frappe.db.sql("""select sum(stock_qty) from `tabSales Order Item` - where parent = %s and item_code = %s""", - (self.sales_order, self.production_item))[0][0] - # get qty from Packing Item table - dnpi_qty = frappe.db.sql("""select sum(qty) from `tabPacked Item` - where parent = %s and parenttype = 'Sales Order' and item_code = %s""", - (self.sales_order, self.production_item))[0][0] - # total qty in SO - so_qty = flt(so_item_qty) + flt(dnpi_qty) - - allowance_percentage = flt(frappe.db.get_single_value("Manufacturing Settings", - "over_production_allowance_percentage")) - - if total_qty > so_qty + (allowance_percentage/100 * so_qty): - frappe.throw(_("Cannot produce more Item {0} than Sales Order quantity {1}") - .format(self.production_item, so_qty), OverProductionError) - - def update_status(self, status=None): - '''Update status of production order if unknown''' - if status != "Stopped": - status = self.get_status(status) - - if status != self.status: - self.db_set("status", status) - - self.update_required_items() - - return status - - def get_status(self, status=None): - '''Return the status based on stock entries against this production order''' - if not status: - status = self.status - - if self.docstatus==0: - status = 'Draft' - elif self.docstatus==1: - if status != 'Stopped': - stock_entries = frappe._dict(frappe.db.sql("""select purpose, sum(fg_completed_qty) - from `tabStock Entry` where production_order=%s and docstatus=1 - group by purpose""", self.name)) - - status = "Not Started" - if stock_entries: - status = "In Process" - produced_qty = stock_entries.get("Manufacture") - if flt(produced_qty) == flt(self.qty): - status = "Completed" - else: - status = 'Cancelled' - - return status - - def update_production_order_qty(self): - """Update **Manufactured Qty** and **Material Transferred for Qty** in Production Order - based on Stock Entry""" - - for purpose, fieldname in (("Manufacture", "produced_qty"), - ("Material Transfer for Manufacture", "material_transferred_for_manufacturing")): - qty = flt(frappe.db.sql("""select sum(fg_completed_qty) - from `tabStock Entry` where production_order=%s and docstatus=1 - and purpose=%s""", (self.name, purpose))[0][0]) - - if qty > self.qty: - frappe.throw(_("{0} ({1}) cannot be greater than planned quanitity ({2}) in Production Order {3}").format(\ - self.meta.get_label(fieldname), qty, self.qty, self.name), StockOverProductionError) - - self.db_set(fieldname, qty) - - def before_submit(self): - self.make_time_logs() - - def on_submit(self): - if not self.wip_warehouse: - frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) - if not self.fg_warehouse: - frappe.throw(_("For Warehouse is required before Submit")) - - self.update_reserved_qty_for_production() - self.update_completed_qty_in_material_request() - self.update_planned_qty() - - def on_cancel(self): - self.validate_cancel() - - frappe.db.set(self,'status', 'Cancelled') - self.delete_timesheet() - self.update_completed_qty_in_material_request() - self.update_planned_qty() - self.update_reserved_qty_for_production() - - def validate_cancel(self): - if self.status == "Stopped": - frappe.throw(_("Stopped Production Order cannot be cancelled, Unstop it first to cancel")) - - # Check whether any stock entry exists against this Production Order - stock_entry = frappe.db.sql("""select name from `tabStock Entry` - where production_order = %s and docstatus = 1""", self.name) - if stock_entry: - frappe.throw(_("Cannot cancel because submitted Stock Entry {0} exists").format(stock_entry[0][0])) - - def update_planned_qty(self): - update_bin_qty(self.production_item, self.fg_warehouse, { - "planned_qty": get_planned_qty(self.production_item, self.fg_warehouse) - }) - - if self.material_request: - mr_obj = frappe.get_doc("Material Request", self.material_request) - mr_obj.update_requested_qty([self.material_request_item]) - - def update_completed_qty_in_material_request(self): - if self.material_request: - frappe.get_doc("Material Request", self.material_request).update_completed_qty([self.material_request_item]) - - def set_production_order_operations(self): - """Fetch operations from BOM and set in 'Production Order'""" - self.set('operations', []) - - if not self.bom_no \ - or cint(frappe.db.get_single_value("Manufacturing Settings", "disable_capacity_planning")): - return - - if self.use_multi_level_bom: - bom_list = frappe.get_doc("BOM", self.bom_no).traverse_tree() - else: - bom_list = [self.bom_no] - - operations = frappe.db.sql(""" - select - operation, description, workstation, idx, - base_hour_rate as hour_rate, time_in_mins, - "Pending" as status, parent as bom - from - `tabBOM Operation` - where - parent in (%s) order by idx - """ % ", ".join(["%s"]*len(bom_list)), tuple(bom_list), as_dict=1) - - self.set('operations', operations) - self.calculate_time() - - def calculate_time(self): - bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") - - for d in self.get("operations"): - d.time_in_mins = flt(d.time_in_mins) / flt(bom_qty) * flt(self.qty) - - self.calculate_operating_cost() - - def get_holidays(self, workstation): - holiday_list = frappe.db.get_value("Workstation", workstation, "holiday_list") - - holidays = {} - - if holiday_list not in holidays: - holiday_list_days = [getdate(d[0]) for d in frappe.get_all("Holiday", fields=["holiday_date"], - filters={"parent": holiday_list}, order_by="holiday_date", limit_page_length=0, as_list=1)] - - holidays[holiday_list] = holiday_list_days - - return holidays[holiday_list] - - def make_time_logs(self, open_new=False): - """Capacity Planning. Plan time logs based on earliest availablity of workstation after - Planned Start Date. Time logs will be created and remain in Draft mode and must be submitted - before manufacturing entry can be made.""" - - if not self.operations: - return - - timesheets = [] - plan_days = frappe.db.get_single_value("Manufacturing Settings", "capacity_planning_for_days") or 30 - - timesheet = make_timesheet(self.name, self.company) - timesheet.set('time_logs', []) - - for i, d in enumerate(self.operations): - - if d.status != 'Completed': - self.set_start_end_time_for_workstation(d, i) - - args = self.get_operations_data(d) - - add_timesheet_detail(timesheet, args) - original_start_time = d.planned_start_time - - # validate operating hours if workstation [not mandatory] is specified - try: - timesheet.validate_time_logs() - except OverlapError: - if frappe.message_log: frappe.message_log.pop() - timesheet.schedule_for_production_order(d.idx) - except WorkstationHolidayError: - if frappe.message_log: frappe.message_log.pop() - timesheet.schedule_for_production_order(d.idx) - - from_time, to_time = self.get_start_end_time(timesheet, d.name) - - if date_diff(from_time, original_start_time) > cint(plan_days): - frappe.throw(_("Unable to find Time Slot in the next {0} days for Operation {1}").format(plan_days, d.operation)) - break - - d.planned_start_time = from_time - d.planned_end_time = to_time - d.db_update() - - if timesheet and open_new: - return timesheet - - if timesheet and timesheet.get("time_logs"): - timesheet.save() - timesheets.append(getlink("Timesheet", timesheet.name)) - - self.planned_end_date = self.operations[-1].planned_end_time - if timesheets: - frappe.local.message_log = [] - frappe.msgprint(_("Timesheet created:") + "\n" + "\n".join(timesheets)) - - def get_operations_data(self, data): - return { - 'from_time': get_datetime(data.planned_start_time), - 'hours': data.time_in_mins / 60.0, - 'to_time': get_datetime(data.planned_end_time), - 'project': self.project, - 'operation': data.operation, - 'operation_id': data.name, - 'workstation': data.workstation, - 'completed_qty': flt(self.qty) - flt(data.completed_qty) - } - - def set_start_end_time_for_workstation(self, data, index): - """Set start and end time for given operation. If first operation, set start as - `planned_start_date`, else add time diff to end time of earlier operation.""" - - if index == 0: - data.planned_start_time = self.planned_start_date - else: - data.planned_start_time = get_datetime(self.operations[index-1].planned_end_time)\ - + get_mins_between_operations() - - data.planned_end_time = get_datetime(data.planned_start_time) + relativedelta(minutes = data.time_in_mins) - - if data.planned_start_time == data.planned_end_time: - frappe.throw(_("Capacity Planning Error")) - - def get_start_end_time(self, timesheet, operation_id): - for data in timesheet.time_logs: - if data.operation_id == operation_id: - return data.from_time, data.to_time - - def check_operation_fits_in_working_hours(self, d): - """Raises expection if operation is longer than working hours in the given workstation.""" - from erpnext.manufacturing.doctype.workstation.workstation import check_if_within_operating_hours - check_if_within_operating_hours(d.workstation, d.operation, d.planned_start_time, d.planned_end_time) - - def update_operation_status(self): - for d in self.get("operations"): - if not d.completed_qty: - d.status = "Pending" - elif flt(d.completed_qty) < flt(self.qty): - d.status = "Work in Progress" - elif flt(d.completed_qty) == flt(self.qty): - d.status = "Completed" - else: - frappe.throw(_("Completed Qty can not be greater than 'Qty to Manufacture'")) - - def set_actual_dates(self): - self.actual_start_date = None - self.actual_end_date = None - if self.get("operations"): - actual_start_dates = [d.actual_start_time for d in self.get("operations") if d.actual_start_time] - if actual_start_dates: - self.actual_start_date = min(actual_start_dates) - - actual_end_dates = [d.actual_end_time for d in self.get("operations") if d.actual_end_time] - if actual_end_dates: - self.actual_end_date = max(actual_end_dates) - - def delete_timesheet(self): - for timesheet in frappe.get_all("Timesheet", ["name"], {"production_order": self.name}): - frappe.delete_doc("Timesheet", timesheet.name) - - def validate_production_item(self): - if frappe.db.get_value("Item", self.production_item, "has_variants"): - frappe.throw(_("Production Order cannot be raised against a Item Template"), ItemHasVariantError) - - if self.production_item: - validate_end_of_life(self.production_item) - - def validate_qty(self): - if not self.qty > 0: - frappe.throw(_("Quantity to Manufacture must be greater than 0.")) - - def validate_operation_time(self): - for d in self.operations: - if not d.time_in_mins > 0: - frappe.throw(_("Operation Time must be greater than 0 for Operation {0}".format(d.operation))) - - def update_required_items(self): - ''' - update bin reserved_qty_for_production - called from Stock Entry for production, after submit, cancel - ''' - if self.docstatus==1: - # calculate transferred qty based on submitted stock entries - self.update_transaferred_qty_for_required_items() - - # update in bin - self.update_reserved_qty_for_production() - - def update_reserved_qty_for_production(self, items=None): - '''update reserved_qty_for_production in bins''' - for d in self.required_items: - if d.source_warehouse: - stock_bin = get_bin(d.item_code, d.source_warehouse) - stock_bin.update_reserved_qty_for_production() - - def get_items_and_operations_from_bom(self): - self.set_required_items() - self.set_production_order_operations() - - return check_if_scrap_warehouse_mandatory(self.bom_no) - - def set_available_qty(self): - for d in self.get("required_items"): - if d.source_warehouse: - d.available_qty_at_source_warehouse = get_latest_stock_qty(d.item_code, d.source_warehouse) - - if self.wip_warehouse: - d.available_qty_at_wip_warehouse = get_latest_stock_qty(d.item_code, self.wip_warehouse) - - def set_required_items(self, reset_only_qty=False): - '''set required_items for production to keep track of reserved qty''' - if not reset_only_qty: - self.required_items = [] - - if self.bom_no and self.qty: - item_dict = get_bom_items_as_dict(self.bom_no, self.company, qty=self.qty, - fetch_exploded = self.use_multi_level_bom) - - if reset_only_qty: - for d in self.get("required_items"): - if item_dict.get(d.item_code): - d.required_qty = item_dict.get(d.item_code).get("qty") - else: - for item in sorted(item_dict.values(), key=lambda d: d['idx']): - self.append('required_items', { - 'item_code': item.item_code, - 'item_name': item.item_name, - 'description': item.description, - 'required_qty': item.qty, - 'source_warehouse': item.source_warehouse or item.default_warehouse - }) - - self.set_available_qty() - - def update_transaferred_qty_for_required_items(self): - '''update transferred qty from submitted stock entries for that item against - the production order''' - - for d in self.required_items: - transferred_qty = frappe.db.sql('''select sum(qty) - from `tabStock Entry` entry, `tabStock Entry Detail` detail - where - entry.production_order = %s - and entry.purpose = "Material Transfer for Manufacture" - and entry.docstatus = 1 - and detail.parent = entry.name - and detail.item_code = %s''', (self.name, d.item_code))[0][0] - - d.db_set('transferred_qty', flt(transferred_qty), update_modified = False) - - -@frappe.whitelist() -def get_item_details(item, project = None): - res = frappe.db.sql(""" - select stock_uom, description - from `tabItem` - where disabled=0 - and (end_of_life is null or end_of_life='0000-00-00' or end_of_life > %s) - and name=%s - """, (nowdate(), item), as_dict=1) - - if not res: - return {} - - res = res[0] - - filters = {"item": item, "is_default": 1} - - if project: - filters = {"item": item, "project": project} - - res["bom_no"] = frappe.db.get_value("BOM", filters = filters) - - if not res["bom_no"]: - variant_of= frappe.db.get_value("Item", item, "variant_of") - - if variant_of: - res["bom_no"] = frappe.db.get_value("BOM", filters={"item": variant_of, "is_default": 1}) - - if not res["bom_no"]: - if project: - res = get_item_details(item) - frappe.msgprint(_("Default BOM not found for Item {0} and Project {1}").format(item, project), alert=1) - else: - frappe.throw(_("Default BOM for {0} not found").format(item)) - - res['project'] = project or frappe.db.get_value('BOM', res['bom_no'], 'project') - res.update(check_if_scrap_warehouse_mandatory(res["bom_no"])) - - return res - -@frappe.whitelist() -def check_if_scrap_warehouse_mandatory(bom_no): - res = {"set_scrap_wh_mandatory": False } - if bom_no: - bom = frappe.get_doc("BOM", bom_no) - - if len(bom.scrap_items) > 0: - res["set_scrap_wh_mandatory"] = True - - return res - -@frappe.whitelist() -def set_production_order_ops(name): - po = frappe.get_doc('Production Order', name) - po.set_production_order_operations() - po.save() - -@frappe.whitelist() -def make_stock_entry(production_order_id, purpose, qty=None): - production_order = frappe.get_doc("Production Order", production_order_id) - if not frappe.db.get_value("Warehouse", production_order.wip_warehouse, "is_group") \ - and not production_order.skip_transfer: - wip_warehouse = production_order.wip_warehouse - else: - wip_warehouse = None - - stock_entry = frappe.new_doc("Stock Entry") - stock_entry.purpose = purpose - stock_entry.production_order = production_order_id - stock_entry.company = production_order.company - stock_entry.from_bom = 1 - stock_entry.bom_no = production_order.bom_no - stock_entry.use_multi_level_bom = production_order.use_multi_level_bom - stock_entry.fg_completed_qty = qty or (flt(production_order.qty) - flt(production_order.produced_qty)) - stock_entry.set_stock_entry_type() - - if purpose=="Material Transfer for Manufacture": - stock_entry.to_warehouse = wip_warehouse - stock_entry.project = production_order.project - else: - stock_entry.from_warehouse = wip_warehouse - stock_entry.to_warehouse = production_order.fg_warehouse - additional_costs = get_additional_costs(production_order, fg_qty=stock_entry.fg_completed_qty) - stock_entry.project = production_order.project - stock_entry.set("additional_costs", additional_costs) - - stock_entry.get_items() - return stock_entry.as_dict() - -@frappe.whitelist() -def make_timesheet(production_order, company): - timesheet = frappe.new_doc("Timesheet") - timesheet.employee = "" - timesheet.production_order = production_order - timesheet.company = company - return timesheet - -@frappe.whitelist() -def add_timesheet_detail(timesheet, args): - if isinstance(timesheet, text_type): - timesheet = frappe.get_doc('Timesheet', timesheet) - - if isinstance(args, text_type): - args = json.loads(args) - - timesheet.append('time_logs', args) - return timesheet - -@frappe.whitelist() -def get_default_warehouse(): - wip_warehouse = frappe.db.get_single_value("Manufacturing Settings", - "default_wip_warehouse") - fg_warehouse = frappe.db.get_single_value("Manufacturing Settings", - "default_fg_warehouse") - return {"wip_warehouse": wip_warehouse, "fg_warehouse": fg_warehouse} - -@frappe.whitelist() -def make_new_timesheet(source_name, target_doc=None): - po = frappe.get_doc('Production Order', source_name) - ts = po.make_time_logs(open_new=True) - - if not ts or not ts.get('time_logs'): - frappe.throw(_("Already completed")) - - return ts - -@frappe.whitelist() -def stop_unstop(production_order, status): - """ Called from client side on Stop/Unstop event""" - - if not frappe.has_permission("Production Order", "write"): - frappe.throw(_("Not permitted"), frappe.PermissionError) - - pro_order = frappe.get_doc("Production Order", production_order) - pro_order.update_status(status) - pro_order.update_planned_qty() - frappe.msgprint(_("Production Order has been {0}").format(status)) - pro_order.notify_update() - - return pro_order.status - -@frappe.whitelist() -def query_sales_order(production_item): - out = frappe.db.sql_list(""" - select distinct so.name from `tabSales Order` so, `tabSales Order Item` so_item - where so_item.parent=so.name and so_item.item_code=%s and so.docstatus=1 - union - select distinct so.name from `tabSales Order` so, `tabPacked Item` pi_item - where pi_item.parent=so.name and pi_item.item_code=%s and so.docstatus=1 - """, (production_item, production_item)) - - return out