| # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors |
| # License: GNU General Public License v3. See license.txt |
| |
| import json |
| from collections import defaultdict |
| from typing import List, Tuple |
| |
| import frappe |
| from frappe import _ |
| from frappe.utils import cint, cstr, flt, get_link_to_form, getdate |
| |
| import erpnext |
| from erpnext.accounts.general_ledger import ( |
| make_gl_entries, |
| make_reverse_gl_entries, |
| process_gl_map, |
| ) |
| from erpnext.accounts.utils import get_fiscal_year |
| from erpnext.controllers.accounts_controller import AccountsController |
| from erpnext.stock import get_warehouse_account_map |
| from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( |
| get_evaluated_inventory_dimension, |
| ) |
| from erpnext.stock.stock_ledger import get_items_to_be_repost |
| |
| |
| class QualityInspectionRequiredError(frappe.ValidationError): |
| pass |
| |
| |
| class QualityInspectionRejectedError(frappe.ValidationError): |
| pass |
| |
| |
| class QualityInspectionNotSubmittedError(frappe.ValidationError): |
| pass |
| |
| |
| class BatchExpiredError(frappe.ValidationError): |
| pass |
| |
| |
| class StockController(AccountsController): |
| def validate(self): |
| super(StockController, self).validate() |
| if not self.get("is_return"): |
| self.validate_inspection() |
| self.validate_serialized_batch() |
| self.clean_serial_nos() |
| self.validate_customer_provided_item() |
| self.set_rate_of_stock_uom() |
| self.validate_internal_transfer() |
| self.validate_putaway_capacity() |
| |
| def make_gl_entries(self, gl_entries=None, from_repost=False): |
| if self.docstatus == 2: |
| make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) |
| |
| provisional_accounting_for_non_stock_items = cint( |
| frappe.db.get_value( |
| "Company", self.company, "enable_provisional_accounting_for_non_stock_items" |
| ) |
| ) |
| |
| if ( |
| cint(erpnext.is_perpetual_inventory_enabled(self.company)) |
| or provisional_accounting_for_non_stock_items |
| ): |
| warehouse_account = get_warehouse_account_map(self.company) |
| |
| if self.docstatus == 1: |
| if not gl_entries: |
| gl_entries = self.get_gl_entries(warehouse_account) |
| make_gl_entries(gl_entries, from_repost=from_repost) |
| |
| elif self.doctype in ["Purchase Receipt", "Purchase Invoice"] and self.docstatus == 1: |
| gl_entries = [] |
| gl_entries = self.get_asset_gl_entry(gl_entries) |
| make_gl_entries(gl_entries, from_repost=from_repost) |
| |
| def validate_serialized_batch(self): |
| from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos |
| |
| is_material_issue = False |
| if self.doctype == "Stock Entry" and self.purpose == "Material Issue": |
| is_material_issue = True |
| |
| for d in self.get("items"): |
| if hasattr(d, "serial_no") and hasattr(d, "batch_no") and d.serial_no and d.batch_no: |
| serial_nos = frappe.get_all( |
| "Serial No", |
| fields=["batch_no", "name", "warehouse"], |
| filters={"name": ("in", get_serial_nos(d.serial_no))}, |
| ) |
| |
| for row in serial_nos: |
| if row.warehouse and row.batch_no != d.batch_no: |
| frappe.throw( |
| _("Row #{0}: Serial No {1} does not belong to Batch {2}").format( |
| d.idx, row.name, d.batch_no |
| ) |
| ) |
| |
| if is_material_issue: |
| continue |
| |
| if flt(d.qty) > 0.0 and d.get("batch_no") and self.get("posting_date") and self.docstatus < 2: |
| expiry_date = frappe.get_cached_value("Batch", d.get("batch_no"), "expiry_date") |
| |
| if expiry_date and getdate(expiry_date) < getdate(self.posting_date): |
| frappe.throw( |
| _("Row #{0}: The batch {1} has already expired.").format( |
| d.idx, get_link_to_form("Batch", d.get("batch_no")) |
| ), |
| BatchExpiredError, |
| ) |
| |
| def clean_serial_nos(self): |
| from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string |
| |
| for row in self.get("items"): |
| if hasattr(row, "serial_no") and row.serial_no: |
| # remove extra whitespace and store one serial no on each line |
| row.serial_no = clean_serial_no_string(row.serial_no) |
| |
| for row in self.get("packed_items") or []: |
| if hasattr(row, "serial_no") and row.serial_no: |
| # remove extra whitespace and store one serial no on each line |
| row.serial_no = clean_serial_no_string(row.serial_no) |
| |
| def get_gl_entries( |
| self, warehouse_account=None, default_expense_account=None, default_cost_center=None |
| ): |
| |
| if not warehouse_account: |
| warehouse_account = get_warehouse_account_map(self.company) |
| |
| sle_map = self.get_stock_ledger_details() |
| voucher_details = self.get_voucher_details(default_expense_account, default_cost_center, sle_map) |
| |
| gl_list = [] |
| warehouse_with_no_account = [] |
| precision = self.get_debit_field_precision() |
| for item_row in voucher_details: |
| sle_list = sle_map.get(item_row.name) |
| sle_rounding_diff = 0.0 |
| if sle_list: |
| for sle in sle_list: |
| if warehouse_account.get(sle.warehouse): |
| # from warehouse account |
| |
| sle_rounding_diff += flt(sle.stock_value_difference) |
| |
| self.check_expense_account(item_row) |
| |
| # expense account/ target_warehouse / source_warehouse |
| if item_row.get("target_warehouse"): |
| warehouse = item_row.get("target_warehouse") |
| expense_account = warehouse_account[warehouse]["account"] |
| else: |
| expense_account = item_row.expense_account |
| |
| gl_list.append( |
| self.get_gl_dict( |
| { |
| "account": warehouse_account[sle.warehouse]["account"], |
| "against": expense_account, |
| "cost_center": item_row.cost_center, |
| "project": item_row.project or self.get("project"), |
| "remarks": self.get("remarks") or _("Accounting Entry for Stock"), |
| "debit": flt(sle.stock_value_difference, precision), |
| "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", |
| }, |
| warehouse_account[sle.warehouse]["account_currency"], |
| item=item_row, |
| ) |
| ) |
| |
| gl_list.append( |
| self.get_gl_dict( |
| { |
| "account": expense_account, |
| "against": warehouse_account[sle.warehouse]["account"], |
| "cost_center": item_row.cost_center, |
| "remarks": self.get("remarks") or _("Accounting Entry for Stock"), |
| "debit": -1 * flt(sle.stock_value_difference, precision), |
| "project": item_row.get("project") or self.get("project"), |
| "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", |
| }, |
| item=item_row, |
| ) |
| ) |
| elif sle.warehouse not in warehouse_with_no_account: |
| warehouse_with_no_account.append(sle.warehouse) |
| |
| if abs(sle_rounding_diff) > (1.0 / (10**precision)) and ( |
| self.get("is_internal_customer") or self.get("is_internal_supplier") |
| ): |
| warehouse_asset_account = "" |
| if self.get("is_internal_customer"): |
| warehouse_asset_account = warehouse_account[item_row.get("target_warehouse")]["account"] |
| elif self.get("is_internal_supplier"): |
| warehouse_asset_account = warehouse_account[item_row.get("warehouse")]["account"] |
| |
| expense_account = frappe.db.get_value("Company", self.company, "default_expense_account") |
| |
| gl_list.append( |
| self.get_gl_dict( |
| { |
| "account": expense_account, |
| "against": warehouse_asset_account, |
| "cost_center": item_row.cost_center, |
| "project": item_row.project or self.get("project"), |
| "remarks": _("Rounding gain/loss Entry for Stock Transfer"), |
| "debit": sle_rounding_diff, |
| "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", |
| }, |
| warehouse_account[sle.warehouse]["account_currency"], |
| item=item_row, |
| ) |
| ) |
| |
| gl_list.append( |
| self.get_gl_dict( |
| { |
| "account": warehouse_asset_account, |
| "against": expense_account, |
| "cost_center": item_row.cost_center, |
| "remarks": _("Rounding gain/loss Entry for Stock Transfer"), |
| "credit": sle_rounding_diff, |
| "project": item_row.get("project") or self.get("project"), |
| "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No", |
| }, |
| item=item_row, |
| ) |
| ) |
| |
| if warehouse_with_no_account: |
| for wh in warehouse_with_no_account: |
| if frappe.db.get_value("Warehouse", wh, "company"): |
| frappe.throw( |
| _( |
| "Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}." |
| ).format(wh, self.company) |
| ) |
| |
| return process_gl_map(gl_list, precision=precision) |
| |
| def get_debit_field_precision(self): |
| if not frappe.flags.debit_field_precision: |
| frappe.flags.debit_field_precision = frappe.get_precision( |
| "GL Entry", "debit_in_account_currency" |
| ) |
| |
| return frappe.flags.debit_field_precision |
| |
| def get_voucher_details(self, default_expense_account, default_cost_center, sle_map): |
| if self.doctype == "Stock Reconciliation": |
| reconciliation_purpose = frappe.db.get_value(self.doctype, self.name, "purpose") |
| is_opening = "Yes" if reconciliation_purpose == "Opening Stock" else "No" |
| details = [] |
| for voucher_detail_no in sle_map: |
| details.append( |
| frappe._dict( |
| { |
| "name": voucher_detail_no, |
| "expense_account": default_expense_account, |
| "cost_center": default_cost_center, |
| "is_opening": is_opening, |
| } |
| ) |
| ) |
| return details |
| else: |
| details = self.get("items") |
| |
| if default_expense_account or default_cost_center: |
| for d in details: |
| if default_expense_account and not d.get("expense_account"): |
| d.expense_account = default_expense_account |
| if default_cost_center and not d.get("cost_center"): |
| d.cost_center = default_cost_center |
| |
| return details |
| |
| def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]: |
| """Get list of items and warehouses affected by a transaction""" |
| |
| if not (hasattr(self, "items") or hasattr(self, "packed_items")): |
| return [], [] |
| |
| item_rows = (self.get("items") or []) + (self.get("packed_items") or []) |
| |
| items = {d.item_code for d in item_rows if d.item_code} |
| |
| warehouses = set() |
| for d in item_rows: |
| if d.get("warehouse"): |
| warehouses.add(d.warehouse) |
| |
| if self.doctype == "Stock Entry": |
| if d.get("s_warehouse"): |
| warehouses.add(d.s_warehouse) |
| if d.get("t_warehouse"): |
| warehouses.add(d.t_warehouse) |
| |
| return list(items), list(warehouses) |
| |
| def get_stock_ledger_details(self): |
| stock_ledger = {} |
| stock_ledger_entries = frappe.db.sql( |
| """ |
| select |
| name, warehouse, stock_value_difference, valuation_rate, |
| voucher_detail_no, item_code, posting_date, posting_time, |
| actual_qty, qty_after_transaction |
| from |
| `tabStock Ledger Entry` |
| where |
| voucher_type=%s and voucher_no=%s and is_cancelled = 0 |
| """, |
| (self.doctype, self.name), |
| as_dict=True, |
| ) |
| |
| for sle in stock_ledger_entries: |
| stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle) |
| return stock_ledger |
| |
| def make_batches(self, warehouse_field): |
| """Create batches if required. Called before submit""" |
| for d in self.items: |
| if d.get(warehouse_field) and not d.batch_no: |
| has_batch_no, create_new_batch = frappe.db.get_value( |
| "Item", d.item_code, ["has_batch_no", "create_new_batch"] |
| ) |
| if has_batch_no and create_new_batch: |
| d.batch_no = ( |
| frappe.get_doc( |
| dict( |
| doctype="Batch", |
| item=d.item_code, |
| supplier=getattr(self, "supplier", None), |
| reference_doctype=self.doctype, |
| reference_name=self.name, |
| ) |
| ) |
| .insert() |
| .name |
| ) |
| |
| def check_expense_account(self, item): |
| if not item.get("expense_account"): |
| msg = _("Please set an Expense Account in the Items table") |
| frappe.throw( |
| _("Row #{0}: Expense Account not set for the Item {1}. {2}").format( |
| item.idx, frappe.bold(item.item_code), msg |
| ), |
| title=_("Expense Account Missing"), |
| ) |
| |
| else: |
| is_expense_account = ( |
| frappe.get_cached_value("Account", item.get("expense_account"), "report_type") |
| == "Profit and Loss" |
| ) |
| if ( |
| self.doctype |
| not in ( |
| "Purchase Receipt", |
| "Purchase Invoice", |
| "Stock Reconciliation", |
| "Stock Entry", |
| "Subcontracting Receipt", |
| ) |
| and not is_expense_account |
| ): |
| frappe.throw( |
| _("Expense / Difference account ({0}) must be a 'Profit or Loss' account").format( |
| item.get("expense_account") |
| ) |
| ) |
| if is_expense_account and not item.get("cost_center"): |
| frappe.throw( |
| _("{0} {1}: Cost Center is mandatory for Item {2}").format( |
| _(self.doctype), self.name, item.get("item_code") |
| ) |
| ) |
| |
| def delete_auto_created_batches(self): |
| for d in self.items: |
| if not d.batch_no: |
| continue |
| |
| frappe.db.set_value( |
| "Serial No", {"batch_no": d.batch_no, "status": "Inactive"}, "batch_no", None |
| ) |
| |
| d.batch_no = None |
| d.db_set("batch_no", None) |
| |
| for data in frappe.get_all( |
| "Batch", {"reference_name": self.name, "reference_doctype": self.doctype} |
| ): |
| frappe.delete_doc("Batch", data.name) |
| |
| def get_sl_entries(self, d, args): |
| sl_dict = frappe._dict( |
| { |
| "item_code": d.get("item_code", None), |
| "warehouse": d.get("warehouse", None), |
| "posting_date": self.posting_date, |
| "posting_time": self.posting_time, |
| "fiscal_year": get_fiscal_year(self.posting_date, company=self.company)[0], |
| "voucher_type": self.doctype, |
| "voucher_no": self.name, |
| "voucher_detail_no": d.name, |
| "actual_qty": (self.docstatus == 1 and 1 or -1) * flt(d.get("stock_qty")), |
| "stock_uom": frappe.db.get_value( |
| "Item", args.get("item_code") or d.get("item_code"), "stock_uom" |
| ), |
| "incoming_rate": 0, |
| "company": self.company, |
| "batch_no": cstr(d.get("batch_no")).strip(), |
| "serial_no": d.get("serial_no"), |
| "project": d.get("project") or self.get("project"), |
| "is_cancelled": 1 if self.docstatus == 2 else 0, |
| } |
| ) |
| |
| sl_dict.update(args) |
| self.update_inventory_dimensions(d, sl_dict) |
| |
| return sl_dict |
| |
| def update_inventory_dimensions(self, row, sl_dict) -> None: |
| # To handle delivery note and sales invoice |
| if row.get("item_row"): |
| row = row.get("item_row") |
| |
| dimensions = get_evaluated_inventory_dimension(row, sl_dict, parent_doc=self) |
| for dimension in dimensions: |
| if not dimension: |
| continue |
| |
| if row.get(dimension.source_fieldname): |
| sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) |
| |
| if not sl_dict.get(dimension.target_fieldname) and dimension.fetch_from_parent: |
| sl_dict[dimension.target_fieldname] = self.get(dimension.fetch_from_parent) |
| |
| # Get value based on doctype name |
| if not sl_dict.get(dimension.target_fieldname): |
| fieldname = frappe.get_cached_value( |
| "DocField", {"parent": self.doctype, "options": dimension.fetch_from_parent}, "fieldname" |
| ) |
| |
| if not fieldname: |
| fieldname = frappe.get_cached_value( |
| "Custom Field", {"dt": self.doctype, "options": dimension.fetch_from_parent}, "fieldname" |
| ) |
| |
| if fieldname and self.get(fieldname): |
| sl_dict[dimension.target_fieldname] = self.get(fieldname) |
| |
| if sl_dict[dimension.target_fieldname] and self.docstatus == 1: |
| row.db_set(dimension.source_fieldname, sl_dict[dimension.target_fieldname]) |
| |
| def make_sl_entries(self, sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): |
| from erpnext.stock.stock_ledger import make_sl_entries |
| |
| make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher) |
| |
| def make_gl_entries_on_cancel(self): |
| if frappe.db.sql( |
| """select name from `tabGL Entry` where voucher_type=%s |
| and voucher_no=%s""", |
| (self.doctype, self.name), |
| ): |
| self.make_gl_entries() |
| |
| def get_serialized_items(self): |
| serialized_items = [] |
| item_codes = list(set(d.item_code for d in self.get("items"))) |
| if item_codes: |
| serialized_items = frappe.db.sql_list( |
| """select name from `tabItem` |
| where has_serial_no=1 and name in ({})""".format( |
| ", ".join(["%s"] * len(item_codes)) |
| ), |
| tuple(item_codes), |
| ) |
| |
| return serialized_items |
| |
| def validate_warehouse(self): |
| from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company |
| |
| warehouses = list(set(d.warehouse for d in self.get("items") if getattr(d, "warehouse", None))) |
| |
| target_warehouses = list( |
| set([d.target_warehouse for d in self.get("items") if getattr(d, "target_warehouse", None)]) |
| ) |
| |
| warehouses.extend(target_warehouses) |
| |
| from_warehouse = list( |
| set([d.from_warehouse for d in self.get("items") if getattr(d, "from_warehouse", None)]) |
| ) |
| |
| warehouses.extend(from_warehouse) |
| |
| for w in warehouses: |
| validate_disabled_warehouse(w) |
| validate_warehouse_company(w, self.company) |
| |
| def update_billing_percentage(self, update_modified=True): |
| target_ref_field = "amount" |
| if self.doctype == "Delivery Note": |
| target_ref_field = "amount - (returned_qty * rate)" |
| |
| self._update_percent_field( |
| { |
| "target_dt": self.doctype + " Item", |
| "target_parent_dt": self.doctype, |
| "target_parent_field": "per_billed", |
| "target_ref_field": target_ref_field, |
| "target_field": "billed_amt", |
| "name": self.name, |
| }, |
| update_modified, |
| ) |
| |
| def validate_inspection(self): |
| """Checks if quality inspection is set/ is valid for Items that require inspection.""" |
| inspection_fieldname_map = { |
| "Purchase Receipt": "inspection_required_before_purchase", |
| "Purchase Invoice": "inspection_required_before_purchase", |
| "Sales Invoice": "inspection_required_before_delivery", |
| "Delivery Note": "inspection_required_before_delivery", |
| } |
| inspection_required_fieldname = inspection_fieldname_map.get(self.doctype) |
| |
| # return if inspection is not required on document level |
| if ( |
| (not inspection_required_fieldname and self.doctype != "Stock Entry") |
| or (self.doctype == "Stock Entry" and not self.inspection_required) |
| or (self.doctype in ["Sales Invoice", "Purchase Invoice"] and not self.update_stock) |
| ): |
| return |
| |
| for row in self.get("items"): |
| qi_required = False |
| if inspection_required_fieldname and frappe.db.get_value( |
| "Item", row.item_code, inspection_required_fieldname |
| ): |
| qi_required = True |
| elif self.doctype == "Stock Entry" and row.t_warehouse: |
| qi_required = True # inward stock needs inspection |
| |
| if qi_required: # validate row only if inspection is required on item level |
| self.validate_qi_presence(row) |
| if self.docstatus == 1: |
| self.validate_qi_submission(row) |
| self.validate_qi_rejection(row) |
| |
| def validate_qi_presence(self, row): |
| """Check if QI is present on row level. Warn on save and stop on submit if missing.""" |
| if not row.quality_inspection: |
| msg = f"Row #{row.idx}: Quality Inspection is required for Item {frappe.bold(row.item_code)}" |
| if self.docstatus == 1: |
| frappe.throw(_(msg), title=_("Inspection Required"), exc=QualityInspectionRequiredError) |
| else: |
| frappe.msgprint(_(msg), title=_("Inspection Required"), indicator="blue") |
| |
| def validate_qi_submission(self, row): |
| """Check if QI is submitted on row level, during submission""" |
| action = frappe.db.get_single_value( |
| "Stock Settings", "action_if_quality_inspection_is_not_submitted" |
| ) |
| qa_docstatus = frappe.db.get_value("Quality Inspection", row.quality_inspection, "docstatus") |
| |
| if not qa_docstatus == 1: |
| link = frappe.utils.get_link_to_form("Quality Inspection", row.quality_inspection) |
| msg = ( |
| f"Row #{row.idx}: Quality Inspection {link} is not submitted for the item: {row.item_code}" |
| ) |
| if action == "Stop": |
| frappe.throw(_(msg), title=_("Inspection Submission"), exc=QualityInspectionNotSubmittedError) |
| else: |
| frappe.msgprint(_(msg), alert=True, indicator="orange") |
| |
| def validate_qi_rejection(self, row): |
| """Check if QI is rejected on row level, during submission""" |
| action = frappe.db.get_single_value("Stock Settings", "action_if_quality_inspection_is_rejected") |
| qa_status = frappe.db.get_value("Quality Inspection", row.quality_inspection, "status") |
| |
| if qa_status == "Rejected": |
| link = frappe.utils.get_link_to_form("Quality Inspection", row.quality_inspection) |
| msg = f"Row #{row.idx}: Quality Inspection {link} was rejected for item {row.item_code}" |
| if action == "Stop": |
| frappe.throw(_(msg), title=_("Inspection Rejected"), exc=QualityInspectionRejectedError) |
| else: |
| frappe.msgprint(_(msg), alert=True, indicator="orange") |
| |
| def update_blanket_order(self): |
| blanket_orders = list(set([d.blanket_order for d in self.items if d.blanket_order])) |
| for blanket_order in blanket_orders: |
| frappe.get_doc("Blanket Order", blanket_order).update_ordered_qty() |
| |
| def validate_customer_provided_item(self): |
| for d in self.get("items"): |
| # Customer Provided parts will have zero valuation rate |
| if frappe.db.get_value("Item", d.item_code, "is_customer_provided_item"): |
| d.allow_zero_valuation_rate = 1 |
| |
| def set_rate_of_stock_uom(self): |
| if self.doctype in [ |
| "Purchase Receipt", |
| "Purchase Invoice", |
| "Purchase Order", |
| "Sales Invoice", |
| "Sales Order", |
| "Delivery Note", |
| "Quotation", |
| ]: |
| for d in self.get("items"): |
| d.stock_uom_rate = d.rate / (d.conversion_factor or 1) |
| |
| def validate_internal_transfer(self): |
| if ( |
| self.doctype in ("Sales Invoice", "Delivery Note", "Purchase Invoice", "Purchase Receipt") |
| and self.is_internal_transfer() |
| ): |
| self.validate_in_transit_warehouses() |
| self.validate_multi_currency() |
| self.validate_packed_items() |
| |
| def validate_in_transit_warehouses(self): |
| if ( |
| self.doctype == "Sales Invoice" and self.get("update_stock") |
| ) or self.doctype == "Delivery Note": |
| for item in self.get("items"): |
| if not item.target_warehouse: |
| frappe.throw( |
| _("Row {0}: Target Warehouse is mandatory for internal transfers").format(item.idx) |
| ) |
| |
| if ( |
| self.doctype == "Purchase Invoice" and self.get("update_stock") |
| ) or self.doctype == "Purchase Receipt": |
| for item in self.get("items"): |
| if not item.from_warehouse: |
| frappe.throw( |
| _("Row {0}: From Warehouse is mandatory for internal transfers").format(item.idx) |
| ) |
| |
| def validate_multi_currency(self): |
| if self.currency != self.company_currency: |
| frappe.throw(_("Internal transfers can only be done in company's default currency")) |
| |
| def validate_packed_items(self): |
| if self.doctype in ("Sales Invoice", "Delivery Note Item") and self.get("packed_items"): |
| frappe.throw(_("Packed Items cannot be transferred internally")) |
| |
| 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_available_putaway_capacity |
| |
| valid_doctype = self.doctype in ( |
| "Purchase Receipt", |
| "Stock Entry", |
| "Purchase Invoice", |
| "Stock Reconciliation", |
| ) |
| |
| if self.doctype == "Purchase Invoice" and self.get("update_stock") == 0: |
| valid_doctype = False |
| |
| if valid_doctype: |
| rule_map = defaultdict(dict) |
| for item in self.get("items"): |
| warehouse_field = "t_warehouse" if self.doctype == "Stock Entry" else "warehouse" |
| rule = frappe.db.get_value( |
| "Putaway Rule", |
| {"item_code": item.get("item_code"), "warehouse": item.get(warehouse_field)}, |
| ["name", "disable"], |
| as_dict=True, |
| ) |
| if rule: |
| if rule.get("disabled"): |
| continue # dont validate for disabled rule |
| |
| if self.doctype == "Stock Reconciliation": |
| stock_qty = flt(item.qty) |
| else: |
| stock_qty = flt(item.transfer_qty) if self.doctype == "Stock Entry" else flt(item.stock_qty) |
| |
| rule_name = rule.get("name") |
| if not rule_map[rule_name]: |
| rule_map[rule_name]["warehouse"] = item.get(warehouse_field) |
| rule_map[rule_name]["item"] = item.get("item_code") |
| rule_map[rule_name]["qty_put"] = 0 |
| rule_map[rule_name]["capacity"] = get_available_putaway_capacity(rule_name) |
| rule_map[rule_name]["qty_put"] += flt(stock_qty) |
| |
| for rule, values in rule_map.items(): |
| if flt(values["qty_put"]) > flt(values["capacity"]): |
| message = self.prepare_over_receipt_message(rule, values) |
| frappe.throw(msg=message, title=_("Over Receipt")) |
| |
| def prepare_over_receipt_message(self, rule, values): |
| 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 += "<br><br>" |
| rule_link = frappe.utils.get_link_to_form("Putaway Rule", rule) |
| message += _("Please adjust the qty or edit {0} to proceed.").format(rule_link) |
| return message |
| |
| def repost_future_sle_and_gle(self): |
| args = frappe._dict( |
| { |
| "posting_date": self.posting_date, |
| "posting_time": self.posting_time, |
| "voucher_type": self.doctype, |
| "voucher_no": self.name, |
| "company": self.company, |
| } |
| ) |
| |
| if future_sle_exists(args) or repost_required_for_queue(self): |
| item_based_reposting = cint( |
| frappe.db.get_single_value("Stock Reposting Settings", "item_based_reposting") |
| ) |
| if item_based_reposting: |
| create_item_wise_repost_entries(voucher_type=self.doctype, voucher_no=self.name) |
| else: |
| create_repost_item_valuation_entry(args) |
| |
| def add_gl_entry( |
| self, |
| gl_entries, |
| account, |
| cost_center, |
| debit, |
| credit, |
| remarks, |
| against_account, |
| debit_in_account_currency=None, |
| credit_in_account_currency=None, |
| account_currency=None, |
| project=None, |
| voucher_detail_no=None, |
| item=None, |
| posting_date=None, |
| ): |
| |
| gl_entry = { |
| "account": account, |
| "cost_center": cost_center, |
| "debit": debit, |
| "credit": credit, |
| "against": against_account, |
| "remarks": remarks, |
| } |
| |
| if voucher_detail_no: |
| gl_entry.update({"voucher_detail_no": voucher_detail_no}) |
| |
| if debit_in_account_currency: |
| gl_entry.update({"debit_in_account_currency": debit_in_account_currency}) |
| |
| if credit_in_account_currency: |
| gl_entry.update({"credit_in_account_currency": credit_in_account_currency}) |
| |
| if posting_date: |
| gl_entry.update({"posting_date": posting_date}) |
| |
| gl_entries.append(self.get_gl_dict(gl_entry, item=item)) |
| |
| |
| def repost_required_for_queue(doc: StockController) -> bool: |
| """check if stock document contains repeated item-warehouse with queue based valuation. |
| |
| if queue exists for repeated items then SLEs need to reprocessed in background again. |
| """ |
| |
| consuming_sles = frappe.db.get_all( |
| "Stock Ledger Entry", |
| filters={ |
| "voucher_type": doc.doctype, |
| "voucher_no": doc.name, |
| "actual_qty": ("<", 0), |
| "is_cancelled": 0, |
| }, |
| fields=["item_code", "warehouse", "stock_queue"], |
| ) |
| item_warehouses = [(sle.item_code, sle.warehouse) for sle in consuming_sles] |
| |
| unique_item_warehouses = set(item_warehouses) |
| |
| if len(unique_item_warehouses) == len(item_warehouses): |
| return False |
| |
| for sle in consuming_sles: |
| if sle.stock_queue != "[]": # using FIFO/LIFO valuation |
| return True |
| return False |
| |
| |
| @frappe.whitelist() |
| def make_quality_inspections(doctype, docname, items): |
| if isinstance(items, str): |
| items = json.loads(items) |
| |
| inspections = [] |
| for item in items: |
| if flt(item.get("sample_size")) > flt(item.get("qty")): |
| frappe.throw( |
| _( |
| "{item_name}'s Sample Size ({sample_size}) cannot be greater than the Accepted Quantity ({accepted_quantity})" |
| ).format( |
| item_name=item.get("item_name"), |
| sample_size=item.get("sample_size"), |
| accepted_quantity=item.get("qty"), |
| ) |
| ) |
| |
| quality_inspection = frappe.get_doc( |
| { |
| "doctype": "Quality Inspection", |
| "inspection_type": "Incoming", |
| "inspected_by": frappe.session.user, |
| "reference_type": doctype, |
| "reference_name": docname, |
| "item_code": item.get("item_code"), |
| "description": item.get("description"), |
| "sample_size": flt(item.get("sample_size")), |
| "item_serial_no": item.get("serial_no").split("\n")[0] if item.get("serial_no") else None, |
| "batch_no": item.get("batch_no"), |
| } |
| ).insert() |
| quality_inspection.save() |
| inspections.append(quality_inspection.name) |
| |
| return inspections |
| |
| |
| def is_reposting_pending(): |
| return frappe.db.exists( |
| "Repost Item Valuation", {"docstatus": 1, "status": ["in", ["Queued", "In Progress"]]} |
| ) |
| |
| |
| def future_sle_exists(args, sl_entries=None): |
| key = (args.voucher_type, args.voucher_no) |
| |
| if validate_future_sle_not_exists(args, key, sl_entries): |
| return False |
| elif get_cached_data(args, key): |
| return True |
| |
| if not sl_entries: |
| sl_entries = get_sle_entries_against_voucher(args) |
| if not sl_entries: |
| return |
| |
| or_conditions = get_conditions_to_validate_future_sle(sl_entries) |
| |
| data = frappe.db.sql( |
| """ |
| select item_code, warehouse, count(name) as total_row |
| from `tabStock Ledger Entry` force index (item_warehouse) |
| where |
| ({}) |
| and timestamp(posting_date, posting_time) |
| >= timestamp(%(posting_date)s, %(posting_time)s) |
| and voucher_no != %(voucher_no)s |
| and is_cancelled = 0 |
| GROUP BY |
| item_code, warehouse |
| """.format( |
| " or ".join(or_conditions) |
| ), |
| args, |
| as_dict=1, |
| ) |
| |
| for d in data: |
| frappe.local.future_sle[key][(d.item_code, d.warehouse)] = d.total_row |
| |
| return len(data) |
| |
| |
| def validate_future_sle_not_exists(args, key, sl_entries=None): |
| item_key = "" |
| if args.get("item_code"): |
| item_key = (args.get("item_code"), args.get("warehouse")) |
| |
| if not sl_entries and hasattr(frappe.local, "future_sle"): |
| if not frappe.local.future_sle.get(key) or ( |
| item_key and item_key not in frappe.local.future_sle.get(key) |
| ): |
| return True |
| |
| |
| def get_cached_data(args, key): |
| if not hasattr(frappe.local, "future_sle"): |
| frappe.local.future_sle = {} |
| |
| if key not in frappe.local.future_sle: |
| frappe.local.future_sle[key] = frappe._dict({}) |
| |
| if args.get("item_code"): |
| item_key = (args.get("item_code"), args.get("warehouse")) |
| count = frappe.local.future_sle[key].get(item_key) |
| |
| return True if (count or count == 0) else False |
| else: |
| return frappe.local.future_sle[key] |
| |
| |
| def get_sle_entries_against_voucher(args): |
| return frappe.get_all( |
| "Stock Ledger Entry", |
| filters={"voucher_type": args.voucher_type, "voucher_no": args.voucher_no}, |
| fields=["item_code", "warehouse"], |
| order_by="creation asc", |
| ) |
| |
| |
| def get_conditions_to_validate_future_sle(sl_entries): |
| warehouse_items_map = {} |
| for entry in sl_entries: |
| if entry.warehouse not in warehouse_items_map: |
| warehouse_items_map[entry.warehouse] = set() |
| |
| warehouse_items_map[entry.warehouse].add(entry.item_code) |
| |
| or_conditions = [] |
| for warehouse, items in warehouse_items_map.items(): |
| or_conditions.append( |
| f"""warehouse = {frappe.db.escape(warehouse)} |
| and item_code in ({', '.join(frappe.db.escape(item) for item in items)})""" |
| ) |
| |
| return or_conditions |
| |
| |
| def create_repost_item_valuation_entry(args): |
| args = frappe._dict(args) |
| repost_entry = frappe.new_doc("Repost Item Valuation") |
| repost_entry.based_on = args.based_on |
| if not args.based_on: |
| repost_entry.based_on = "Transaction" if args.voucher_no else "Item and Warehouse" |
| repost_entry.voucher_type = args.voucher_type |
| repost_entry.voucher_no = args.voucher_no |
| repost_entry.item_code = args.item_code |
| repost_entry.warehouse = args.warehouse |
| repost_entry.posting_date = args.posting_date |
| repost_entry.posting_time = args.posting_time |
| repost_entry.company = args.company |
| repost_entry.allow_zero_rate = args.allow_zero_rate |
| repost_entry.flags.ignore_links = True |
| repost_entry.flags.ignore_permissions = True |
| repost_entry.save() |
| repost_entry.submit() |
| |
| |
| def create_item_wise_repost_entries(voucher_type, voucher_no, allow_zero_rate=False): |
| """Using a voucher create repost item valuation records for all item-warehouse pairs.""" |
| |
| stock_ledger_entries = get_items_to_be_repost(voucher_type, voucher_no) |
| |
| distinct_item_warehouses = set() |
| repost_entries = [] |
| |
| for sle in stock_ledger_entries: |
| item_wh = (sle.item_code, sle.warehouse) |
| if item_wh in distinct_item_warehouses: |
| continue |
| distinct_item_warehouses.add(item_wh) |
| |
| repost_entry = frappe.new_doc("Repost Item Valuation") |
| repost_entry.based_on = "Item and Warehouse" |
| repost_entry.voucher_type = voucher_type |
| repost_entry.voucher_no = voucher_no |
| |
| repost_entry.item_code = sle.item_code |
| repost_entry.warehouse = sle.warehouse |
| repost_entry.posting_date = sle.posting_date |
| repost_entry.posting_time = sle.posting_time |
| repost_entry.allow_zero_rate = allow_zero_rate |
| repost_entry.flags.ignore_links = True |
| repost_entry.flags.ignore_permissions = True |
| repost_entry.submit() |
| repost_entries.append(repost_entry) |
| |
| return repost_entries |