From fac4035f23b9b655da466ba6a436533b821b61d8 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 7 Dec 2020 21:35:49 +0530 Subject: [PATCH] feat: Apply Putaway Rules within transaction itself - Added checkbox 'Apply Putaway Rule' in PR and SE - Added link to rule in child tables - Rule is applied on Save - Validation for over receipt - Apply Rule on Stock Entry as well for Material Transfer and Receipt --- .../doctype/purchase_order/purchase_order.py | 2 - erpnext/controllers/stock_controller.py | 38 +++++ erpnext/public/js/controllers/buying.js | 23 +++ .../purchase_receipt/purchase_receipt.js | 4 + .../purchase_receipt/purchase_receipt.py | 7 + .../purchase_receipt_item.json | 12 +- .../doctype/putaway_rule/putaway_rule.py | 153 ++++++++++++------ .../stock/doctype/stock_entry/stock_entry.js | 4 + .../doctype/stock_entry/stock_entry.json | 10 +- .../stock/doctype/stock_entry/stock_entry.py | 8 + .../stock_entry_detail.json | 14 +- 11 files changed, 217 insertions(+), 58 deletions(-) diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index bb67eb92c0..d32e98e8d9 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -349,9 +349,7 @@ def close_or_unclose_purchase_orders(names, status): frappe.local.message_log = [] def set_missing_values(source, target): - from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule target.ignore_pricing_rule = 1 - target.items = apply_putaway_rule(target.items, target.company) target.run_method("set_missing_values") target.run_method("calculate_taxes_and_totals") diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 2d2fff8fd5..c7fadde16e 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -6,6 +6,7 @@ import frappe, erpnext from frappe.utils import cint, flt, cstr, get_link_to_form, today, getdate from frappe import _ import frappe.defaults +from collections import defaultdict from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries, process_gl_map from erpnext.controllers.accounts_controller import AccountsController @@ -23,6 +24,7 @@ class StockController(AccountsController): self.validate_inspection() self.validate_serialized_batch() self.validate_customer_provided_item() + self.validate_putaway_capacity() def make_gl_entries(self, gl_entries=None): if self.docstatus == 2: @@ -399,6 +401,42 @@ class StockController(AccountsController): if frappe.db.get_value('Item', d.item_code, 'is_customer_provided_item'): d.allow_zero_valuation_rate = 1 + def validate_putaway_capacity(self): + # if over receipt is attempted while 'apply putaway rule' is disabled + # and if rule was applied on the transaction, validate it. + from erpnext.stock.doctype.putaway_rule.putaway_rule import get_putaway_capacity + valid_doctype = self.doctype in ("Purchase Receipt", "Stock Entry") + rule_applied = any(item.get("putaway_rule") for item in self.get("items")) + + if valid_doctype and rule_applied and not self.apply_putaway_rule: + rule_map = defaultdict(dict) + for item in self.get("items"): + if item.get("putaway_rule"): + rule = item.get("putaway_rule") + disabled = frappe.db.get_value("Putaway Rule", rule, "disable") + if disabled: return # dont validate for disabled rule + stock_qty = flt(item.transfer_qty) if self.doctype == "Stock Entry" else flt(item.stock_qty) + warehouse_field = "t_warehouse" if self.doctype == "Stock Entry" else "warehouse" + if not rule_map[rule]: + rule_map[rule]["warehouse"] = item.get(warehouse_field) + rule_map[rule]["item"] = item.get("item_code") + rule_map[rule]["qty_put"] = 0 + rule_map[rule]["capacity"] = get_putaway_capacity(rule) + rule_map[rule]["qty_put"] += flt(stock_qty) + + for rule, values in rule_map.items(): + if flt(values["qty_put"]) > flt(values["capacity"]): + message = _("{0} qty of Item {1} is being received into Warehouse {2} with capacity {3}.") \ + .format( + frappe.bold(values["qty_put"]), frappe.bold(values["item"]), + frappe.bold(values["warehouse"]), frappe.bold(values["capacity"]) + ) + message += "

" + rule_link = frappe.utils.get_link_to_form("Putaway Rule", rule) + message += _(" Please adjust the qty or edit {0} to proceed.").format(rule_link) + frappe.throw(msg=message, title=_("Over Receipt")) + return rule_map + def compare_existing_and_expected_gle(existing_gle, expected_gle): matched = True for entry in expected_gle: diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index 58ac38f0a8..ac48d451b9 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -524,3 +524,26 @@ erpnext.buying.get_items_from_product_bundle = function(frm) { dialog.show(); } + +erpnext.apply_putaway_rule = (frm) => { + if (!frm.doc.company) { + frappe.throw({message:__("Please select a Company first."), title: __("Mandatory")}) + } + if (!frm.doc.items.length) return; + + frappe.call({ + method: "erpnext.stock.doctype.putaway_rule.putaway_rule.apply_putaway_rule", + args: { + items: frm.doc.items, + company: frm.doc.company + }, + callback: (result) => { + if(!result.exc) { + if(result.message) { + frm.doc.items = result.message; + frm.get_field("items").refresh(); + } + } + } + }); +} \ No newline at end of file diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index bc1d81d356..45eb646d4b 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -213,6 +213,10 @@ erpnext.stock.PurchaseReceiptController = erpnext.buying.BuyingController.extend }); }, + apply_putaway_rule: function() { + // if (this.frm.doc.apply_putaway_rule) erpnext.apply_putaway_rule(this.frm); + } + }); // for backward compatibility: combine new and previous states diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 2cc4679c8c..511bae6f58 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -70,6 +70,12 @@ class PurchaseReceipt(BuyingController): where name=`tabPurchase Invoice Item`.parent and is_return=1 and update_stock=1)""" }) + def before_save(self): + from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule + + if self.get("items") and self.apply_putaway_rule: + self.items = apply_putaway_rule(self.doctype, self.get("items"), self.company) + def validate(self): self.validate_posting_time() super(PurchaseReceipt, self).validate() @@ -90,6 +96,7 @@ class PurchaseReceipt(BuyingController): if getdate(self.posting_date) > getdate(nowdate()): throw(_("Posting Date cannot be future date")) + def validate_cwip_accounts(self): for item in self.get('items'): if item.is_fixed_asset and is_cwip_accounting_enabled(item.asset_category): diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index c1e1f901ba..fcbf6ccf6e 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -72,6 +72,7 @@ "purchase_order_item", "material_request_item", "purchase_receipt_item", + "putaway_rule", "section_break_45", "allow_zero_valuation_rate", "bom", @@ -834,12 +835,21 @@ "collapsible": 1, "fieldname": "image_column", "fieldtype": "Column Break" + }, + { + "fieldname": "putaway_rule", + "fieldtype": "Link", + "label": "Putaway Rule", + "no_copy": 1, + "options": "Putaway Rule", + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-04-28 19:01:21.154963", + "modified": "2020-11-26 12:16:14.897160", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py index 606e190458..8838bb75f1 100644 --- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py @@ -5,11 +5,14 @@ from __future__ import unicode_literals import frappe import copy +import json from collections import defaultdict +from six import string_types from frappe import _ -from frappe.utils import flt, floor, nowdate +from frappe.utils import flt, floor, nowdate, cint from frappe.model.document import Document from erpnext.stock.utils import get_stock_balance +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos class PutawayRule(Document): def validate(self): @@ -52,70 +55,50 @@ class PutawayRule(Document): def set_stock_capacity(self): self.stock_capacity = (flt(self.conversion_factor) or 1) * flt(self.capacity) -def get_ordered_putaway_rules(item_code, company): - """Returns an ordered list of putaway rules to apply on an item.""" - rules = frappe.get_all("Putaway Rule", - fields=["name", "item_code", "stock_capacity", "priority", "warehouse"], - filters={"item_code": item_code, "company": company, "disable": 0}, - order_by="priority asc, capacity desc") - - if not rules: - return False, None - - for rule in rules: - balance_qty = get_stock_balance(rule.item_code, rule.warehouse, nowdate()) - free_space = flt(rule.stock_capacity) - flt(balance_qty) - if free_space > 0: - rule["free_space"] = free_space - else: - del rule - - if not rules: - # After iterating through rules, if no rules are left - # then there is not enough space left in any rule - return True, None - - rules = sorted(rules, key = lambda i: (i['priority'], -i['free_space'])) - return False, rules +@frappe.whitelist() +def get_putaway_capacity(rule): + stock_capacity, item_code, warehouse = frappe.db.get_value("Putaway Rule", rule, + ["stock_capacity", "item_code", "warehouse"]) + balance_qty = get_stock_balance(item_code, warehouse, nowdate()) + free_space = flt(stock_capacity) - flt(balance_qty) + return free_space if free_space > 0 else 0 @frappe.whitelist() -def apply_putaway_rule(items, company): +def apply_putaway_rule(doctype, items, company): """ Applies Putaway Rule on line items. items: List of Purchase Receipt Item objects company: Company in the Purchase Receipt """ + if isinstance(items, string_types): + items = json.loads(items) + items_not_accomodated, updated_table = [], [] item_wise_rules = defaultdict(list) - def add_row(item, to_allocate, warehouse): - new_updated_table_row = copy.deepcopy(item) - new_updated_table_row.name = '' - new_updated_table_row.idx = 1 if not updated_table else flt(updated_table[-1].idx) + 1 - new_updated_table_row.qty = to_allocate - new_updated_table_row.stock_qty = flt(to_allocate) * flt(new_updated_table_row.conversion_factor) - new_updated_table_row.warehouse = warehouse - updated_table.append(new_updated_table_row) - for item in items: - conversion = flt(item.conversion_factor) - uom_must_be_whole_number = frappe.db.get_value('UOM', item.uom, 'must_be_whole_number') - pending_qty, pending_stock_qty, item_code = flt(item.qty), flt(item.stock_qty), item.item_code + if isinstance(item, dict): + item = frappe._dict(item) - if not pending_qty: - add_row(item, pending_qty, item.warehouse) + source_warehouse = item.get("s_warehouse") + serial_nos = get_serial_nos(item.get("serial_no")) + conversion = flt(item.conversion_factor) or 1 + pending_qty, item_code = flt(item.qty), item.item_code + pending_stock_qty = flt(item.transfer_qty) if doctype == "Stock Entry" else flt(item.stock_qty) + if not pending_qty or not item_code: + updated_table = add_row(item, pending_qty, item.warehouse, updated_table) continue - at_capacity, rules = get_ordered_putaway_rules(item_code, company) + uom_must_be_whole_number = frappe.db.get_value('UOM', item.uom, 'must_be_whole_number') + + at_capacity, rules = get_ordered_putaway_rules(item_code, company, source_warehouse=source_warehouse) if not rules: + warehouse = item.warehouse if at_capacity: - # rules available, but no free space - add_row(item, pending_qty, '') + warehouse = '' # rules available, but no free space items_not_accomodated.append([item_code, pending_qty]) - else: - # no rules to apply - add_row(item, pending_qty, item.warehouse) + updated_table = add_row(item, pending_qty, warehouse, updated_table) continue # maintain item wise rules, to handle if item is entered twice @@ -126,7 +109,7 @@ def apply_putaway_rule(items, company): for rule in item_wise_rules[item_code]: if pending_stock_qty > 0 and rule.free_space: stock_qty_to_allocate = flt(rule.free_space) if pending_stock_qty >= flt(rule.free_space) else pending_stock_qty - qty_to_allocate = stock_qty_to_allocate / (conversion or 1) + qty_to_allocate = stock_qty_to_allocate / (conversion) if uom_must_be_whole_number: qty_to_allocate = floor(qty_to_allocate) @@ -134,7 +117,8 @@ def apply_putaway_rule(items, company): if not qty_to_allocate: break - add_row(item, qty_to_allocate, rule.warehouse) + updated_table = add_row(item, qty_to_allocate, rule.warehouse, updated_table, + rule.name, serial_nos=serial_nos) pending_stock_qty -= stock_qty_to_allocate pending_qty -= qty_to_allocate @@ -144,15 +128,71 @@ def apply_putaway_rule(items, company): # if pending qty after applying all rules, add row without warehouse if pending_stock_qty > 0: - add_row(item, pending_qty, '') + # updated_table = add_row(item, pending_qty, '', updated_table, serial_nos=serial_nos) items_not_accomodated.append([item.item_code, pending_qty]) if items_not_accomodated: - format_unassigned_items_error(items_not_accomodated) + show_unassigned_items_message(items_not_accomodated) return updated_table if updated_table else items -def format_unassigned_items_error(items_not_accomodated): +def get_ordered_putaway_rules(item_code, company, source_warehouse=None): + """Returns an ordered list of putaway rules to apply on an item.""" + filters = { + "item_code": item_code, + "company": company, + "disable": 0 + } + if source_warehouse: + filters.update({"warehouse": ["!=", source_warehouse]}) + + rules = frappe.get_all("Putaway Rule", + fields=["name", "item_code", "stock_capacity", "priority", "warehouse"], + filters=filters, + order_by="priority asc, capacity desc") + + if not rules: + return False, None + + vacant_rules = [] + for rule in rules: + balance_qty = get_stock_balance(rule.item_code, rule.warehouse, nowdate()) + free_space = flt(rule.stock_capacity) - flt(balance_qty) + if free_space > 0: + rule["free_space"] = free_space + vacant_rules.append(rule) + + if not vacant_rules: + # After iterating through rules, if no rules are left + # then there is not enough space left in any rule + return True, None + + vacant_rules = sorted(vacant_rules, key = lambda i: (i['priority'], -i['free_space'])) + + return False, vacant_rules + +def add_row(item, to_allocate, warehouse, updated_table, rule=None, serial_nos=None): + new_updated_table_row = copy.deepcopy(item) + new_updated_table_row.idx = 1 if not updated_table else cint(updated_table[-1].idx) + 1 + new_updated_table_row.name = "New " + str(item.doctype) + " " + str(new_updated_table_row.idx) + new_updated_table_row.qty = to_allocate + new_updated_table_row.stock_qty = flt(to_allocate) * flt(new_updated_table_row.conversion_factor) + if item.doctype == "Stock Entry Detail": + new_updated_table_row.t_warehouse = warehouse + else: + new_updated_table_row.warehouse = warehouse + new_updated_table_row.rejected_qty = 0 + new_updated_table_row.received_qty = to_allocate + + if rule: + new_updated_table_row.putaway_rule = rule + if serial_nos: + new_updated_table_row.serial_no = get_serial_nos_to_allocate(serial_nos, to_allocate) + + updated_table.append(new_updated_table_row) + return updated_table + +def show_unassigned_items_message(items_not_accomodated): msg = _("The following Items, having Putaway Rules, could not be accomodated:") + "

" formatted_item_rows = "" @@ -173,4 +213,11 @@ def format_unassigned_items_error(items_not_accomodated): """.format(_("Item"), _("Unassigned Qty"), formatted_item_rows) - frappe.msgprint(msg, title=_("Insufficient Capacity"), is_minimizable=True, wide=True) \ No newline at end of file + frappe.msgprint(msg, title=_("Insufficient Capacity"), is_minimizable=True, wide=True) + +def get_serial_nos_to_allocate(serial_nos, to_allocate): + if serial_nos: + allocated_serial_nos = serial_nos[0: cint(to_allocate)] + serial_nos[:] = serial_nos[cint(to_allocate):] # pop out allocated serial nos and modify list + return "\n".join(allocated_serial_nos) if allocated_serial_nos else "" + else: return "" \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 91217582ca..2be70f37f2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -571,6 +571,10 @@ frappe.ui.form.on('Stock Entry', { } }); } + }, + + apply_putaway_rule: function(frm) { + // if (frm.doc.apply_putaway_rule) erpnext.apply_putaway_rule(frm); } }) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 61e0df6723..cd01fa7277 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -27,6 +27,7 @@ "set_posting_time", "inspection_required", "from_bom", + "apply_putaway_rule", "sb1", "bom_no", "fg_completed_qty", @@ -640,13 +641,20 @@ "fieldtype": "Check", "label": "Add to Transit", "no_copy": 1 + }, + { + "default": "0", + "depends_on": "eval:in_list([\"Material Transfer\", \"Material Receipt\"], doc.purpose)", + "fieldname": "apply_putaway_rule", + "fieldtype": "Check", + "label": "Apply Putaway Rule" } ], "icon": "fa fa-file-text", "idx": 1, "is_submittable": 1, "links": [], - "modified": "2020-08-11 19:10:07.954981", + "modified": "2020-12-07 14:58:13.267321", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e3159b95c3..aa3425cbd8 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -42,6 +42,13 @@ class StockEntry(StockController): for item in self.get("items"): item.update(get_bin_details(item.item_code, item.s_warehouse)) + def before_save(self): + from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule + apply_rule = self.apply_putaway_rule and (self.purpose in ["Material Transfer", "Material Receipt"]) + + if self.get("items") and apply_rule: + self.items = apply_putaway_rule(self.doctype, self.get("items"), self.company) + def validate(self): self.pro_doc = frappe._dict() if self.work_order: @@ -79,6 +86,7 @@ class StockEntry(StockController): self.validate_serialized_batch() self.set_actual_qty() self.calculate_rate_and_amount(update_finished_item_rate=False) + self.validate_putaway_capacity() def on_submit(self): diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 79e8f9af8f..4075e28b7b 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "hash", "creation": "2013-03-29 18:22:12", "doctype": "DocType", @@ -61,6 +62,7 @@ "against_stock_entry", "ste_detail", "po_detail", + "putaway_rule", "column_break_51", "transferred_qty", "reference_purchase_receipt", @@ -498,13 +500,23 @@ "fieldname": "set_basic_rate_manually", "fieldtype": "Check", "label": "Set Basic Rate Manually" + }, + { + "depends_on": "eval:in_list([\"Material Transfer\", \"Material Receipt\"], parent.purpose)", + "fieldname": "putaway_rule", + "fieldtype": "Link", + "label": "Putaway Rule", + "no_copy": 1, + "options": "Putaway Rule", + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-23 17:55:03.384138", + "modified": "2020-12-07 15:00:44.489442", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail",