diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c3cbbb7dfed..7375ca14727 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,12 +59,14 @@ repos: rev: v0.2.0 hooks: - id: ruff - name: "Run ruff linter and apply fixes" - args: ["--fix"] + name: "Run ruff import sorter" + args: ["--select=I", "--fix"] + + - id: ruff + name: "Run ruff linter" - id: ruff-format - name: "Format Python code" - + name: "Run ruff formatter" ci: autoupdate_schedule: weekly diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py index b158edaed5f..9faf8693a8a 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py @@ -70,7 +70,7 @@ class POSClosingEntry(StatusUpdater): for key, value in pos_occurences.items(): if len(value) > 1: error_list.append( - _(f"{frappe.bold(key)} is added multiple times on rows: {frappe.bold(value)}") + _("{0} is added multiple times on rows: {1}").format(frappe.bold(key), frappe.bold(value)) ) if error_list: diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 3350a160cea..34a31d52dd0 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: diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index 4816cccb285..1fca0495098 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -318,29 +318,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/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py index 7501df02c3f..e8f94b880e2 100644 --- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py +++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py @@ -54,7 +54,7 @@ class POSInvoiceMergeLog(Document): for key, value in pos_occurences.items(): if len(value) > 1: error_list.append( - _(f"{frappe.bold(key)} is added multiple times on rows: {frappe.bold(value)}") + _("{0} is added multiple times on rows: {1}").format(frappe.bold(key), frappe.bold(value)) ) if error_list: @@ -481,7 +481,7 @@ def create_merge_logs(invoice_by_customer, closing_entry=None): if closing_entry: closing_entry.set_status(update=True, status="Failed") if isinstance(error_message, list): - error_message = frappe.json.dumps(error_message) + error_message = json.dumps(error_message) closing_entry.db_set("error_message", error_message) raise diff --git a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py index d4d66a51c34..5048fc5e25e 100644 --- a/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py +++ b/erpnext/accounts/doctype/process_payment_reconciliation/process_payment_reconciliation.py @@ -1,6 +1,8 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +import json + import frappe from frappe import _, qb from frappe.model.document import Document @@ -504,7 +506,7 @@ def is_any_doc_running(for_filter: str | dict | None = None) -> str | None: running_doc = None if for_filter: if isinstance(for_filter, str): - for_filter = frappe.json.loads(for_filter) + for_filter = json.loads(for_filter) running_doc = frappe.db.get_value( "Process Payment Reconciliation", diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index f4d38220ae4..49ba9f459f2 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -448,7 +448,7 @@ class PurchaseInvoice(BuyingController): stock_not_billed_account = self.get_company_default("stock_received_but_not_billed") stock_items = self.get_stock_items() - asset_received_but_not_billed = None + asset_received_but_not_billed = self.get_company_default("asset_received_but_not_billed") if self.update_stock: self.validate_item_code() @@ -531,26 +531,40 @@ class PurchaseInvoice(BuyingController): frappe.msgprint(msg, title=_("Expense Head Changed")) item.expense_account = stock_not_billed_account - elif item.is_fixed_asset and item.pr_detail: - if not asset_received_but_not_billed: - asset_received_but_not_billed = self.get_company_default("asset_received_but_not_billed") - item.expense_account = asset_received_but_not_billed elif item.is_fixed_asset: - account_type = ( - "capital_work_in_progress_account" - if is_cwip_accounting_enabled(item.asset_category) - else "fixed_asset_account" - ) - asset_category_account = get_asset_category_account( - account_type, item=item.item_code, company=self.company - ) - if not asset_category_account: - form_link = get_link_to_form("Asset Category", item.asset_category) - throw( - _("Please set Fixed Asset Account in {} against {}.").format(form_link, self.company), - title=_("Missing Account"), + account = None + if item.pr_detail: + # check if 'Asset Received But Not Billed' account is credited in Purchase receipt or not + arbnb_booked_in_pr = frappe.db.get_value( + "GL Entry", + { + "voucher_type": "Purchase Receipt", + "voucher_no": item.purchase_receipt, + "account": asset_received_but_not_billed, + }, + "name", ) - item.expense_account = asset_category_account + if arbnb_booked_in_pr: + account = asset_received_but_not_billed + + if not account: + account_type = ( + "capital_work_in_progress_account" + if is_cwip_accounting_enabled(item.asset_category) + else "fixed_asset_account" + ) + account = get_asset_category_account( + account_type, item=item.item_code, company=self.company + ) + if not account: + form_link = get_link_to_form("Asset Category", item.asset_category) + throw( + _("Please set Fixed Asset Account in {} against {}.").format( + form_link, self.company + ), + title=_("Missing Account"), + ) + item.expense_account = account elif not item.expense_account and for_validate: throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name)) @@ -707,6 +721,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/repost_accounting_ledger/repost_accounting_ledger.json b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json index 5b7cd2b0b20..f9344ce4f3a 100644 --- a/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json +++ b/erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json @@ -1,7 +1,5 @@ { "actions": [], - "allow_rename": 1, - "autoname": "format:ACC-REPOST-{#####}", "creation": "2023-07-04 13:07:32.923675", "default_view": "List", "doctype": "DocType", @@ -55,11 +53,10 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-09-26 14:21:27.362567", + "modified": "2024-05-23 17:00:42.984798", "modified_by": "Administrator", "module": "Accounts", "name": "Repost Accounting Ledger", - "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json index ed8d395a0ec..4ecff8cac3b 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json +++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.json @@ -1,6 +1,5 @@ { "actions": [], - "allow_rename": 1, "creation": "2022-10-19 21:59:33.553852", "doctype": "DocType", "editable_grid": 1, @@ -99,7 +98,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-09-26 14:21:35.719727", + "modified": "2024-05-23 17:00:31.540640", "modified_by": "Administrator", "module": "Accounts", "name": "Repost Payment Ledger", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 63f13ae251f..68da9ea1657 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -450,6 +450,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() diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index ee8658a9414..5cffe11e995 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2,6 +2,7 @@ # License: GNU General Public License v3. See license.txt import copy +import json import frappe from frappe.model.dynamic_links import get_dynamic_link_map @@ -3720,9 +3721,9 @@ class TestSalesInvoice(FrappeTestCase): map_docs( method="erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", - source_names=frappe.json.dumps([dn1.name, dn2.name]), + source_names=json.dumps([dn1.name, dn2.name]), target_doc=si, - args=frappe.json.dumps({"customer": dn1.customer, "merge_taxes": 1, "filtered_children": []}), + args=json.dumps({"customer": dn1.customer, "merge_taxes": 1, "filtered_children": []}), ) si.save().submit() diff --git a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json index e7536e9bb94..932bc8e49d4 100644 --- a/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json +++ b/erpnext/accounts/doctype/sales_invoice_item/sales_invoice_item.json @@ -870,7 +870,8 @@ "label": "Purchase Order", "options": "Purchase Order", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "column_break_92", @@ -926,7 +927,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2024-02-25 15:56:44.828634", + "modified": "2024-05-23 16:36:18.970862", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Item", @@ -936,4 +937,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py index edbd78db611..7612294a85c 100644 --- a/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py +++ b/erpnext/accounts/doctype/unreconcile_payment/unreconcile_payment.py @@ -1,6 +1,8 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +import json + import frappe from frappe import _, qb from frappe.model.document import Document @@ -161,7 +163,7 @@ def get_linked_payments_for_doc( @frappe.whitelist() def create_unreconcile_doc_for_selection(selections=None): if selections: - selections = frappe.json.loads(selections) + selections = json.loads(selections) # assuming each row is a unique voucher for row in selections: unrecon = frappe.new_doc("Unreconcile Payment") diff --git a/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py b/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py index 834eb5f519c..f3f30d38a04 100644 --- a/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py +++ b/erpnext/accounts/report/payment_period_based_on_invoice_date/payment_period_based_on_invoice_date.py @@ -3,7 +3,9 @@ import frappe -from frappe import _ +from frappe import _, qb +from frappe.query_builder import Criterion +from frappe.query_builder.functions import Abs from frappe.utils import flt, getdate from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport @@ -21,16 +23,12 @@ def execute(filters=None): data = [] for d in entries: - invoice = invoice_details.get(d.against_voucher) or frappe._dict() - - if d.reference_type == "Purchase Invoice": - payment_amount = flt(d.debit) or -1 * flt(d.credit) - else: - payment_amount = flt(d.credit) or -1 * flt(d.debit) + invoice = invoice_details.get(d.against_voucher_no) or frappe._dict() + payment_amount = d.amount d.update({"range1": 0, "range2": 0, "range3": 0, "range4": 0, "outstanding": payment_amount}) - if d.against_voucher: + if d.against_voucher_no: ReceivablePayableReport(filters).get_ageing_data(invoice.posting_date, d) row = [ @@ -39,11 +37,10 @@ def execute(filters=None): d.party_type, d.party, d.posting_date, - d.against_voucher, + d.against_voucher_no, invoice.posting_date, invoice.due_date, - d.debit, - d.credit, + d.amount, d.remarks, d.age, d.range1, @@ -111,8 +108,7 @@ def get_columns(filters): "width": 100, }, {"fieldname": "due_date", "label": _("Payment Due Date"), "fieldtype": "Date", "width": 100}, - {"fieldname": "debit", "label": _("Debit"), "fieldtype": "Currency", "width": 140}, - {"fieldname": "credit", "label": _("Credit"), "fieldtype": "Currency", "width": 140}, + {"fieldname": "amount", "label": _("Amount"), "fieldtype": "Currency", "width": 140}, {"fieldname": "remarks", "label": _("Remarks"), "fieldtype": "Data", "width": 200}, {"fieldname": "age", "label": _("Age"), "fieldtype": "Int", "width": 50}, {"fieldname": "range1", "label": _("0-30"), "fieldtype": "Currency", "width": 140}, @@ -129,51 +125,68 @@ def get_columns(filters): def get_conditions(filters): + ple = qb.DocType("Payment Ledger Entry") conditions = [] - if not filters.party_type: - if filters.payment_type == _("Outgoing"): - filters.party_type = "Supplier" - else: - filters.party_type = "Customer" - - if filters.party_type: - conditions.append("party_type=%(party_type)s") + conditions.append(ple.delinked.eq(0)) + if filters.payment_type == _("Outgoing"): + conditions.append(ple.party_type.eq("Supplier")) + conditions.append(ple.against_voucher_type.eq("Purchase Invoice")) + else: + conditions.append(ple.party_type.eq("Customer")) + conditions.append(ple.against_voucher_type.eq("Sales Invoice")) if filters.party: - conditions.append("party=%(party)s") - - if filters.party_type: - conditions.append("against_voucher_type=%(reference_type)s") - filters["reference_type"] = ( - "Sales Invoice" if filters.party_type == "Customer" else "Purchase Invoice" - ) + conditions.append(ple.party.eq(filters.party)) if filters.get("from_date"): - conditions.append("posting_date >= %(from_date)s") + conditions.append(ple.posting_date.gte(filters.get("from_date"))) if filters.get("to_date"): - conditions.append("posting_date <= %(to_date)s") + conditions.append(ple.posting_date.lte(filters.get("to_date"))) - return "and " + " and ".join(conditions) if conditions else "" + if filters.get("company"): + conditions.append(ple.company.eq(filters.get("company"))) + + return conditions def get_entries(filters): - return frappe.db.sql( - """select - voucher_type, voucher_no, party_type, party, posting_date, debit, credit, remarks, against_voucher - from `tabGL Entry` - where company=%(company)s and voucher_type in ('Journal Entry', 'Payment Entry') and is_cancelled = 0 {} - """.format(get_conditions(filters)), - filters, - as_dict=1, + ple = qb.DocType("Payment Ledger Entry") + conditions = get_conditions(filters) + + query = ( + qb.from_(ple) + .select( + ple.voucher_type, + ple.voucher_no, + ple.party_type, + ple.party, + ple.posting_date, + Abs(ple.amount).as_("amount"), + ple.remarks, + ple.against_voucher_no, + ) + .where(Criterion.all(conditions)) ) + res = query.run(as_dict=True) + return res def get_invoice_posting_date_map(filters): invoice_details = {} - dt = "Sales Invoice" if filters.get("payment_type") == _("Incoming") else "Purchase Invoice" - for t in frappe.db.sql(f"select name, posting_date, due_date from `tab{dt}`", as_dict=1): + dt = ( + qb.DocType("Sales Invoice") + if filters.get("payment_type") == _("Incoming") + else qb.DocType("Purchase Invoice") + ) + res = ( + qb.from_(dt) + .select(dt.name, dt.posting_date, dt.due_date) + .where((dt.docstatus.eq(1)) & (dt.company.eq(filters.get("company")))) + .run(as_dict=1) + ) + for t in res: invoice_details[t.name] = t return invoice_details diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index bbb8900de39..1ad24c46603 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -56,7 +56,7 @@ def get_fiscal_year( date=None, fiscal_year=None, label="Date", verbose=1, company=None, as_dict=False, boolean=False ): if isinstance(boolean, str): - boolean = frappe.json.loads(boolean) + boolean = loads(boolean) fiscal_years = get_fiscal_years( date, fiscal_year, label, verbose, company, as_dict=as_dict, boolean=boolean diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 272d077b1e3..b8689d29a56 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -513,7 +513,7 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll method: "frappe.desk.doctype.tag.tag.get_tagged_docs", args: { doctype: "Supplier", - tag: args.tag, + tag: "%" + args.tag + "%", }, callback: load_suppliers, }); diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 913665dce2b..96af12f2340 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 @@ -513,6 +514,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai 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( @@ -570,7 +572,14 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai 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 ) @@ -578,14 +587,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai 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 @@ -839,3 +841,229 @@ def get_returned_batches(child_doc, parent_doc, batch_no_field=None, ignore_vouc 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 63a8c842c9a..bde2f6bd099 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -16,6 +16,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, @@ -217,6 +222,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 @@ -611,35 +735,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( @@ -1557,3 +1662,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/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index fc66345aeee..e0f1ed77140 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -327,13 +327,13 @@ class SubcontractingController(StockController): consumed_bundles.batch_nos[batch_no] += abs(qty) # Will be deprecated in v16 - if row.serial_no: + if row.serial_no and not consumed_bundles.serial_nos: self.available_materials[key]["serial_no"] = list( set(self.available_materials[key]["serial_no"]) - set(get_serial_nos(row.serial_no)) ) # Will be deprecated in v16 - if row.batch_no: + if row.batch_no and not consumed_bundles.batch_nos: self.available_materials[key]["batch_no"][row.batch_no] -= row.consumed_qty def get_available_materials(self): diff --git a/erpnext/projects/doctype/project/project.js b/erpnext/projects/doctype/project/project.js index 49e8d8486a5..d03ab786cc1 100644 --- a/erpnext/projects/doctype/project/project.js +++ b/erpnext/projects/doctype/project/project.js @@ -55,6 +55,14 @@ frappe.ui.form.on("Project", { filters: filters, }; }); + + frm.set_query("cost_center", () => { + return { + filters: { + company: frm.doc.company, + }, + }; + }); }, refresh: function (frm) { diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index adf05ffb154..cf6b34c9604 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -83,7 +83,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { this.frm.doc.paid_amount = flt(this.frm.doc.grand_total, precision("grand_total")); } - this.frm.refresh_fields(); + this.frm.refresh_field("taxes"); } calculate_discount_amount() { @@ -841,7 +841,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { }); } - this.frm.refresh_fields(); + this.frm.refresh_field("taxes"); } set_default_payment(total_amount_to_pay, update_paid_amount) { diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 63a44cc19fb..90051673e70 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -2,6 +2,8 @@ # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.test_runner import make_test_records from frappe.tests.utils import FrappeTestCase @@ -321,7 +323,7 @@ class TestCustomer(FrappeTestCase): frappe.ValidationError, update_child_qty_rate, so.doctype, - frappe.json.dumps([modified_item]), + json.dumps([modified_item]), so.name, ) diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.js b/erpnext/selling/doctype/product_bundle/product_bundle.js index 3096b692a7e..67b9ae5ba31 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.js +++ b/erpnext/selling/doctype/product_bundle/product_bundle.js @@ -9,5 +9,13 @@ frappe.ui.form.on("Product Bundle", { query: "erpnext.selling.doctype.product_bundle.product_bundle.get_new_item_code", }; }); + + frm.set_query("item_code", "items", () => { + return { + filters: { + has_variants: 0, + }, + }; + }); }, }); diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 3a045d5ff2d..0695c3fd9c4 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1139,7 +1139,8 @@ "hide_seconds": 1, "label": "Inter Company Order Reference", "options": "Purchase Order", - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "project", @@ -1645,7 +1646,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2024-03-29 16:27:41.539613", + "modified": "2024-05-23 16:35:54.905804", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 5b826455d4c..e43e6f21c92 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 77b87aa995c..0be85e46015 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -208,7 +208,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 ec05738c68a..c971c8da4f0 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -467,6 +467,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, @@ -1371,6 +1372,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 192d828d843..5ebcc795fa0 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 415f882129e..0742ba3b590 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -370,6 +370,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 ede2dc00714..edfe8152169 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -2645,7 +2645,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 a95f99ffd43..fd26b611f45 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", @@ -1116,12 +1117,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.278292", + "modified": "2024-05-28 09:48:24.448815", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", @@ -1132,4 +1140,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} 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 908c0a7a0f4..393b6a25691 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py @@ -85,6 +85,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 9f7fdeccd05..9178229c018 100644 --- a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py @@ -377,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) @@ -398,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.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 1) + stock_entry.delete() pr.cancel() rule_1.delete() 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 5e16115db01..ad757351a9b 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 @@ -156,6 +156,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 fb63f1c23c6..4e00de0d7ce 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -111,6 +111,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 { @@ -1110,6 +1112,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 569d4e9c0a0..266d74afbed 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -226,6 +226,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() @@ -289,9 +290,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() @@ -992,7 +990,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) @@ -1045,12 +1043,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 diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 6dcd3b6f484..aa9dfd2048e 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -1,7 +1,6 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - import frappe from frappe import _, throw from frappe.contacts.address_and_contact import load_address_and_contact 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 16a0de57a5d..822da13cc72 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) diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py index f229f73e683..6ef02724f65 100644 --- a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py +++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py @@ -9,6 +9,9 @@ from frappe import _ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos as get_serial_nos_from_sle from erpnext.stock.stock_ledger import get_stock_ledger_entries +BUYING_VOUCHER_TYPES = ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"] +SELLING_VOUCHER_TYPES = ["Sales Invoice", "Delivery Note"] + def execute(filters=None): columns = get_columns(filters) @@ -72,6 +75,20 @@ def get_columns(filters): "fieldname": "qty", "width": 150, }, + { + "label": _("Party Type"), + "fieldtype": "Link", + "fieldname": "party_type", + "options": "DocType", + "width": 90, + }, + { + "label": _("Party"), + "fieldtype": "Dynamic Link", + "fieldname": "party", + "options": "party_type", + "width": 120, + }, ] return columns @@ -102,6 +119,17 @@ def get_data(filters): } ) + # get party details depending on the voucher type + party_field = ( + "supplier" + if row.voucher_type in BUYING_VOUCHER_TYPES + else ("customer" if row.voucher_type in SELLING_VOUCHER_TYPES else None) + ) + args.party_type = party_field.title() if party_field else None + args.party = ( + frappe.db.get_value(row.voucher_type, row.voucher_no, party_field) if party_field else None + ) + serial_nos = [] if row.serial_no: parsed_serial_nos = get_serial_nos_from_sle(row.serial_no) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index b57333f9f35..1a50e3dea89 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -27,7 +27,11 @@ def execute(filters=None): items = get_items(filters) sl_entries = get_stock_ledger_entries(filters, items) item_details = get_item_details(items, sl_entries, include_uom) - opening_row = get_opening_balance(filters, columns, sl_entries) + if filters.get("batch_no"): + opening_row = get_opening_balance_from_batch(filters, columns, sl_entries) + else: + opening_row = get_opening_balance(filters, columns, sl_entries) + precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) bundle_details = {} @@ -48,13 +52,16 @@ def execute(filters=None): available_serial_nos = {} inventory_dimension_filters_applied = check_inventory_dimension_filters_applied(filters) - batch_balance_dict = defaultdict(float) + batch_balance_dict = frappe._dict({}) + if actual_qty and filters.get("batch_no"): + batch_balance_dict[filters.batch_no] = [actual_qty, stock_value] + for sle in sl_entries: item_detail = item_details[sle.item_code] sle.update(item_detail) if bundle_info := bundle_details.get(sle.serial_and_batch_bundle): - data.extend(get_segregated_bundle_entries(sle, bundle_info, batch_balance_dict)) + data.extend(get_segregated_bundle_entries(sle, bundle_info, batch_balance_dict, filters)) continue if filters.get("batch_no") or inventory_dimension_filters_applied: @@ -90,7 +97,7 @@ def execute(filters=None): return columns, data -def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict): +def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict, filters): segregated_entries = [] qty_before_transaction = sle.qty_after_transaction - sle.actual_qty stock_value_before_transaction = sle.stock_value - sle.stock_value_difference @@ -109,9 +116,19 @@ def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict): } ) - if row.batch_no: - batch_balance_dict[row.batch_no] += row.qty - new_sle.update({"qty_after_transaction": batch_balance_dict[row.batch_no]}) + if filters.get("batch_no") and row.batch_no: + if not batch_balance_dict.get(row.batch_no): + batch_balance_dict[row.batch_no] = [0, 0] + + batch_balance_dict[row.batch_no][0] += row.qty + batch_balance_dict[row.batch_no][1] += row.stock_value_difference + + new_sle.update( + { + "qty_after_transaction": batch_balance_dict[row.batch_no][0], + "stock_value": batch_balance_dict[row.batch_no][1], + } + ) qty_before_transaction += row.qty stock_value_before_transaction += new_sle.stock_value_difference @@ -504,6 +521,62 @@ def get_sle_conditions(filters): return "and {}".format(" and ".join(conditions)) if conditions else "" +def get_opening_balance_from_batch(filters, columns, sl_entries): + query_filters = { + "batch_no": filters.batch_no, + "docstatus": 1, + "posting_date": ("<", filters.from_date), + } + + for fields in ["item_code", "warehouse"]: + if filters.get(fields): + query_filters[fields] = filters.get(fields) + + opening_data = frappe.get_all( + "Stock Ledger Entry", + fields=["sum(actual_qty) as qty_after_transaction", "sum(stock_value_difference) as stock_value"], + filters=query_filters, + )[0] + + for field in ["qty_after_transaction", "stock_value", "valuation_rate"]: + if opening_data.get(field) is None: + opening_data[field] = 0.0 + + query_filters = [ + ["Serial and Batch Entry", "batch_no", "=", filters.batch_no], + ["Serial and Batch Bundle", "docstatus", "=", 1], + ["Serial and Batch Bundle", "posting_date", "<", filters.from_date], + ] + + for fields in ["item_code", "warehouse"]: + if filters.get(fields): + query_filters.append(["Serial and Batch Bundle", fields, "=", filters.get(fields)]) + + bundle_data = frappe.get_all( + "Serial and Batch Bundle", + fields=[ + "sum(`tabSerial and Batch Entry`.`qty`) as qty", + "sum(`tabSerial and Batch Entry`.`stock_value_difference`) as stock_value", + ], + filters=query_filters, + ) + + if bundle_data: + opening_data.qty_after_transaction += flt(bundle_data[0].qty) + opening_data.stock_value += flt(bundle_data[0].stock_value) + if opening_data.qty_after_transaction: + opening_data.valuation_rate = flt(opening_data.stock_value) / flt( + opening_data.qty_after_transaction + ) + + return { + "item_code": _("'Opening'"), + "qty_after_transaction": opening_data.qty_after_transaction, + "valuation_rate": opening_data.valuation_rate, + "stock_value": opening_data.stock_value, + } + + def get_opening_balance(filters, columns, sl_entries): if not (filters.item_code and filters.warehouse and filters.from_date): return diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 50a7707d4a9..fcebf0491ac 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -55,8 +55,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( { @@ -143,6 +180,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" ): @@ -341,11 +381,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 8036a3e179d..abbd48853cc 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -308,7 +308,15 @@ def get_reposting_data(file_path) -> dict: attached_file = frappe.get_doc("File", file_name) - data = gzip.decompress(attached_file.get_content()) + content = attached_file.get_content() + if isinstance(content, str): + content = content.encode("utf-8") + + try: + data = gzip.decompress(content) + except Exception: + return frappe._dict() + if data := json.loads(data.decode("utf-8")): data = data @@ -1428,7 +1436,11 @@ def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_vouc order by posting_datetime desc, creation desc limit 1 for update""", - args, + { + "item_code": args.get("item_code"), + "warehouse": args.get("warehouse"), + "posting_datetime": args.get("posting_datetime"), + }, as_dict=1, ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 0dff297e45d..8dfd9bd486d 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -302,6 +302,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 383a83b3fcd..8bcf97da40d 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": "2023-11-16 13:04:00.710534", + "modified": "2024-05-28 15:02:13.517969", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt", @@ -714,4 +732,4 @@ "timeline_field": "supplier", "title_field": "title", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 19e8dfd5431..5e717e1f22a 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -179,6 +179,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: diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 996a99065bb..81662a6257b 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -1235,6 +1235,116 @@ class TestSubcontractingReceipt(FrappeTestCase): self.assertTrue(scr.items[0].serial_and_batch_bundle) self.assertTrue(scr.items[0].rejected_serial_and_batch_bundle) + def test_subcontracting_receipt_for_batch_materials_without_use_serial_batch_fields(self): + from erpnext.controllers.subcontracting_controller import make_rm_stock_entry + + set_backflush_based_on("Material Transferred for Subcontract") + + fg_item = make_item( + "Test Subcontracted FG Item With Batch No and Without Use Serial Batch Fields", + properties={"is_stock_item": 1, "is_sub_contracted_item": 1}, + ).name + + rm_item1 = make_item( + "Test Subcontracted RM Item With Batch No and Without Use Serial Batch Fields", + properties={ + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "BATCH-RM-BNGS-.####", + }, + ).name + + make_item( + "Subcontracted Service Item 21", + properties={ + "is_stock_item": 0, + }, + ) + + bom = make_bom(item=fg_item, raw_materials=[rm_item1]) + + rm_batch_no = None + for row in bom.items: + se = make_stock_entry( + item_code=row.item_code, + qty=10, + target="_Test Warehouse - _TC", + rate=300, + ) + + se.reload() + rm_batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle) + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 21", + "qty": 10, + "rate": 100, + "fg_item": fg_item, + "fg_item_qty": 10, + }, + ] + sco = get_subcontracting_order(service_items=service_items) + self.assertTrue(sco.docstatus) + rm_items = [ + { + "name": sco.supplied_items[0].name, + "item_code": rm_item1, + "rm_item_code": rm_item1, + "item_name": rm_item1, + "qty": 10, + "warehouse": "_Test Warehouse - _TC", + "rate": 100, + "stock_uom": frappe.get_cached_value("Item", rm_item1, "stock_uom"), + "use_serial_batch_fields": 1, + }, + ] + se = frappe.get_doc(make_rm_stock_entry(sco.name, rm_items)) + se.items[0].subcontracted_item = fg_item + se.items[0].s_warehouse = "_Test Warehouse - _TC" + se.items[0].t_warehouse = "_Test Warehouse 1 - _TC" + se.items[0].use_serial_batch_fields = 1 + se.items[0].batch_no = rm_batch_no + se.submit() + + self.assertEqual(se.items[0].batch_no, rm_batch_no) + self.assertEqual(se.items[0].use_serial_batch_fields, 1) + + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0) + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty = 2 + scr.save() + scr.submit() + + self.assertEqual(scr.supplied_items[0].consumed_qty, 2) + self.assertEqual(scr.supplied_items[0].batch_no, rm_batch_no) + self.assertEqual(get_batch_from_bundle(scr.supplied_items[0].serial_and_batch_bundle), rm_batch_no) + + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty = 2 + scr.save() + scr.submit() + + self.assertEqual(scr.supplied_items[0].consumed_qty, 2) + self.assertEqual(scr.supplied_items[0].batch_no, rm_batch_no) + self.assertEqual(get_batch_from_bundle(scr.supplied_items[0].serial_and_batch_bundle), rm_batch_no) + + scr = make_subcontracting_receipt(sco.name) + scr.items[0].qty = 6 + scr.save() + scr.submit() + + self.assertEqual(scr.supplied_items[0].consumed_qty, 6) + self.assertEqual(scr.supplied_items[0].batch_no, rm_batch_no) + self.assertEqual(get_batch_from_bundle(scr.supplied_items[0].serial_and_batch_bundle), rm_batch_no) + + sco.reload() + self.assertEqual(sco.status, "Completed") + + frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) + def make_return_subcontracting_receipt(**args): args = frappe._dict(args)