diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 045b57349989fc97b0a13c0fbbaa47c169bd9e35..64aa162abf8616a508b6c1f0c6e1e9ac88be687d 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -228,6 +228,7 @@ class POSInvoice(SalesInvoice): self.apply_loyalty_points() self.check_phone_payments() self.set_status(update=True) + self.make_bundle_for_sales_purchase_return() self.submit_serial_batch_bundle() if self.coupon_code: @@ -308,7 +309,9 @@ class POSInvoice(SalesInvoice): ) if paid_amt and pay.amount != paid_amt: - return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment)) + return frappe.throw( + _("Payment related to {0} is not completed").format(pay.mode_of_payment) + ) def validate_stock_availablility(self): if self.is_return: @@ -408,8 +411,7 @@ class POSInvoice(SalesInvoice): if ( self.change_amount and self.account_for_change_amount - and frappe.get_cached_value("Account", self.account_for_change_amount, "company") - != self.company + and frappe.get_cached_value("Account", self.account_for_change_amount, "company") != self.company ): frappe.throw( _("The selected change account {} doesn't belongs to Company {}.").format( diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 6785d65e1bd2e9aa75c7c55b68f7b246572f0c4c..ec6d42f30cd3d2e6e80cc01551eaf673cc74885f 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -323,29 +323,28 @@ class TestPOSInvoice(unittest.TestCase): pos.insert() pos.submit() + pos.reload() pos_return1 = make_sales_return(pos.name) # partial return 1 pos_return1.get("items")[0].qty = -1 + pos_return1.submit() + pos_return1.reload() bundle_id = frappe.get_doc( "Serial and Batch Bundle", pos_return1.get("items")[0].serial_and_batch_bundle ) - bundle_id.remove(bundle_id.entries[1]) - bundle_id.save() - bundle_id.load_from_db() serial_no = bundle_id.entries[0].serial_no self.assertEqual(serial_no, serial_nos[0]) - pos_return1.insert() - pos_return1.submit() - # partial return 2 pos_return2 = make_sales_return(pos.name) + pos_return2.submit() + self.assertEqual(pos_return2.get("items")[0].qty, -1) serial_no = get_serial_nos_from_bundle(pos_return2.get("items")[0].serial_and_batch_bundle)[0] self.assertEqual(serial_no, serial_nos[1]) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 2fd78dfdb683eaab5c0901fbdbf32292c382db90..3f67288a84afc6c2748add0566dbed12ce134210 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -735,6 +735,7 @@ class PurchaseInvoice(BuyingController): # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty in bin depends upon updated ordered qty in PO if self.update_stock == 1: + self.make_bundle_for_sales_purchase_return() self.make_bundle_using_old_serial_batch_fields() self.update_stock_ledger() diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 32688e9c89dce3445bbc64685bd5131333c19a34..355988e5e6032038115a3a1ac0c2ffff71605857 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -475,6 +475,7 @@ class SalesInvoice(SellingController): if not self.get(table_name): continue + self.make_bundle_for_sales_purchase_return(table_name) self.make_bundle_using_old_serial_batch_fields(table_name) self.update_stock_ledger() @@ -496,9 +497,7 @@ class SalesInvoice(SellingController): self.update_time_sheet(self.name) self.update_unreconciled_amount() - if ( - frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction" - ): + if frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) self.update_project() update_linked_doc(self.doctype, self.name, self.inter_company_invoice_reference) @@ -514,9 +513,7 @@ class SalesInvoice(SellingController): and not self.dont_create_loyalty_points ): self.make_loyalty_point_entry() - elif ( - self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program - ): + elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program: against_si_doc = frappe.get_doc("Sales Invoice", self.return_against) against_si_doc.delete_loyalty_point_entry() against_si_doc.make_loyalty_point_entry() @@ -546,11 +543,11 @@ class SalesInvoice(SellingController): def check_if_consolidated_invoice(self): # since POS Invoice extends Sales Invoice, we explicitly check if doctype is Sales Invoice if self.doctype == "Sales Invoice" and self.is_consolidated: - invoice_or_credit_note = ( - "consolidated_credit_note" if self.is_return else "consolidated_invoice" - ) + invoice_or_credit_note = "consolidated_credit_note" if self.is_return else "consolidated_invoice" pos_closing_entry = frappe.get_all( - "POS Invoice Merge Log", filters={invoice_or_credit_note: self.name}, pluck="pos_closing_entry" + "POS Invoice Merge Log", + filters={invoice_or_credit_note: self.name}, + pluck="pos_closing_entry", ) if pos_closing_entry and pos_closing_entry[0]: msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format( @@ -598,16 +595,12 @@ class SalesInvoice(SellingController): if self.coupon_code: update_coupon_code_count(self.coupon_code, "cancelled") - if ( - frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction" - ): + if frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) self.update_project() if not self.is_return and not self.is_consolidated and self.loyalty_program: self.delete_loyalty_point_entry() - elif ( - self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program - ): + elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program: against_si_doc = frappe.get_doc("Sales Invoice", self.return_against) against_si_doc.delete_loyalty_point_entry() against_si_doc.make_loyalty_point_entry() @@ -822,7 +815,6 @@ class SalesInvoice(SellingController): update_multi_mode_option(self, pos) if pos: - if not for_validate: self.tax_category = pos.get("tax_category") @@ -914,7 +906,8 @@ class SalesInvoice(SellingController): if account.report_type != "Balance Sheet": msg = ( - _("Please ensure {} account is a Balance Sheet account.").format(frappe.bold("Debit To")) + " " + _("Please ensure {} account is a Balance Sheet account.").format(frappe.bold("Debit To")) + + " " ) msg += _( "You can change the parent account to a Balance Sheet account or select a different account." @@ -947,7 +940,12 @@ class SalesInvoice(SellingController): { "Sales Order": { "ref_dn_field": "sales_order", - "compare_fields": [["customer", "="], ["company", "="], ["project", "="], ["currency", "="]], + "compare_fields": [ + ["customer", "="], + ["company", "="], + ["project", "="], + ["currency", "="], + ], }, "Sales Order Item": { "ref_dn_field": "so_detail", @@ -957,7 +955,12 @@ class SalesInvoice(SellingController): }, "Delivery Note": { "ref_dn_field": "delivery_note", - "compare_fields": [["customer", "="], ["company", "="], ["project", "="], ["currency", "="]], + "compare_fields": [ + ["customer", "="], + ["company", "="], + ["project", "="], + ["currency", "="], + ], }, "Delivery Note Item": { "ref_dn_field": "dn_detail", @@ -1014,13 +1017,14 @@ class SalesInvoice(SellingController): } for key, value in prev_doc_field_map.items(): if frappe.db.get_single_value("Selling Settings", value[0]) == "Yes": - if frappe.get_value("Customer", self.customer, value[0]): continue for d in self.get("items"): if d.item_code and not d.get(key.lower().replace(" ", "_")) and not self.get(value[1]): - msgprint(_("{0} is mandatory for Item {1}").format(key, d.item_code), raise_exception=1) + msgprint( + _("{0} is mandatory for Item {1}").format(key, d.item_code), raise_exception=1 + ) def validate_proj_cust(self): """check for does customer belong to same project as entered..""" @@ -1333,7 +1337,8 @@ class SalesInvoice(SellingController): else flt(amount, tax.precision("tax_amount_after_discount_amount")) ), "cost_center": tax.cost_center, - "remarks": tax.get("remarks") or f'{tax.description} / {_("Customer")}: {self.customer}', + "remarks": tax.get("remarks") + or f'{tax.description} / {_("Customer")}: {self.customer}', "accounting_journal": self.accounting_journal, }, account_currency, @@ -1372,13 +1377,19 @@ class SalesInvoice(SellingController): if self.is_return: fixed_asset_gl_entries = get_gl_entries_on_asset_regain( - asset, item.base_net_amount, item.finance_book, self.get("doctype"), self.get("name") + asset, + item.base_net_amount, + item.finance_book, + self.get("doctype"), + self.get("name"), ) asset.db_set("disposal_date", None) add_asset_activity(asset.name, _("Asset returned")) if asset.calculate_depreciation: - posting_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date") + posting_date = frappe.db.get_value( + "Sales Invoice", self.return_against, "posting_date" + ) reverse_depreciation_entry_made_after_disposal(asset, posting_date) notes = _( "This schedule was created when Asset {0} was returned through Sales Invoice {1}." @@ -1401,7 +1412,11 @@ class SalesInvoice(SellingController): asset.reload() fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( - asset, item.base_net_amount, item.finance_book, self.get("doctype"), self.get("name") + asset, + item.base_net_amount, + item.finance_book, + self.get("doctype"), + self.get("name"), ) asset.db_set("disposal_date", self.posting_date) add_asset_activity(asset.name, _("Asset sold")) @@ -1417,10 +1432,16 @@ class SalesInvoice(SellingController): if not self.is_internal_transfer(): income_account = ( item.income_account - if (not item.enable_deferred_revenue or self.is_return or self.is_down_payment_invoice) + if ( + not item.enable_deferred_revenue + or self.is_return + or self.is_down_payment_invoice + ) else item.deferred_revenue_account ) - amount, base_amount = self.get_amount_and_base_amount(item, enable_discount_accounting) + amount, base_amount = self.get_amount_and_base_amount( + item, enable_discount_accounting + ) account_currency = get_account_currency(income_account) gl_dict = self.get_gl_dict( @@ -1680,7 +1701,9 @@ class SalesInvoice(SellingController): "credit_in_account_currency": flt( self.rounding_adjustment, self.precision("rounding_adjustment") ), - "credit": flt(self.base_rounding_adjustment, self.precision("base_rounding_adjustment")), + "credit": flt( + self.base_rounding_adjustment, self.precision("base_rounding_adjustment") + ), "cost_center": round_off_cost_center if self.use_company_roundoff_cost_center else (self.cost_center or round_off_cost_center), @@ -1703,7 +1726,11 @@ class SalesInvoice(SellingController): ) billed_amt = billed_amt and billed_amt[0][0] or 0 frappe.db.set_value( - "Delivery Note Item", d.dn_detail, "billed_amt", billed_amt, update_modified=update_modified + "Delivery Note Item", + d.dn_detail, + "billed_amt", + billed_amt, + update_modified=update_modified, ) updated_delivery_notes.append(d.delivery_note) elif d.so_detail: @@ -2067,7 +2094,6 @@ def validate_inter_company_party(doctype, party, company, inter_company_referenc def update_linked_doc(doctype, name, inter_company_reference): - if doctype in ["Sales Invoice", "Purchase Invoice"]: ref_field = "inter_company_invoice_reference" else: @@ -2078,7 +2104,6 @@ def update_linked_doc(doctype, name, inter_company_reference): def unlink_inter_company_doc(doctype, name, inter_company_reference): - if doctype in ["Sales Invoice", "Purchase Invoice"]: ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice" ref_field = "inter_company_invoice_reference" @@ -2253,16 +2278,13 @@ def get_internal_party(parties, link_doctype, doc): def validate_inter_company_transaction(doc, doctype): - details = get_inter_company_details(doc, doctype) price_list = ( doc.selling_price_list if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"] else doc.buying_price_list ) - valid_price_list = frappe.db.get_value( - "Price List", {"name": price_list, "buying": 1, "selling": 1} - ) + valid_price_list = frappe.db.get_value("Price List", {"name": price_list, "buying": 1, "selling": 1}) if not valid_price_list and not doc.is_internal_transfer(): frappe.throw(_("Selected Price List should have buying and selling fields checked.")) @@ -2523,9 +2545,7 @@ def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, wa for item in doc.get("items"): item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item)) if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"): - item.warehouse = frappe.db.get_value( - "Purchase Order Item", item.purchase_order_item, "warehouse" - ) + item.warehouse = frappe.db.get_value("Purchase Order Item", item.purchase_order_item, "warehouse") def get_delivery_note_details(internal_reference): @@ -2771,9 +2791,7 @@ def check_if_return_invoice_linked_with_payment_entry(self): # If a Return invoice is linked with payment entry along with other invoices, # the cancellation of the Return causes allocated amount to be greater than paid - if not frappe.db.get_single_value( - "Accounts Settings", "unlink_payment_on_cancellation_of_invoice" - ): + if not frappe.db.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"): return payment_entries = [] diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 7ef2d860b06f3bf5a2c244d13be88c3fda6730d6..a6063d52779a5603021a2c4ad3db7ccc19085094 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -1,11 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from collections import defaultdict import frappe from frappe import _ from frappe.model.meta import get_field_precision -from frappe.utils import flt, format_datetime, get_datetime +from frappe.utils import cint, flt, format_datetime, get_datetime import erpnext from erpnext.stock.serial_batch_bundle import get_batches_from_bundle @@ -519,6 +520,7 @@ def make_return_doc( target_doc.rejected_warehouse = "" target_doc.warehouse = source_doc.rejected_warehouse target_doc.received_qty = target_doc.qty + target_doc.return_qty_from_rejected_warehouse = 1 elif doctype == "Purchase Invoice": returned_qty_map = get_returned_qty_map_for_row( @@ -576,7 +578,14 @@ def make_return_doc( if default_warehouse_for_sales_return: target_doc.warehouse = default_warehouse_for_sales_return - if source_doc.item_code: + if ( + (source_doc.serial_no or source_doc.batch_no) + and not source_doc.serial_and_batch_bundle + and not source_doc.use_serial_batch_fields + ): + target_doc.set("use_serial_batch_fields", 1) + + if source_doc.item_code and target_doc.get("use_serial_batch_fields"): item_details = frappe.get_cached_value( "Item", source_doc.item_code, ["has_batch_no", "has_serial_no"], as_dict=1 ) @@ -584,14 +593,7 @@ def make_return_doc( if not item_details.has_batch_no and not item_details.has_serial_no: return - if not target_doc.get("use_serial_batch_fields"): - for qty_field in ["stock_qty", "rejected_qty"]: - if not target_doc.get(qty_field): - continue - - update_serial_batch_no(source_doc, target_doc, source_parent, item_details, qty_field) - elif target_doc.get("use_serial_batch_fields"): - update_non_bundled_serial_nos(source_doc, target_doc, source_parent) + update_non_bundled_serial_nos(source_doc, target_doc, source_parent) def update_non_bundled_serial_nos(source_doc, target_doc, source_parent): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -855,3 +857,229 @@ def get_returned_batches( batches.update(get_batches_from_bundle(ids)) return batches + + +def available_serial_batch_for_return(field, doctype, reference_ids, is_rejected=False): + available_dict = get_available_serial_batches(field, doctype, reference_ids, is_rejected=is_rejected) + if not available_dict: + frappe.throw(_("No Serial / Batches are available for return")) + + return available_dict + + +def get_available_serial_batches(field, doctype, reference_ids, is_rejected=False): + _bundle_ids = get_serial_and_batch_bundle(field, doctype, reference_ids, is_rejected=is_rejected) + if not _bundle_ids: + return frappe._dict({}) + + return get_serial_batches_based_on_bundle(field, _bundle_ids) + + +def get_serial_batches_based_on_bundle(field, _bundle_ids): + available_dict = frappe._dict({}) + batch_serial_nos = frappe.get_all( + "Serial and Batch Bundle", + fields=[ + "`tabSerial and Batch Entry`.`serial_no`", + "`tabSerial and Batch Entry`.`batch_no`", + "`tabSerial and Batch Entry`.`qty`", + "`tabSerial and Batch Bundle`.`voucher_detail_no`", + "`tabSerial and Batch Bundle`.`voucher_type`", + "`tabSerial and Batch Bundle`.`voucher_no`", + ], + filters=[ + ["Serial and Batch Bundle", "name", "in", _bundle_ids], + ["Serial and Batch Entry", "docstatus", "=", 1], + ], + order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`", + ) + + for row in batch_serial_nos: + key = row.voucher_detail_no + if frappe.get_cached_value(row.voucher_type, row.voucher_no, "is_return"): + key = frappe.get_cached_value(row.voucher_type + " Item", row.voucher_detail_no, field) + + if key not in available_dict: + available_dict[key] = frappe._dict( + {"qty": 0.0, "serial_nos": defaultdict(float), "batches": defaultdict(float)} + ) + + available_dict[key]["qty"] += row.qty + + if row.serial_no: + available_dict[key]["serial_nos"][row.serial_no] += row.qty + elif row.batch_no: + available_dict[key]["batches"][row.batch_no] += row.qty + + return available_dict + + +def get_serial_and_batch_bundle(field, doctype, reference_ids, is_rejected=False): + filters = {"docstatus": 1, "name": ("in", reference_ids), "serial_and_batch_bundle": ("is", "set")} + + pluck_field = "serial_and_batch_bundle" + if is_rejected: + del filters["serial_and_batch_bundle"] + filters["rejected_serial_and_batch_bundle"] = ("is", "set") + pluck_field = "rejected_serial_and_batch_bundle" + + _bundle_ids = frappe.get_all( + doctype, + filters=filters, + pluck=pluck_field, + ) + + if not _bundle_ids: + return {} + + del filters["name"] + + filters[field] = ("in", reference_ids) + + if not is_rejected: + _bundle_ids.extend( + frappe.get_all( + doctype, + filters=filters, + pluck="serial_and_batch_bundle", + ) + ) + else: + fields = [ + "serial_and_batch_bundle", + ] + + if is_rejected: + fields.extend(["rejected_serial_and_batch_bundle", "return_qty_from_rejected_warehouse"]) + + data = frappe.get_all( + doctype, + fields=fields, + filters=filters, + ) + + for d in data: + if is_rejected: + if d.get("return_qty_from_rejected_warehouse"): + _bundle_ids.append(d.get("serial_and_batch_bundle")) + else: + _bundle_ids.append(d.get("rejected_serial_and_batch_bundle")) + else: + _bundle_ids.append(d.get("serial_and_batch_bundle")) + + return _bundle_ids + + +def filter_serial_batches(parent_doc, data, row, warehouse_field=None, qty_field=None): + if not qty_field: + qty_field = "qty" + + if not warehouse_field: + warehouse_field = "warehouse" + + warehouse = row.get(warehouse_field) + qty = abs(row.get(qty_field)) + + filterd_serial_batch = frappe._dict({"serial_nos": [], "batches": defaultdict(float)}) + + if data.serial_nos: + available_serial_nos = [] + for serial_no, sn_qty in data.serial_nos.items(): + if sn_qty != 0: + available_serial_nos.append(serial_no) + + if available_serial_nos: + if parent_doc.doctype in ["Purchase Invoice", "Purchase Reecipt"]: + available_serial_nos = get_available_serial_nos(available_serial_nos) + + if len(available_serial_nos) > qty: + filterd_serial_batch["serial_nos"] = sorted(available_serial_nos[0 : cint(qty)]) + else: + filterd_serial_batch["serial_nos"] = available_serial_nos + + elif data.batches: + for batch_no, batch_qty in data.batches.items(): + if parent_doc.get("is_internal_customer"): + batch_qty = batch_qty * -1 + + if batch_qty <= 0: + continue + + if parent_doc.doctype in ["Purchase Invoice", "Purchase Reecipt"]: + batch_qty = get_available_batch_qty( + parent_doc, + batch_no, + warehouse, + ) + + if batch_qty <= 0: + frappe.throw( + _("Batch {0} is not available in warehouse {1}").format(batch_no, warehouse), + title=_("Batch Not Available for Return"), + ) + + if qty <= 0: + break + + if batch_qty > qty: + filterd_serial_batch["batches"][batch_no] = qty + qty = 0 + else: + filterd_serial_batch["batches"][batch_no] += batch_qty + qty -= batch_qty + + return filterd_serial_batch + + +def get_available_batch_qty(parent_doc, batch_no, warehouse): + from erpnext.stock.doctype.batch.batch import get_batch_qty + + return get_batch_qty( + batch_no, + warehouse, + posting_date=parent_doc.posting_date, + posting_time=parent_doc.posting_time, + for_stock_levels=True, + ) + + +def make_serial_batch_bundle_for_return(data, child_doc, parent_doc, warehouse_field=None): + from erpnext.stock.serial_batch_bundle import SerialBatchCreation + + type_of_transaction = "Outward" + if parent_doc.doctype in ["Sales Invoice", "Delivery Note", "POS Invoice"]: + type_of_transaction = "Inward" + + if not warehouse_field: + warehouse_field = "warehouse" + + warehouse = child_doc.get(warehouse_field) + if parent_doc.get("is_internal_customer"): + warehouse = child_doc.get("target_warehouse") + type_of_transaction = "Outward" + + cls_obj = SerialBatchCreation( + { + "type_of_transaction": type_of_transaction, + "item_code": child_doc.item_code, + "warehouse": warehouse, + "serial_nos": data.get("serial_nos"), + "batches": data.get("batches"), + "posting_date": parent_doc.posting_date, + "posting_time": parent_doc.posting_time, + "voucher_type": parent_doc.doctype, + "voucher_no": parent_doc.name, + "voucher_detail_no": child_doc.name, + "qty": child_doc.qty, + "company": parent_doc.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle() + + return cls_obj.name + + +def get_available_serial_nos(serial_nos, warehouse): + return frappe.get_all( + "Serial No", filters={"warehouse": warehouse, "name": ("in", serial_nos)}, pluck="name" + ) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 61d284431bfb371ff2597486eef1905f0b6ad112..d740f3c6fc7656d9d71ec2d332f63db1165df8ca 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -17,6 +17,11 @@ from erpnext.accounts.general_ledger import ( ) from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController +from erpnext.controllers.sales_and_purchase_return import ( + available_serial_batch_for_return, + filter_serial_batches, + make_serial_batch_bundle_for_return, +) from erpnext.stock import get_warehouse_account_map from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( get_evaluated_inventory_dimension, @@ -220,6 +225,125 @@ class StockController(AccountsController): self.update_bundle_details(bundle_details, table_name, row, is_rejected=True) self.create_serial_batch_bundle(bundle_details, row) + def make_bundle_for_sales_purchase_return(self, table_name=None): + if not self.get("is_return"): + return + + if not table_name: + table_name = "items" + + self.make_bundle_for_non_rejected_qty(table_name) + + if self.doctype in ["Purchase Invoice", "Purchase Receipt"]: + self.make_bundle_for_rejected_qty(table_name) + + def make_bundle_for_rejected_qty(self, table_name=None): + field, reference_ids = self.get_reference_ids( + table_name, "rejected_qty", "rejected_serial_and_batch_bundle" + ) + + if not reference_ids: + return + + child_doctype = self.doctype + " Item" + available_dict = available_serial_batch_for_return( + field, child_doctype, reference_ids, is_rejected=True + ) + + for row in self.get(table_name): + if data := available_dict.get(row.get(field)): + qty_field = "rejected_qty" + warehouse_field = "rejected_warehouse" + if row.get("return_qty_from_rejected_warehouse"): + qty_field = "qty" + warehouse_field = "warehouse" + + data = filter_serial_batches( + self, data, row, warehouse_field=warehouse_field, qty_field=qty_field + ) + bundle = make_serial_batch_bundle_for_return(data, row, self, warehouse_field) + if row.get("return_qty_from_rejected_warehouse"): + row.db_set( + { + "serial_and_batch_bundle": bundle, + "batch_no": "", + "serial_no": "", + } + ) + else: + row.db_set( + { + "rejected_serial_and_batch_bundle": bundle, + "batch_no": "", + "rejected_serial_no": "", + } + ) + + def make_bundle_for_non_rejected_qty(self, table_name): + field, reference_ids = self.get_reference_ids(table_name) + if not reference_ids: + return + + child_doctype = self.doctype + " Item" + available_dict = available_serial_batch_for_return(field, child_doctype, reference_ids) + + for row in self.get(table_name): + if data := available_dict.get(row.get(field)): + data = filter_serial_batches(self, data, row) + bundle = make_serial_batch_bundle_for_return(data, row, self) + row.db_set( + { + "serial_and_batch_bundle": bundle, + "batch_no": "", + "serial_no": "", + } + ) + + def get_reference_ids(self, table_name, qty_field=None, bundle_field=None) -> tuple[str, list[str]]: + field = { + "Sales Invoice": "sales_invoice_item", + "Delivery Note": "dn_detail", + "Purchase Receipt": "purchase_receipt_item", + "Purchase Invoice": "purchase_invoice_item", + "POS Invoice": "pos_invoice_item", + }.get(self.doctype) + + if not bundle_field: + bundle_field = "serial_and_batch_bundle" + + if not qty_field: + qty_field = "qty" + + reference_ids = [] + + for row in self.get(table_name): + if not self.is_serial_batch_item(row.item_code): + continue + + if ( + row.get(field) + and ( + qty_field == "qty" + and not row.get("return_qty_from_rejected_warehouse") + or qty_field == "rejected_qty" + and (row.get("return_qty_from_rejected_warehouse") or row.get("rejected_warehouse")) + ) + and not row.get("use_serial_batch_fields") + and not row.get(bundle_field) + ): + reference_ids.append(row.get(field)) + + return field, reference_ids + + @frappe.request_cache + def is_serial_batch_item(self, item_code) -> bool: + item_details = frappe.db.get_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1) + + if item_details.has_serial_no or item_details.has_batch_no: + return True + + return False + def update_bundle_details(self, bundle_details, table_name, row, is_rejected=False): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -613,35 +737,16 @@ class StockController(AccountsController): def make_package_for_transfer( self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None ): - bundle_doc = frappe.get_doc("Serial and Batch Bundle", serial_and_batch_bundle) - - if not type_of_transaction: - type_of_transaction = "Inward" - - bundle_doc = frappe.copy_doc(bundle_doc) - bundle_doc.warehouse = warehouse - bundle_doc.type_of_transaction = type_of_transaction - bundle_doc.voucher_type = self.doctype - bundle_doc.voucher_no = "" if self.is_new() or self.docstatus == 2 else self.name - bundle_doc.is_cancelled = 0 - - for row in bundle_doc.entries: - row.is_outward = 0 - row.qty = abs(row.qty) - row.stock_value_difference = abs(row.stock_value_difference) - if type_of_transaction == "Outward": - row.qty *= -1 - row.stock_value_difference *= row.stock_value_difference - row.is_outward = 1 - - row.warehouse = warehouse - - bundle_doc.calculate_qty_and_amount() - bundle_doc.flags.ignore_permissions = True - bundle_doc.flags.ignore_validate = True - bundle_doc.save(ignore_permissions=True) - - return bundle_doc.name + return make_bundle_for_material_transfer( + is_new=self.is_new(), + docstatus=self.docstatus, + voucher_type=self.doctype, + voucher_no=self.name, + serial_and_batch_bundle=serial_and_batch_bundle, + warehouse=warehouse, + type_of_transaction=type_of_transaction, + do_not_submit=do_not_submit, + ) def get_sl_entries(self, d, args): sl_dict = frappe._dict( @@ -1572,3 +1677,38 @@ def create_item_wise_repost_entries( repost_entries.append(repost_entry) return repost_entries + + +def make_bundle_for_material_transfer(**kwargs): + if isinstance(kwargs, dict): + kwargs = frappe._dict(kwargs) + + bundle_doc = frappe.get_doc("Serial and Batch Bundle", kwargs.serial_and_batch_bundle) + + if not kwargs.type_of_transaction: + kwargs.type_of_transaction = "Inward" + + bundle_doc = frappe.copy_doc(bundle_doc) + bundle_doc.warehouse = kwargs.warehouse + bundle_doc.type_of_transaction = kwargs.type_of_transaction + bundle_doc.voucher_type = kwargs.voucher_type + bundle_doc.voucher_no = "" if kwargs.is_new or kwargs.docstatus == 2 else kwargs.voucher_no + bundle_doc.is_cancelled = 0 + + for row in bundle_doc.entries: + row.is_outward = 0 + row.qty = abs(row.qty) + row.stock_value_difference = abs(row.stock_value_difference) + if kwargs.type_of_transaction == "Outward": + row.qty *= -1 + row.stock_value_difference *= row.stock_value_difference + row.is_outward = 1 + + row.warehouse = kwargs.warehouse + + bundle_doc.calculate_qty_and_amount() + bundle_doc.flags.ignore_permissions = True + bundle_doc.flags.ignore_validate = True + bundle_doc.save(ignore_permissions=True) + + return bundle_doc.name diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index f2a53858a9a62ffe22d465b08991e73451f1feee..79ace06d25a40c682854f006eeeb89c4a02eaea6 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -8,8 +8,11 @@ from pypika import Order class DeprecatedSerialNoValuation: @deprecated def calculate_stock_value_from_deprecarated_ledgers(self): - if not frappe.db.get_value( - "Stock Ledger Entry", {"serial_no": ("is", "set"), "is_cancelled": 0}, "name" + if not frappe.db.get_all( + "Stock Ledger Entry", + fields=["name"], + filters={"serial_no": ("is", "set"), "is_cancelled": 0, "item_code": self.sle.item_code}, + limit=1, ): return @@ -41,6 +44,12 @@ class DeprecatedSerialNoValuation: # get rate from serial nos within same company incoming_values = 0.0 for serial_no in serial_nos: + sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1) + if sn_details and sn_details.purchase_rate and sn_details.company == self.sle.company: + self.serial_no_incoming_rate[serial_no] += flt(sn_details.purchase_rate) + incoming_values += self.serial_no_incoming_rate[serial_no] + continue + table = frappe.qb.DocType("Stock Ledger Entry") stock_ledgers = ( frappe.qb.from_(table) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 3cf720a025e46016f970bcdd49d7e3822be8061c..ef1b0725f223dfab1dce598db0d73a48221cd261 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -179,7 +179,8 @@ def get_batch_qty( :param batch_no: Optional - give qty for this batch no :param warehouse: Optional - give qty for this warehouse - :param item_code: Optional - give qty for this item""" + :param item_code: Optional - give qty for this item + :param for_stock_levels: True consider expired batches""" from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( get_auto_batch_nos, diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index a5569784db83fb1fb84ecbcb6845068140b242c0..6ce05941cbc7369b49163692e87b741e804aef9c 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -459,6 +459,7 @@ class DeliveryNote(SellingController): if not self.get(table_name): continue + self.make_bundle_for_sales_purchase_return(table_name) self.make_bundle_using_old_serial_batch_fields(table_name) # Updating stock ledger should always be called after updating prevdoc status, @@ -1355,6 +1356,9 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): if source_parent.doctype == "Delivery Note" and source.received_qty: target.qty = flt(source.qty) + flt(source.returned_qty) - flt(source.received_qty) + if source.get("use_serial_batch_fields"): + target.set("use_serial_batch_fields", 1) + doclist = get_mapped_doc( doctype, source_name, diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 9a32e32fb6abaf9e0014b77ae5e546161e665a75..af5a49b7763bf0e19f82439a0c7b3fbad45144f1 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -249,18 +249,15 @@ class TestDeliveryNote(FrappeTestCase): self.assertTrue(dn.items[0].serial_no) frappe.flags.ignore_serial_batch_bundle_validation = False + frappe.flags.use_serial_and_batch_fields = False # return entry dn1 = make_sales_return(dn.name) dn1.items[0].qty = -2 - - bundle_doc = frappe.get_doc("Serial and Batch Bundle", dn1.items[0].serial_and_batch_bundle) - bundle_doc.set("entries", bundle_doc.entries[:2]) - bundle_doc.save() - - dn1.save() + dn1.items[0].serial_no = "\n".join(get_serial_nos(serial_nos)[0:2]) dn1.submit() + dn1.reload() returned_serial_nos1 = get_serial_nos_from_bundle(dn1.items[0].serial_and_batch_bundle) for serial_no in returned_serial_nos1: @@ -269,21 +266,15 @@ class TestDeliveryNote(FrappeTestCase): dn2 = make_sales_return(dn.name) dn2.items[0].qty = -2 - - bundle_doc = frappe.get_doc("Serial and Batch Bundle", dn2.items[0].serial_and_batch_bundle) - bundle_doc.set("entries", bundle_doc.entries[:2]) - bundle_doc.save() - - dn2.save() + dn2.items[0].serial_no = "\n".join(get_serial_nos(serial_nos)[2:4]) dn2.submit() + dn2.reload() returned_serial_nos2 = get_serial_nos_from_bundle(dn2.items[0].serial_and_batch_bundle) for serial_no in returned_serial_nos2: self.assertTrue(serial_no in serial_nos) self.assertFalse(serial_no in returned_serial_nos1) - frappe.flags.use_serial_and_batch_fields = False - def test_sales_return_for_non_bundled_items_partial(self): company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") @@ -428,7 +419,7 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.per_returned, 100) self.assertEqual(dn.status, "Return Issued") - def test_delivery_note_return_valuation_on_different_warehuose(self): + def test_delivery_note_return_valuation_on_different_warehouse(self): from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 76a4f5901186da941d914a8f7fc52a002ca5e161..e814840becf0613449071d43dd7790e2d3eab38a 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -375,6 +375,7 @@ class PurchaseReceipt(BuyingController): else: self.db_set("status", "Completed") + self.make_bundle_for_sales_purchase_return() self.make_bundle_using_old_serial_batch_fields() # Updating stock ledger should always be called after updating prevdoc status, # because updating ordered qty, reserved_qty_for_subcontract in bin diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 2ee40f3163d109e802244725f99362b2b1a3bb56..0fc5d4fe98b779a80ac63692756311a85b59018d 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -2651,7 +2651,7 @@ class TestPurchaseReceipt(FrappeTestCase): for row in inter_transfer_dn_return.items: self.assertTrue(row.serial_and_batch_bundle) - def test_internal_transfer_with_serial_batch_items_without_user_serial_batch_fields(self): + def test_internal_transfer_with_serial_batch_items_without_use_serial_batch_fields(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note 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 a97a016c868095abdc3e0dbdbbd6008135271452..a501ce82391bcb426f41db01d962890bbde14308 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -81,6 +81,7 @@ "purchase_invoice", "column_break_40", "allow_zero_valuation_rate", + "return_qty_from_rejected_warehouse", "is_fixed_asset", "asset_location", "asset_category", @@ -1117,12 +1118,19 @@ "hidden": 1, "label": "Apply TDS", "read_only": 1 + }, + { + "default": "0", + "fieldname": "return_qty_from_rejected_warehouse", + "fieldtype": "Check", + "label": "Return Qty from Rejected Warehouse", + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2024-04-08 20:00:16.277292", + "modified": "2024-05-28 09:48:24.448815", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py index 4666ed29d207e563c5ce02a5ec49e0c43c2bd83d..2d913f1f347a7f521cc11cc48470d1ac763a7515 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py @@ -86,6 +86,7 @@ class PurchaseReceiptItem(Document): rejected_serial_no: DF.Text | None rejected_warehouse: DF.Link | None retain_sample: DF.Check + return_qty_from_rejected_warehouse: DF.Check returned_qty: DF.Float rm_supp_cost: DF.Currency sales_order: DF.Link | None diff --git a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py index 6e3672461b90a561c0bc2895fb62916a8e0e3a14..153bf4cedd14a52b611d602b4c2f92d4888b3b3b 100644 --- a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py @@ -50,9 +50,7 @@ class TestPutawayRule(FrappeTestCase): def test_putaway_rules_priority(self): """Test if rule is applied by priority, irrespective of free space.""" - rule_1 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_1, capacity=200, uom="Kg" - ) + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, uom="Kg") rule_2 = create_putaway_rule( item_code="_Rice", warehouse=self.warehouse_2, capacity=300, uom="Kg", priority=2 ) @@ -73,17 +71,11 @@ class TestPutawayRule(FrappeTestCase): def test_putaway_rules_with_same_priority(self): """Test if rule with more free space is applied, among two rules with same priority and capacity.""" - rule_1 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_1, capacity=500, uom="Kg" - ) - rule_2 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_2, capacity=500, uom="Kg" - ) + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=500, uom="Kg") + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=500, uom="Kg") # out of 500 kg capacity, occupy 100 kg in warehouse_1 - stock_receipt = make_stock_entry( - item_code="_Rice", target=self.warehouse_1, qty=100, basic_rate=50 - ) + stock_receipt = make_stock_entry(item_code="_Rice", target=self.warehouse_1, qty=100, basic_rate=50) pr = make_purchase_receipt(item_code="_Rice", qty=700, apply_putaway_rule=1, do_not_submit=1) self.assertEqual(len(pr.items), 2) @@ -101,12 +93,8 @@ class TestPutawayRule(FrappeTestCase): def test_putaway_rules_with_insufficient_capacity(self): """Test if qty exceeding capacity, is handled.""" - rule_1 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_1, capacity=100, uom="Kg" - ) - rule_2 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_2, capacity=200, uom="Kg" - ) + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=100, uom="Kg") + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=200, uom="Kg") pr = make_purchase_receipt(item_code="_Rice", qty=350, apply_putaway_rule=1, do_not_submit=1) self.assertEqual(len(pr.items), 2) @@ -127,19 +115,13 @@ class TestPutawayRule(FrappeTestCase): item.append("uoms", {"uom": "Bag", "conversion_factor": 1000}) item.save() - rule_1 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_1, capacity=3, uom="Bag" - ) + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=3, uom="Bag") self.assertEqual(rule_1.stock_capacity, 3000) - rule_2 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_2, capacity=4, uom="Bag" - ) + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=4, uom="Bag") self.assertEqual(rule_2.stock_capacity, 4000) # populate 'Rack 1' with 1 Bag, making the free space 2 Bags - stock_receipt = make_stock_entry( - item_code="_Rice", target=self.warehouse_1, qty=1000, basic_rate=50 - ) + stock_receipt = make_stock_entry(item_code="_Rice", target=self.warehouse_1, qty=1000, basic_rate=50) pr = make_purchase_receipt( item_code="_Rice", @@ -171,9 +153,7 @@ class TestPutawayRule(FrappeTestCase): frappe.db.set_value("UOM", "Bag", "must_be_whole_number", 1) # Putaway Rule in different UOM - rule_1 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_1, capacity=1, uom="Bag" - ) + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=1, uom="Bag") self.assertEqual(rule_1.stock_capacity, 1000) # Putaway Rule in Stock UOM rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=500) @@ -203,9 +183,7 @@ class TestPutawayRule(FrappeTestCase): def test_putaway_rules_with_reoccurring_item(self): """Test rules on same item entered multiple times with different rate.""" - rule_1 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_1, capacity=200, uom="Kg" - ) + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, uom="Kg") # total capacity is 200 Kg pr = make_purchase_receipt(item_code="_Rice", qty=100, apply_putaway_rule=1, do_not_submit=1) @@ -241,9 +219,7 @@ class TestPutawayRule(FrappeTestCase): def test_validate_over_receipt_in_warehouse(self): """Test if overreceipt is blocked in the presence of putaway rules.""" - rule_1 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_1, capacity=200, uom="Kg" - ) + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200, uom="Kg") pr = make_purchase_receipt(item_code="_Rice", qty=300, apply_putaway_rule=1, do_not_submit=1) self.assertEqual(len(pr.items), 1) @@ -295,9 +271,7 @@ class TestPutawayRule(FrappeTestCase): def test_putaway_rule_on_stock_entry_material_transfer_reoccuring_item(self): """Test if reoccuring item is correctly considered.""" - rule_1 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_1, capacity=300, uom="Kg" - ) + rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=300, uom="Kg") rule_2 = create_putaway_rule( item_code="_Rice", warehouse=self.warehouse_2, capacity=600, uom="Kg", priority=2 ) @@ -403,7 +377,7 @@ class TestPutawayRule(FrappeTestCase): apply_putaway_rule=1, do_not_save=1, ) - stock_entry.save() + stock_entry.submit() stock_entry.load_from_db() self.assertEqual(stock_entry.items[0].t_warehouse, self.warehouse_1) @@ -424,11 +398,17 @@ class TestPutawayRule(FrappeTestCase): self.assertUnchangedItemsOnResave(stock_entry) - for row in stock_entry.items: - if row.serial_and_batch_bundle: - frappe.delete_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) - stock_entry.load_from_db() + stock_entry.cancel() + + rivs = frappe.get_all("Repost Item Valuation", filters={"voucher_no": stock_entry.name}) + for row in rivs: + riv_doc = frappe.get_doc("Repost Item Valuation", row.name) + riv_doc.cancel() + riv_doc.delete() + + frappe.db.delete("Stock Ledger Entry", dict(voucher_type="Stock Entry", voucher_no=stock_entry.name)) + stock_entry.delete() pr.cancel() rule_1.delete() @@ -439,9 +419,7 @@ class TestPutawayRule(FrappeTestCase): rule_1 = create_putaway_rule( item_code="_Rice", warehouse=self.warehouse_1, capacity=200, uom="Kg" ) # more capacity - rule_2 = create_putaway_rule( - item_code="_Rice", warehouse=self.warehouse_2, capacity=100, uom="Kg" - ) + rule_2 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_2, capacity=100, uom="Kg") stock_entry = make_stock_entry( item_code="_Rice", @@ -491,9 +469,7 @@ def create_putaway_rule(**args): putaway.capacity = args.capacity or 1 putaway.stock_uom = frappe.db.get_value("Item", putaway.item_code, "stock_uom") putaway.uom = args.uom or putaway.stock_uom - putaway.conversion_factor = get_conversion_factor(putaway.item_code, putaway.uom)[ - "conversion_factor" - ] + putaway.conversion_factor = get_conversion_factor(putaway.item_code, putaway.uom)["conversion_factor"] if not args.do_not_save: putaway.save() diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 4234140209602411f0159bea367da8aacf471c5b..a1b374ba8c765cf0f2e9757e1ce6d2015f652664 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -159,6 +159,8 @@ class SerialandBatchBundle(Document): def validate_serial_nos_duplicate(self): # Don't inward same serial number multiple times + if self.voucher_type in ["POS Invoice", "Pick List"]: + return if not self.warehouse: return diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index da6f3d7482292f3b370d46ac81d145563c633ccf..0d47a25ee6200fc0fd76e8900b68bd4d2f699b8a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -96,6 +96,8 @@ frappe.ui.form.on('Stock Entry', { // or a pre-existing batch if (frm.doc.purpose != "Material Receipt") { filters["warehouse"] = item.s_warehouse || item.t_warehouse; + } else { + filters["is_inward"] = 1; } return { @@ -1067,6 +1069,13 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle on_submit() { this.clean_up(); + this.refresh_serial_batch_bundle_field(); + } + + refresh_serial_batch_bundle_field() { + frappe.route_hooks.after_submit = (frm_obj) => { + frm_obj.reload_doc(); + }; } after_cancel() { diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e9a74c82de04149838306ccceb5c030de85d7f4e..2a4ca01441cc9ee4a271219b9c7dc9f1840c5595 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -229,6 +229,7 @@ class StockEntry(StockController): if not self.from_bom: self.fg_completed_qty = 0.0 + self.make_serial_and_batch_bundle_for_outward() self.validate_serialized_batch() self.calculate_rate_and_amount() self.validate_putaway_capacity() @@ -292,9 +293,6 @@ class StockEntry(StockController): if self.purpose == "Material Transfer" and self.outgoing_stock_entry: self.set_material_request_transfer_status("In Transit") - def before_save(self): - self.make_serial_and_batch_bundle_for_outward() - def on_update(self): self.set_serial_and_batch_bundle() @@ -998,7 +996,7 @@ class StockEntry(StockController): self.purpose = frappe.get_cached_value("Stock Entry Type", self.stock_entry_type, "purpose") def make_serial_and_batch_bundle_for_outward(self): - if self.docstatus == 1: + if self.docstatus == 0: return serial_or_batch_items = get_serial_or_batch_items(self.items) @@ -1051,12 +1049,11 @@ class StockEntry(StockController): if not bundle_doc: continue - if self.docstatus == 0: - for entry in bundle_doc.entries: - if not entry.serial_no: - continue + for entry in bundle_doc.entries: + if not entry.serial_no: + continue - already_picked_serial_nos.append(entry.serial_no) + already_picked_serial_nos.append(entry.serial_no) row.serial_and_batch_bundle = bundle_doc.name @@ -1631,11 +1628,7 @@ class StockEntry(StockController): ret.update(get_uom_details(args.get("item_code"), args.get("uom"), args.get("qty"))) if self.purpose == "Material Issue": - ret["expense_account"] = ( - item.get("expense_account") - or item_group_defaults.get("expense_account") - or frappe.get_cached_value("Company", self.company, "default_expense_account") - ) + ret["expense_account"] = item.get("expense_account") or item_group_defaults.get("expense_account") for company_field, field in { "stock_adjustment_account": "expense_account", diff --git a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py index b7d4ee3b809b8fcdd7f653a36de7d242d152cd75..47b93fafba5727d4b90b698f3380339fba416bad 100644 --- a/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py +++ b/erpnext/stock/report/batch_wise_balance_history/batch_wise_balance_history.py @@ -4,7 +4,7 @@ import frappe from frappe import _ -from frappe.utils import cint, flt, get_table_name, getdate +from frappe.utils import add_to_date, cint, flt, get_datetime, get_table_name, getdate from frappe.utils.deprecations import deprecated from pypika import functions as fn @@ -107,6 +107,8 @@ def get_stock_ledger_entries_for_batch_no(filters): if not filters.get("to_date"): frappe.throw(_("'To Date' is required")) + posting_datetime = get_datetime(add_to_date(filters["to_date"], days=1)) + sle = frappe.qb.DocType("Stock Ledger Entry") query = ( frappe.qb.from_(sle) @@ -121,7 +123,7 @@ def get_stock_ledger_entries_for_batch_no(filters): (sle.docstatus < 2) & (sle.is_cancelled == 0) & (sle.batch_no != "") - & (sle.posting_date <= filters["to_date"]) + & (sle.posting_datetime < posting_datetime) ) .groupby(sle.voucher_no, sle.batch_no, sle.item_code, sle.warehouse) .orderby(sle.item_code, sle.warehouse) @@ -222,9 +224,7 @@ def get_item_warehouse_batch_map(filters, float_precision): def get_item_details(filters): item_map = {} - for d in (frappe.qb.from_("Item").select("name", "item_name", "description", "stock_uom")).run( - as_dict=1 - ): + for d in (frappe.qb.from_("Item").select("name", "item_name", "description", "stock_uom")).run(as_dict=1): item_map.setdefault(d.name, d) return item_map diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 701940214b3516af393f099ee5d0c82a86ad397e..9ad7e4e6fd08c2fcc8dde580431804aedad67b35 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -56,8 +56,45 @@ class SerialBatchBundle: elif not self.sle.is_cancelled: self.validate_item_and_warehouse() + def is_material_transfer(self): + allowed_types = [ + "Material Transfer", + "Send to Subcontractor", + "Material Transfer for Manufacture", + ] + + if ( + self.sle.voucher_type == "Stock Entry" + and not self.sle.is_cancelled + and frappe.get_cached_value("Stock Entry", self.sle.voucher_no, "purpose") in allowed_types + ): + return True + + def make_serial_batch_no_bundle_for_material_transfer(self): + from erpnext.controllers.stock_controller import make_bundle_for_material_transfer + + bundle = frappe.db.get_value( + "Stock Entry Detail", self.sle.voucher_detail_no, "serial_and_batch_bundle" + ) + + if bundle: + new_bundle_id = make_bundle_for_material_transfer( + is_new=False, + docstatus=1, + voucher_type=self.sle.voucher_type, + voucher_no=self.sle.voucher_no, + serial_and_batch_bundle=bundle, + warehouse=self.sle.warehouse, + type_of_transaction="Inward" if self.sle.actual_qty > 0 else "Outward", + do_not_submit=0, + ) + self.sle.db_set({"serial_and_batch_bundle": new_bundle_id}) + def make_serial_batch_no_bundle(self): self.validate_item() + if self.sle.actual_qty > 0 and self.is_material_transfer(): + self.make_serial_batch_no_bundle_for_material_transfer() + return sn_doc = SerialBatchCreation( { @@ -153,6 +190,9 @@ class SerialBatchBundle: "serial_and_batch_bundle": sn_doc.name, } + if self.sle.actual_qty < 0 and self.is_material_transfer(): + values_to_update["valuation_rate"] = sn_doc.avg_rate + if not frappe.db.get_single_value( "Stock Settings", "do_not_update_serial_batch_on_creation_of_auto_bundle" ): @@ -353,13 +393,9 @@ def get_serial_nos(serial_and_batch_bundle, serial_nos=None): if serial_nos: filters["serial_no"] = ("in", serial_nos) - entries = frappe.get_all( - "Serial and Batch Entry", fields=["serial_no"], filters=filters, order_by="idx" - ) - if not entries: - return [] + serial_nos = frappe.get_all("Serial and Batch Entry", filters=filters, order_by="idx", pluck="serial_no") - return [d.serial_no for d in entries if d.serial_no] + return serial_nos def get_batches_from_bundle(serial_and_batch_bundle, batches=None): diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 69019b3fbc728237f0c78ba67d61aa4765201604..b00c6a22ad823137274e2c556caab2960009bcc2 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -315,7 +315,11 @@ def get_reposting_data(file_path) -> dict: if isinstance(content, str): content = content.encode("utf-8") - data = gzip.decompress(content) + try: + data = gzip.decompress(content) + except Exception: + return frappe._dict() + if data := json.loads(data.decode("utf-8")): data = data diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index c449c47a0a48ce3cf7b3163dee84d4719cec6903..a595746030d665de8c4242e55e0c99bb67ed9f4b 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -278,6 +278,21 @@ frappe.ui.form.on('Subcontracting Receipt', { } } }, + + reset_raw_materials_table: (frm) => { + frm.clear_table("supplied_items"); + + frm.call({ + method: "reset_raw_materials", + doc: frm.doc, + freeze: true, + callback: (r) => { + if (!r.exc) { + frm.save(); + } + }, + }); + }, }); frappe.ui.form.on('Landed Cost Taxes and Charges', { diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json index 85b4fe47c15d3e661c034f9103dc94edfe0ec6c4..660e36b8e03837be4dcfe79eaa312d59bd61c4d3 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.json @@ -47,8 +47,11 @@ "total_qty", "column_break_27", "total", - "raw_material_details", + "raw_materials_consumed_section", + "reset_raw_materials_table", + "column_break_uinr", "get_current_stock", + "raw_material_details", "supplied_items", "additional_costs_section", "distribute_additional_costs_based_on", @@ -300,6 +303,7 @@ "depends_on": "supplied_items", "fieldname": "raw_material_details", "fieldtype": "Section Break", + "hide_border": 1, "label": "Raw Materials Consumed", "options": "fa fa-table", "print_hide": 1, @@ -640,12 +644,26 @@ "fieldname": "supplier_delivery_note", "fieldtype": "Data", "label": "Supplier Delivery Note" + }, + { + "fieldname": "raw_materials_consumed_section", + "fieldtype": "Section Break", + "label": "Raw Materials Actions" + }, + { + "fieldname": "reset_raw_materials_table", + "fieldtype": "Button", + "label": "Reset Raw Materials Table" + }, + { + "fieldname": "column_break_uinr", + "fieldtype": "Column Break" } ], "in_create": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-22 16:15:08.074134", + "modified": "2024-05-27 15:02:13.517969", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index ecdd0db11b1bcf791534f44dcf5a7698bcc00a95..ee81f13d9c24170256c5f0803b5824937245d311 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -181,6 +181,11 @@ class SubcontractingReceipt(SubcontractingController): self.update_status() self.delete_auto_created_batches() + @frappe.whitelist() + def reset_raw_materials(self): + self.supplied_items = [] + self.create_raw_materials_supplied() + def validate_closed_subcontracting_order(self): for item in self.items: if item.subcontracting_order: