Merge pull request #37625 from ruthra-kumar/set_multi_currency_fields_for_je_in_bank_reconciliation
refactor: set exchange rate on foreign currency JE from Bank Reconciliation
diff --git a/.github/workflows/initiate_release.yml b/.github/workflows/initiate_release.yml
index 7034773..e51c194 100644
--- a/.github/workflows/initiate_release.yml
+++ b/.github/workflows/initiate_release.yml
@@ -15,7 +15,7 @@
strategy:
fail-fast: false
matrix:
- version: ["13", "14"]
+ version: ["13", "14", "15"]
steps:
- uses: octokit/request-action@v2.x
diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
index 1d843ab..71bc498 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py
@@ -14,6 +14,7 @@
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.party import get_party_account
+from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.doctype.item.test_item import create_item
test_dependencies = ["Item"]
@@ -85,26 +86,44 @@
self.customer5 = make_customer("_Test PR Customer 5", "EUR")
def create_account(self):
- account_name = "Debtors EUR"
- if not frappe.db.get_value(
- "Account", filters={"account_name": account_name, "company": self.company}
- ):
- acc = frappe.new_doc("Account")
- acc.account_name = account_name
- acc.parent_account = "Accounts Receivable - _PR"
- acc.company = self.company
- acc.account_currency = "EUR"
- acc.account_type = "Receivable"
- acc.insert()
- else:
- name = frappe.db.get_value(
- "Account",
- filters={"account_name": account_name, "company": self.company},
- fieldname="name",
- pluck=True,
- )
- acc = frappe.get_doc("Account", name)
- self.debtors_eur = acc.name
+ accounts = [
+ {
+ "attribute": "debtors_eur",
+ "account_name": "Debtors EUR",
+ "parent_account": "Accounts Receivable - _PR",
+ "account_currency": "EUR",
+ "account_type": "Receivable",
+ },
+ {
+ "attribute": "creditors_usd",
+ "account_name": "Payable USD",
+ "parent_account": "Accounts Payable - _PR",
+ "account_currency": "USD",
+ "account_type": "Payable",
+ },
+ ]
+
+ for x in accounts:
+ x = frappe._dict(x)
+ if not frappe.db.get_value(
+ "Account", filters={"account_name": x.account_name, "company": self.company}
+ ):
+ acc = frappe.new_doc("Account")
+ acc.account_name = x.account_name
+ acc.parent_account = x.parent_account
+ acc.company = self.company
+ acc.account_currency = x.account_currency
+ acc.account_type = x.account_type
+ acc.insert()
+ else:
+ name = frappe.db.get_value(
+ "Account",
+ filters={"account_name": x.account_name, "company": self.company},
+ fieldname="name",
+ pluck=True,
+ )
+ acc = frappe.get_doc("Account", name)
+ setattr(self, x.attribute, acc.name)
def create_sales_invoice(
self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False
@@ -151,6 +170,64 @@
payment.posting_date = posting_date
return payment
+ def create_purchase_invoice(
+ self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False
+ ):
+ """
+ Helper function to populate default values in sales invoice
+ """
+ pinv = make_purchase_invoice(
+ qty=qty,
+ rate=rate,
+ company=self.company,
+ customer=self.supplier,
+ item_code=self.item,
+ item_name=self.item,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_to,
+ parent_cost_center=self.cost_center,
+ update_stock=0,
+ currency="INR",
+ is_pos=0,
+ is_return=0,
+ return_against=None,
+ income_account=self.income_account,
+ expense_account=self.expense_account,
+ do_not_save=do_not_save,
+ do_not_submit=do_not_submit,
+ )
+ return pinv
+
+ def create_purchase_order(
+ self, qty=1, rate=100, posting_date=nowdate(), do_not_save=False, do_not_submit=False
+ ):
+ """
+ Helper function to populate default values in sales invoice
+ """
+ pord = create_purchase_order(
+ qty=qty,
+ rate=rate,
+ company=self.company,
+ customer=self.supplier,
+ item_code=self.item,
+ item_name=self.item,
+ cost_center=self.cost_center,
+ warehouse=self.warehouse,
+ debit_to=self.debit_to,
+ parent_cost_center=self.cost_center,
+ update_stock=0,
+ currency="INR",
+ is_pos=0,
+ is_return=0,
+ return_against=None,
+ income_account=self.income_account,
+ expense_account=self.expense_account,
+ do_not_save=do_not_save,
+ do_not_submit=do_not_submit,
+ )
+ return pord
+
def clear_old_entries(self):
doctype_list = [
"GL Entry",
@@ -163,13 +240,11 @@
for doctype in doctype_list:
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
- def create_payment_reconciliation(self):
+ def create_payment_reconciliation(self, party_is_customer=True):
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
- pr.party_type = (
- self.party_type if hasattr(self, "party_type") and self.party_type else "Customer"
- )
- pr.party = self.customer
+ pr.party_type = "Customer" if party_is_customer else "Supplier"
+ pr.party = self.customer if party_is_customer else self.supplier
pr.receivable_payable_account = get_party_account(pr.party_type, pr.party, pr.company)
pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = nowdate()
return pr
@@ -906,9 +981,13 @@
self.assertEqual(pr.allocation[0].difference_amount, 0)
def test_reconciliation_purchase_invoice_against_return(self):
- pi = make_purchase_invoice(
- supplier="_Test Supplier USD", currency="USD", conversion_rate=50
- ).submit()
+ self.supplier = "_Test Supplier USD"
+ pi = self.create_purchase_invoice(qty=5, rate=50, do_not_submit=True)
+ pi.supplier = self.supplier
+ pi.currency = "USD"
+ pi.conversion_rate = 50
+ pi.credit_to = self.creditors_usd
+ pi.save().submit()
pi_return = frappe.get_doc(pi.as_dict())
pi_return.name = None
@@ -918,11 +997,12 @@
pi_return.items[0].qty = -pi_return.items[0].qty
pi_return.submit()
- self.company = "_Test Company"
- self.party_type = "Supplier"
- self.customer = "_Test Supplier USD"
-
- pr = self.create_payment_reconciliation()
+ pr = frappe.get_doc("Payment Reconciliation")
+ pr.company = self.company
+ pr.party_type = "Supplier"
+ pr.party = self.supplier
+ pr.receivable_payable_account = self.creditors_usd
+ pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = nowdate()
pr.get_unreconciled_entries()
invoices = []
@@ -931,6 +1011,7 @@
if invoice.invoice_number == pi.name:
invoices.append(invoice.as_dict())
break
+
for payment in pr.payments:
if payment.reference_name == pi_return.name:
payments.append(payment.as_dict())
@@ -941,6 +1022,121 @@
# Should not raise frappe.exceptions.ValidationError: Total Debit must be equal to Total Credit.
pr.reconcile()
+ def test_reconciliation_from_purchase_order_to_multiple_invoices(self):
+ """
+ Reconciling advance payment from PO/SO to multiple invoices should not cause overallocation
+ """
+
+ self.supplier = "_Test Supplier"
+
+ pi1 = self.create_purchase_invoice(qty=10, rate=100)
+ pi2 = self.create_purchase_invoice(qty=10, rate=100)
+ po = self.create_purchase_order(qty=20, rate=100)
+ pay = get_payment_entry(po.doctype, po.name)
+ # Overpay Puchase Order
+ pay.paid_amount = 3000
+ pay.save().submit()
+ # assert total allocated and unallocated before reconciliation
+ self.assertEqual(
+ (
+ pay.references[0].reference_doctype,
+ pay.references[0].reference_name,
+ pay.references[0].allocated_amount,
+ ),
+ (po.doctype, po.name, 2000),
+ )
+ self.assertEqual(pay.total_allocated_amount, 2000)
+ self.assertEqual(pay.unallocated_amount, 1000)
+ self.assertEqual(pay.difference_amount, 0)
+
+ pr = self.create_payment_reconciliation(party_is_customer=False)
+ pr.get_unreconciled_entries()
+
+ self.assertEqual(len(pr.invoices), 2)
+ self.assertEqual(len(pr.payments), 2)
+
+ for x in pr.payments:
+ self.assertEqual((x.reference_type, x.reference_name), (pay.doctype, pay.name))
+
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ # partial allocation on pi1 and full allocate on pi2
+ pr.allocation[0].allocated_amount = 100
+ pr.reconcile()
+
+ # assert references and total allocated and unallocated amount
+ pay.reload()
+ self.assertEqual(len(pay.references), 3)
+ self.assertEqual(
+ (
+ pay.references[0].reference_doctype,
+ pay.references[0].reference_name,
+ pay.references[0].allocated_amount,
+ ),
+ (po.doctype, po.name, 900),
+ )
+ self.assertEqual(
+ (
+ pay.references[1].reference_doctype,
+ pay.references[1].reference_name,
+ pay.references[1].allocated_amount,
+ ),
+ (pi1.doctype, pi1.name, 100),
+ )
+ self.assertEqual(
+ (
+ pay.references[2].reference_doctype,
+ pay.references[2].reference_name,
+ pay.references[2].allocated_amount,
+ ),
+ (pi2.doctype, pi2.name, 1000),
+ )
+ self.assertEqual(pay.total_allocated_amount, 2000)
+ self.assertEqual(pay.unallocated_amount, 1000)
+ self.assertEqual(pay.difference_amount, 0)
+
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 2)
+
+ invoices = [x.as_dict() for x in pr.invoices]
+ payments = [x.as_dict() for x in pr.payments]
+ pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
+ pr.reconcile()
+
+ # assert references and total allocated and unallocated amount
+ pay.reload()
+ self.assertEqual(len(pay.references), 3)
+ # PO references should be removed now
+ self.assertEqual(
+ (
+ pay.references[0].reference_doctype,
+ pay.references[0].reference_name,
+ pay.references[0].allocated_amount,
+ ),
+ (pi1.doctype, pi1.name, 100),
+ )
+ self.assertEqual(
+ (
+ pay.references[1].reference_doctype,
+ pay.references[1].reference_name,
+ pay.references[1].allocated_amount,
+ ),
+ (pi2.doctype, pi2.name, 1000),
+ )
+ self.assertEqual(
+ (
+ pay.references[2].reference_doctype,
+ pay.references[2].reference_name,
+ pay.references[2].allocated_amount,
+ ),
+ (pi1.doctype, pi1.name, 900),
+ )
+ self.assertEqual(pay.total_allocated_amount, 2000)
+ self.assertEqual(pay.unallocated_amount, 1000)
+ self.assertEqual(pay.difference_amount, 0)
+
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index f2691fb..1c7052f 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -645,7 +645,7 @@
"outstanding_amount": d.outstanding_amount,
"allocated_amount": d.allocated_amount,
"exchange_rate": d.exchange_rate if d.exchange_gain_loss else payment_entry.get_exchange_rate(),
- "exchange_gain_loss": d.exchange_gain_loss, # only populated from invoice in case of advance allocation
+ "exchange_gain_loss": d.exchange_gain_loss,
"account": d.account,
}
@@ -658,22 +658,21 @@
existing_row.reference_doctype, existing_row.reference_name
).set_total_advance_paid()
- original_row = existing_row.as_dict().copy()
- existing_row.update(reference_details)
+ if d.allocated_amount <= existing_row.allocated_amount:
+ existing_row.allocated_amount -= d.allocated_amount
- if d.allocated_amount < original_row.allocated_amount:
new_row = payment_entry.append("references")
new_row.docstatus = 1
for field in list(reference_details):
- new_row.set(field, original_row[field])
+ new_row.set(field, reference_details[field])
- new_row.allocated_amount = original_row.allocated_amount - d.allocated_amount
else:
new_row = payment_entry.append("references")
new_row.docstatus = 1
new_row.update(reference_details)
payment_entry.flags.ignore_validate_update_after_submit = True
+ payment_entry.clear_unallocated_reference_document_rows()
payment_entry.setup_party_account_field()
payment_entry.set_missing_values()
if not skip_ref_details_update_for_pe:
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index 99824b7..d69f5ef 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -1789,6 +1789,7 @@
"fixed_asset_account": "_Test Fixed Asset - _TC",
"accumulated_depreciation_account": "_Test Accumulated Depreciations - _TC",
"depreciation_expense_account": "_Test Depreciations - _TC",
+ "capital_work_in_progress_account": "CWIP Account - _TC",
},
)
asset_category.append(
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 465fe96..7c40aaf 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -556,6 +556,8 @@
"bom": "bom",
"material_request": "material_request",
"material_request_item": "material_request_item",
+ "sales_order": "sales_order",
+ "sales_order_item": "sales_order_item",
},
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty)
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index a40976b..a7330ec 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -76,8 +76,6 @@
gl_entries = self.get_gl_entries(warehouse_account)
make_gl_entries(gl_entries, from_repost=from_repost)
- update_regional_gl_entries(gl_entries, self)
-
def validate_serialized_batch(self):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -1226,8 +1224,3 @@
repost_entries.append(repost_entry)
return repost_entries
-
-
-@erpnext.allow_regional
-def update_regional_gl_entries(gl_list, doc):
- return
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index d59fe0e..53bddb5 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -340,5 +340,6 @@
execute:frappe.delete_doc("Page", "welcome-to-erpnext")
erpnext.patches.v15_0.delete_payment_gateway_doctypes
erpnext.patches.v14_0.create_accounting_dimensions_in_sales_order_item
+erpnext.patches.v15_0.update_sre_from_voucher_details
# below migration patch should always run last
erpnext.patches.v14_0.migrate_gl_to_payment_ledger
\ No newline at end of file
diff --git a/erpnext/patches/v15_0/update_sre_from_voucher_details.py b/erpnext/patches/v15_0/update_sre_from_voucher_details.py
new file mode 100644
index 0000000..06ba553
--- /dev/null
+++ b/erpnext/patches/v15_0/update_sre_from_voucher_details.py
@@ -0,0 +1,18 @@
+import frappe
+from frappe.query_builder.functions import IfNull
+
+
+def execute():
+ columns = frappe.db.get_table_columns("Stock Reservation Entry")
+
+ if set(["against_pick_list", "against_pick_list_item"]).issubset(set(columns)):
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ (
+ frappe.qb.update(sre)
+ .set(sre.from_voucher_type, "Pick List")
+ .set(sre.from_voucher_no, sre.against_pick_list)
+ .set(sre.from_voucher_detail_no, sre.against_pick_list_item)
+ .where(
+ (IfNull(sre.against_pick_list, "") != "") & (IfNull(sre.against_pick_list_item, "") != "")
+ )
+ ).run()
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index ba8bc33..3ad18da 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -87,17 +87,13 @@
frm.events.get_items_from_internal_purchase_order(frm);
}
- if (frm.is_new()) {
+ if (frm.doc.docstatus === 0) {
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => {
- if (value) {
- frappe.db.get_single_value("Stock Settings", "auto_reserve_stock_for_sales_order").then((value) => {
- // If `Reserve Stock on Sales Order Submission` is enabled in Stock Settings, set Reserve Stock to 1 else 0.
- frm.set_value("reserve_stock", value ? 1 : 0);
- })
- } else {
- // If `Stock Reservation` is disabled in Stock Settings, set Reserve Stock to 0 and read only.
+ if (!value) {
+ // If `Stock Reservation` is disabled in Stock Settings, set Reserve Stock to 0 and make the field read-only and hidden.
frm.set_value("reserve_stock", 0);
frm.set_df_property("reserve_stock", "read_only", 1);
+ frm.set_df_property("reserve_stock", "hidden", 1);
}
})
}
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index a74084d..01d047c 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -1631,10 +1631,9 @@
{
"default": "0",
"depends_on": "eval: (doc.docstatus == 0 || doc.reserve_stock)",
- "description": "If checked, Stock Reservation Entries will be created on <b>Submit</b>",
+ "description": "If checked, Stock will be reserved on <b>Submit</b>",
"fieldname": "reserve_stock",
"fieldtype": "Check",
- "hidden": 1,
"label": "Reserve Stock",
"no_copy": 1,
"print_hide": 1,
@@ -1645,7 +1644,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2023-07-24 08:59:11.599875",
+ "modified": "2023-10-18 12:41:54.813462",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index b91002e..94f9d6e 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -3,6 +3,7 @@
import json
+from typing import Literal
import frappe
import frappe.utils
@@ -534,14 +535,24 @@
return False
@frappe.whitelist()
- def create_stock_reservation_entries(self, items_details=None, notify=True) -> None:
+ def create_stock_reservation_entries(
+ self,
+ items_details: list[dict] = None,
+ from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None,
+ notify=True,
+ ) -> None:
"""Creates Stock Reservation Entries for Sales Order Items."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
create_stock_reservation_entries_for_so_items as create_stock_reservation_entries,
)
- create_stock_reservation_entries(so=self, items_details=items_details, notify=notify)
+ create_stock_reservation_entries(
+ sales_order=self,
+ items_details=items_details,
+ from_voucher_type=from_voucher_type,
+ notify=notify,
+ )
@frappe.whitelist()
def cancel_stock_reservation_entries(self, sre_list=None, notify=True) -> None:
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 83689a2..d8b5878 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -1784,10 +1784,10 @@
si.submit()
pe.load_from_db()
- self.assertEqual(pe.references[0].reference_name, si.name)
- self.assertEqual(pe.references[0].allocated_amount, 200)
- self.assertEqual(pe.references[1].reference_name, so.name)
- self.assertEqual(pe.references[1].allocated_amount, 300)
+ self.assertEqual(pe.references[0].reference_name, so.name)
+ self.assertEqual(pe.references[0].allocated_amount, 300)
+ self.assertEqual(pe.references[1].reference_name, si.name)
+ self.assertEqual(pe.references[1].allocated_amount, 200)
def test_delivered_item_material_request(self):
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index ae05b80..7cd171e 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -265,7 +265,8 @@
from_date: moment(frm.doc.creation).format('YYYY-MM-DD'),
to_date: to_date,
voucher_type: "Sales Order",
- against_pick_list: frm.doc.name,
+ from_voucher_type: "Pick List",
+ from_voucher_no: frm.doc.name,
}
frappe.set_route("query-report", "Reserved Stock");
}
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 2fcd102..ed20209 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -229,20 +229,27 @@
def create_stock_reservation_entries(self, notify=True) -> None:
"""Creates Stock Reservation Entries for Sales Order Items against Pick List."""
- from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- create_stock_reservation_entries_for_so_items,
- )
-
- so_details = {}
+ so_items_details_map = {}
for location in self.locations:
if location.warehouse and location.sales_order and location.sales_order_item:
- so_details.setdefault(location.sales_order, []).append(location)
+ item_details = {
+ "name": location.sales_order_item,
+ "item_code": location.item_code,
+ "warehouse": location.warehouse,
+ "qty_to_reserve": (flt(location.picked_qty) - flt(location.stock_reserved_qty)),
+ "from_voucher_no": location.parent,
+ "from_voucher_detail_no": location.name,
+ "serial_and_batch_bundle": location.serial_and_batch_bundle,
+ }
+ so_items_details_map.setdefault(location.sales_order, []).append(item_details)
- if so_details:
- for so, locations in so_details.items():
+ if so_items_details_map:
+ for so, items_details in so_items_details_map.items():
so_doc = frappe.get_doc("Sales Order", so)
- create_stock_reservation_entries_for_so_items(
- so=so_doc, items_details=locations, against_pick_list=True, notify=notify
+ so_doc.create_stock_reservation_entries(
+ items_details=items_details,
+ from_voucher_type="Pick List",
+ notify=notify,
)
@frappe.whitelist()
@@ -253,7 +260,9 @@
cancel_stock_reservation_entries,
)
- cancel_stock_reservation_entries(against_pick_list=self.name, notify=notify)
+ cancel_stock_reservation_entries(
+ from_voucher_type="Pick List", from_voucher_no=self.name, notify=notify
+ )
def validate_picked_qty(self, data):
over_delivery_receipt_allowance = 100 + flt(
diff --git a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py
index 0830fa2..29571a5 100644
--- a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py
+++ b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py
@@ -2,7 +2,7 @@
return {
"fieldname": "pick_list",
"non_standard_fieldnames": {
- "Stock Reservation Entry": "against_pick_list",
+ "Stock Reservation Entry": "from_voucher_no",
},
"internal_links": {
"Sales Order": ["locations", "sales_order"],
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index d89d805..91344ea 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -263,6 +263,7 @@
self.make_gl_entries()
self.repost_future_sle_and_gle()
self.set_consumed_qty_in_subcontract_order()
+ self.reserve_stock_for_sales_order()
def check_next_docstatus(self):
submit_rv = frappe.db.sql(
@@ -541,17 +542,19 @@
d, gl_entries, self.posting_date, d.get("provisional_expense_account")
)
elif flt(d.qty) and (flt(d.valuation_rate) or self.is_return):
- is_asset_pr = any(d.is_fixed_asset for d in self.get("items"))
remarks = self.get("remarks") or _("Accounting Entry for {0}").format(
- "Asset" if is_asset_pr else "Stock"
+ "Asset" if d.is_fixed_asset else "Stock"
)
- if not (erpnext.is_perpetual_inventory_enabled(self.company) or is_asset_pr):
- return
+ if not (
+ (erpnext.is_perpetual_inventory_enabled(self.company) and d.item_code in stock_items)
+ or d.is_fixed_asset
+ ):
+ continue
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed")
- if is_asset_pr
+ if d.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed")
)
landed_cost_entries = get_item_account_wise_additional_cost(self.name)
@@ -757,7 +760,36 @@
pr_doc = self if (pr == self.name) else frappe.get_doc("Purchase Receipt", pr)
update_billing_percentage(pr_doc, update_modified=update_modified)
- self.load_from_db()
+ def reserve_stock_for_sales_order(self):
+ if self.is_return or not cint(
+ frappe.db.get_single_value("Stock Settings", "auto_reserve_stock_for_sales_order_on_purchase")
+ ):
+ return
+
+ self.reload() # reload to get the Serial and Batch Bundle Details
+
+ so_items_details_map = {}
+ for item in self.items:
+ if item.sales_order and item.sales_order_item:
+ item_details = {
+ "name": item.sales_order_item,
+ "item_code": item.item_code,
+ "warehouse": item.warehouse,
+ "qty_to_reserve": item.stock_qty,
+ "from_voucher_no": item.parent,
+ "from_voucher_detail_no": item.name,
+ "serial_and_batch_bundle": item.serial_and_batch_bundle,
+ }
+ so_items_details_map.setdefault(item.sales_order, []).append(item_details)
+
+ if so_items_details_map:
+ for so, items_details in so_items_details_map.items():
+ so_doc = frappe.get_doc("Sales Order", so)
+ so_doc.create_stock_reservation_entries(
+ items_details=items_details,
+ from_voucher_type="Purchase Receipt",
+ notify=True,
+ )
def get_stock_value_difference(voucher_no, voucher_detail_no, warehouse):
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py
index b3ae7b5..71489fb 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_dashboard.py
@@ -10,6 +10,7 @@
"Landed Cost Voucher": "receipt_document",
"Auto Repeat": "reference_document",
"Purchase Receipt": "return_against",
+ "Stock Reservation Entry": "from_voucher_no",
},
"internal_links": {
"Material Request": ["items", "material_request"],
@@ -18,7 +19,10 @@
"Quality Inspection": ["items", "quality_inspection"],
},
"transactions": [
- {"label": _("Related"), "items": ["Purchase Invoice", "Landed Cost Voucher", "Asset"]},
+ {
+ "label": _("Related"),
+ "items": ["Purchase Invoice", "Landed Cost Voucher", "Asset", "Stock Reservation Entry"],
+ },
{
"label": _("Reference"),
"items": ["Material Request", "Purchase Order", "Quality Inspection", "Project"],
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 d93d21c..f5240a6 100644
--- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
+++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
@@ -125,7 +125,9 @@
"dimension_col_break",
"cost_center",
"section_break_80",
- "page_break"
+ "page_break",
+ "sales_order",
+ "sales_order_item"
],
"fields": [
{
@@ -1062,12 +1064,32 @@
"fieldtype": "Link",
"label": "WIP Composite Asset",
"options": "Asset"
+ },
+ {
+ "fieldname": "sales_order",
+ "fieldtype": "Link",
+ "label": "Sales Order",
+ "no_copy": 1,
+ "options": "Sales Order",
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "sales_order_item",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "Sales Order Item",
+ "no_copy": 1,
+ "print_hide": 1,
+ "read_only": 1,
+ "search_index": 1
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2023-10-03 21:11:50.547261",
+ "modified": "2023-10-19 10:50:58.071735",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",
@@ -1078,4 +1100,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
-}
+}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index a2cae7f..35f6230 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -438,31 +438,37 @@
item_code.append(item.item_code)
def validate_fg_completed_qty(self):
- item_wise_qty = {}
- if self.purpose == "Manufacture" and self.work_order:
- for d in self.items:
- if d.is_finished_item:
- if self.process_loss_qty:
- d.qty = self.fg_completed_qty - self.process_loss_qty
+ if self.purpose != "Manufacture":
+ return
- item_wise_qty.setdefault(d.item_code, []).append(d.qty)
+ fg_qty = defaultdict(float)
+ for d in self.items:
+ if d.is_finished_item:
+ fg_qty[d.item_code] += flt(d.qty)
+
+ if not fg_qty:
+ return
precision = frappe.get_precision("Stock Entry Detail", "qty")
- for item_code, qty_list in item_wise_qty.items():
- total = flt(sum(qty_list), precision)
+ fg_item = list(fg_qty.keys())[0]
+ fg_item_qty = flt(fg_qty[fg_item], precision)
+ fg_completed_qty = flt(self.fg_completed_qty, precision)
- if (self.fg_completed_qty - total) > 0 and not self.process_loss_qty:
- self.process_loss_qty = flt(self.fg_completed_qty - total, precision)
- self.process_loss_percentage = flt(self.process_loss_qty * 100 / self.fg_completed_qty)
+ for d in self.items:
+ if not fg_qty.get(d.item_code):
+ continue
- if self.process_loss_qty:
- total += flt(self.process_loss_qty, precision)
+ if (fg_completed_qty - fg_item_qty) > 0:
+ self.process_loss_qty = fg_completed_qty - fg_item_qty
- if self.fg_completed_qty != total:
+ if not self.process_loss_qty:
+ continue
+
+ if fg_completed_qty != (flt(fg_item_qty) + flt(self.process_loss_qty, precision)):
frappe.throw(
- _("The finished product {0} quantity {1} and For Quantity {2} cannot be different").format(
- frappe.bold(item_code), frappe.bold(total), frappe.bold(self.fg_completed_qty)
- )
+ _(
+ "Since there is a process loss of {0} units for the finished good {1}, you should reduce the quantity by {0} units for the finished good {1} in the Items Table."
+ ).format(frappe.bold(self.process_loss_qty), frappe.bold(d.item_code))
)
def validate_difference_account(self):
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
index c5df319..f60a037 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
@@ -92,7 +92,7 @@
'qty', 'read_only', frm.doc.has_serial_no
);
- frm.set_df_property('sb_entries', 'allow_on_submit', frm.doc.against_pick_list ? 0 : 1);
+ frm.set_df_property('sb_entries', 'allow_on_submit', frm.doc.from_voucher_type == "Pick List" ? 0 : 1);
},
hide_rate_related_fields(frm) {
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
index 5c3018f..76cedd4 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
@@ -17,8 +17,9 @@
"voucher_no",
"voucher_detail_no",
"column_break_7dxj",
- "against_pick_list",
- "against_pick_list_item",
+ "from_voucher_type",
+ "from_voucher_no",
+ "from_voucher_detail_no",
"section_break_xt4m",
"stock_uom",
"column_break_grdt",
@@ -158,7 +159,7 @@
"oldfieldname": "actual_qty",
"oldfieldtype": "Currency",
"print_width": "150px",
- "read_only_depends_on": "eval: ((doc.reservation_based_on == \"Serial and Batch\") || (doc.against_pick_list) || (doc.delivered_qty > 0))",
+ "read_only_depends_on": "eval: ((doc.reservation_based_on == \"Serial and Batch\") || (doc.from_voucher_type == \"Pick List\") || (doc.delivered_qty > 0))",
"width": "150px"
},
{
@@ -268,27 +269,7 @@
"label": "Reservation Based On",
"no_copy": 1,
"options": "Qty\nSerial and Batch",
- "read_only_depends_on": "eval: (doc.delivered_qty > 0 || doc.against_pick_list)"
- },
- {
- "fieldname": "against_pick_list",
- "fieldtype": "Link",
- "label": "Against Pick List",
- "no_copy": 1,
- "options": "Pick List",
- "print_hide": 1,
- "read_only": 1,
- "report_hide": 1,
- "search_index": 1
- },
- {
- "fieldname": "against_pick_list_item",
- "fieldtype": "Data",
- "label": "Against Pick List Item",
- "no_copy": 1,
- "print_hide": 1,
- "read_only": 1,
- "report_hide": 1
+ "read_only_depends_on": "eval: (doc.delivered_qty > 0 || doc.from_voucher_type == \"Pick List\")"
},
{
"fieldname": "column_break_7dxj",
@@ -297,6 +278,36 @@
{
"fieldname": "column_break_grdt",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "from_voucher_type",
+ "fieldtype": "Select",
+ "label": "From Voucher Type",
+ "no_copy": 1,
+ "options": "\nPick List\nPurchase Receipt",
+ "print_hide": 1,
+ "read_only": 1,
+ "report_hide": 1
+ },
+ {
+ "fieldname": "from_voucher_detail_no",
+ "fieldtype": "Data",
+ "label": "From Voucher Detail No",
+ "no_copy": 1,
+ "print_hide": 1,
+ "read_only": 1,
+ "report_hide": 1
+ },
+ {
+ "fieldname": "from_voucher_no",
+ "fieldtype": "Dynamic Link",
+ "label": "From Voucher No",
+ "no_copy": 1,
+ "options": "from_voucher_type",
+ "print_hide": 1,
+ "read_only": 1,
+ "report_hide": 1,
+ "search_index": 1
}
],
"hide_toolbar": 1,
@@ -304,7 +315,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2023-08-08 17:15:13.317706",
+ "modified": "2023-10-19 16:41:16.545416",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reservation Entry",
diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
index 936be3f..81e9dfa 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -1,6 +1,8 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
+from typing import Literal
+
import frappe
from frappe import _
from frappe.model.document import Document
@@ -113,7 +115,7 @@
"""Auto pick Serial and Batch Nos to reserve when `Reservation Based On` is `Serial and Batch`."""
if (
- not self.against_pick_list
+ not self.from_voucher_type
and (self.get("_action") == "submit")
and (self.has_serial_no or self.has_batch_no)
and cint(frappe.db.get_single_value("Stock Settings", "auto_reserve_serial_and_batch"))
@@ -316,21 +318,24 @@
) -> None:
"""Updates total reserved qty in the Pick List."""
- if self.against_pick_list and self.against_pick_list_item:
+ if (
+ self.from_voucher_type == "Pick List" and self.from_voucher_no and self.from_voucher_detail_no
+ ):
sre = frappe.qb.DocType("Stock Reservation Entry")
reserved_qty = (
frappe.qb.from_(sre)
.select(Sum(sre.reserved_qty))
.where(
(sre.docstatus == 1)
- & (sre.against_pick_list == self.against_pick_list)
- & (sre.against_pick_list_item == self.against_pick_list_item)
+ & (sre.from_voucher_type == "Pick List")
+ & (sre.from_voucher_no == self.from_voucher_no)
+ & (sre.from_voucher_detail_no == self.from_voucher_detail_no)
)
).run(as_list=True)[0][0] or 0
frappe.db.set_value(
"Pick List Item",
- self.against_pick_list_item,
+ self.from_voucher_detail_no,
reserved_qty_field,
reserved_qty,
update_modified=update_modified,
@@ -365,7 +370,7 @@
).format(self.status, self.doctype)
frappe.throw(msg)
- if self.against_pick_list:
+ if self.from_voucher_type == "Pick List":
msg = _(
"Stock Reservation Entry created against a Pick List cannot be updated. If you need to make changes, we recommend canceling the existing entry and creating a new one."
)
@@ -761,25 +766,27 @@
def create_stock_reservation_entries_for_so_items(
- so: object,
+ sales_order: object,
items_details: list[dict] = None,
- against_pick_list: bool = False,
+ from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None,
notify=True,
) -> None:
"""Creates Stock Reservation Entries for Sales Order Items."""
from erpnext.selling.doctype.sales_order.sales_order import get_unreserved_qty
- if not against_pick_list and (
- so.get("_action") == "submit"
- and so.set_warehouse
- and cint(frappe.get_cached_value("Warehouse", so.set_warehouse, "is_group"))
+ if not from_voucher_type and (
+ sales_order.get("_action") == "submit"
+ and sales_order.set_warehouse
+ and cint(frappe.get_cached_value("Warehouse", sales_order.set_warehouse, "is_group"))
):
return frappe.msgprint(
- _("Stock cannot be reserved in the group warehouse {0}.").format(frappe.bold(so.set_warehouse))
+ _("Stock cannot be reserved in the group warehouse {0}.").format(
+ frappe.bold(sales_order.set_warehouse)
+ )
)
- validate_stock_reservation_settings(so)
+ validate_stock_reservation_settings(sales_order)
allow_partial_reservation = frappe.db.get_single_value(
"Stock Settings", "allow_partial_reservation"
@@ -788,38 +795,36 @@
items = []
if items_details:
for item in items_details:
- so_item = frappe.get_doc(
- "Sales Order Item", item.get("sales_order_item") if against_pick_list else item.get("name")
- )
- so_item.reserve_stock = 1
+ so_item = frappe.get_doc("Sales Order Item", item.get("name"))
so_item.warehouse = item.get("warehouse")
so_item.qty_to_reserve = (
- item.get("picked_qty") - item.get("stock_reserved_qty", 0)
- if against_pick_list
- else (flt(item.get("qty_to_reserve")) * flt(so_item.conversion_factor, 1))
+ flt(item.get("qty_to_reserve"))
+ if from_voucher_type in ["Pick List", "Purchase Receipt"]
+ else (
+ flt(item.get("qty_to_reserve"))
+ * (flt(item.get("conversion_factor")) or flt(so_item.conversion_factor) or 1)
+ )
)
-
- if against_pick_list:
- so_item.pick_list = item.get("parent")
- so_item.pick_list_item = item.get("name")
- so_item.pick_list_sbb = item.get("serial_and_batch_bundle")
+ so_item.from_voucher_no = item.get("from_voucher_no")
+ so_item.from_voucher_detail_no = item.get("from_voucher_detail_no")
+ so_item.serial_and_batch_bundle = item.get("serial_and_batch_bundle")
items.append(so_item)
sre_count = 0
- reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", sales_order.name)
- for item in items if items_details else so.get("items"):
+ for item in items if items_details else sales_order.get("items"):
# Skip if `Reserved Stock` is not checked for the item.
if not item.get("reserve_stock"):
continue
# Stock should be reserved from the Pick List if has Picked Qty.
- if not against_pick_list and flt(item.picked_qty) > 0:
+ if not from_voucher_type == "Pick List" and flt(item.picked_qty) > 0:
frappe.throw(
- _(
- "Row #{0}: Item {1} has been picked, please create a Stock Reservation from the Pick List."
- ).format(item.idx, frappe.bold(item.item_code))
+ _("Row #{0}: Item {1} has been picked, please reserve stock from the Pick List.").format(
+ item.idx, frappe.bold(item.item_code)
+ )
)
is_stock_item, has_serial_no, has_batch_no = frappe.get_cached_value(
@@ -828,13 +833,15 @@
# Skip if Non-Stock Item.
if not is_stock_item:
- frappe.msgprint(
- _("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format(
- item.idx, frappe.bold(item.item_code)
- ),
- title=_("Stock Reservation"),
- indicator="yellow",
- )
+ if not from_voucher_type:
+ frappe.msgprint(
+ _("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format(
+ item.idx, frappe.bold(item.item_code)
+ ),
+ title=_("Stock Reservation"),
+ indicator="yellow",
+ )
+
item.db_set("reserve_stock", 0)
continue
@@ -853,13 +860,15 @@
# Stock is already reserved for the item, notify the user and skip the item.
if unreserved_qty <= 0:
- frappe.msgprint(
- _("Row #{0}: Stock is already reserved for the Item {1}.").format(
- item.idx, frappe.bold(item.item_code)
- ),
- title=_("Stock Reservation"),
- indicator="yellow",
- )
+ if not from_voucher_type:
+ frappe.msgprint(
+ _("Row #{0}: Stock is already reserved for the Item {1}.").format(
+ item.idx, frappe.bold(item.item_code)
+ ),
+ title=_("Stock Reservation"),
+ indicator="yellow",
+ )
+
continue
available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse)
@@ -867,7 +876,7 @@
# No stock available to reserve, notify the user and skip the item.
if available_qty_to_reserve <= 0:
frappe.msgprint(
- _("Row #{0}: No available stock to reserve for the Item {1} in Warehouse {2}.").format(
+ _("Row #{0}: Stock not available to reserve for the Item {1} in Warehouse {2}.").format(
item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse)
),
title=_("Stock Reservation"),
@@ -893,7 +902,9 @@
# Partial Reservation
if qty_to_be_reserved < unreserved_qty:
- if not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")):
+ if not from_voucher_type and (
+ not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve"))
+ ):
msg = _("Row #{0}: Only {1} available to reserve for the Item {2}").format(
item.idx,
frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom),
@@ -915,33 +926,42 @@
sre.warehouse = item.warehouse
sre.has_serial_no = has_serial_no
sre.has_batch_no = has_batch_no
- sre.voucher_type = so.doctype
- sre.voucher_no = so.name
+ sre.voucher_type = sales_order.doctype
+ sre.voucher_no = sales_order.name
sre.voucher_detail_no = item.name
sre.available_qty = available_qty_to_reserve
sre.voucher_qty = item.stock_qty
sre.reserved_qty = qty_to_be_reserved
- sre.company = so.company
+ sre.company = sales_order.company
sre.stock_uom = item.stock_uom
- sre.project = so.project
+ sre.project = sales_order.project
- if against_pick_list:
- sre.against_pick_list = item.pick_list
- sre.against_pick_list_item = item.pick_list_item
+ if from_voucher_type:
+ sre.from_voucher_type = from_voucher_type
+ sre.from_voucher_no = item.from_voucher_no
+ sre.from_voucher_detail_no = item.from_voucher_detail_no
- if item.pick_list_sbb:
- sbb = frappe.get_doc("Serial and Batch Bundle", item.pick_list_sbb)
- sre.reservation_based_on = "Serial and Batch"
- for entry in sbb.entries:
- sre.append(
- "sb_entries",
- {
- "serial_no": entry.serial_no,
- "batch_no": entry.batch_no,
- "qty": 1 if has_serial_no else abs(entry.qty),
- "warehouse": entry.warehouse,
- },
- )
+ if item.get("serial_and_batch_bundle"):
+ sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
+ sre.reservation_based_on = "Serial and Batch"
+
+ index, picked_qty = 0, 0
+ while index < len(sbb.entries) and picked_qty < qty_to_be_reserved:
+ entry = sbb.entries[index]
+ qty = 1 if has_serial_no else min(abs(entry.qty), qty_to_be_reserved - picked_qty)
+
+ sre.append(
+ "sb_entries",
+ {
+ "serial_no": entry.serial_no,
+ "batch_no": entry.batch_no,
+ "qty": qty,
+ "warehouse": entry.warehouse,
+ },
+ )
+
+ index += 1
+ picked_qty += qty
sre.save()
sre.submit()
@@ -956,29 +976,37 @@
voucher_type: str = None,
voucher_no: str = None,
voucher_detail_no: str = None,
- against_pick_list: str = None,
+ from_voucher_type: Literal["Pick List", "Purchase Receipt"] = None,
+ from_voucher_no: str = None,
+ from_voucher_detail_no: str = None,
sre_list: list[dict] = None,
notify: bool = True,
) -> None:
"""Cancel Stock Reservation Entries."""
- if not sre_list and against_pick_list:
- sre = frappe.qb.DocType("Stock Reservation Entry")
- sre_list = (
- frappe.qb.from_(sre)
- .select(sre.name)
- .where(
- (sre.docstatus == 1)
- & (sre.against_pick_list == against_pick_list)
- & (sre.status.notin(["Delivered", "Cancelled"]))
+ if not sre_list:
+ if voucher_type and voucher_no:
+ sre_list = get_stock_reservation_entries_for_voucher(
+ voucher_type, voucher_no, voucher_detail_no, fields=["name"]
)
- .orderby(sre.creation)
- ).run(as_dict=True)
+ elif from_voucher_type and from_voucher_no:
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .select(sre.name)
+ .where(
+ (sre.docstatus == 1)
+ & (sre.from_voucher_type == from_voucher_type)
+ & (sre.from_voucher_no == from_voucher_no)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .orderby(sre.creation)
+ )
- elif not sre_list and (voucher_type and voucher_no):
- sre_list = get_stock_reservation_entries_for_voucher(
- voucher_type, voucher_no, voucher_detail_no, fields=["name"]
- )
+ if from_voucher_detail_no:
+ query = query.where(sre.from_voucher_detail_no == from_voucher_detail_no)
+
+ sre_list = query.run(as_dict=True)
if sre_list:
for sre in sre_list:
diff --git a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
index 1168a4e..f4c74a8 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
+++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
@@ -5,6 +5,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils import today
from erpnext.selling.doctype.sales_order.sales_order import create_pick_list, make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
@@ -28,10 +29,6 @@
items={self.sr_item.name: self.sr_item}, warehouse=self.warehouse, qty=100
)
- def tearDown(self) -> None:
- cancel_all_stock_reservation_entries()
- return super().tearDown()
-
@change_settings("Stock Settings", {"allow_negative_stock": 0})
def test_validate_stock_reservation_settings(self) -> None:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
@@ -555,8 +552,9 @@
(sre.voucher_type == "Sales Order")
& (sre.voucher_no == location.sales_order)
& (sre.voucher_detail_no == location.sales_order_item)
- & (sre.against_pick_list == pl.name)
- & (sre.against_pick_list_item == location.name)
+ & (sre.from_voucher_type == "Pick List")
+ & (sre.from_voucher_no == pl.name)
+ & (sre.from_voucher_detail_no == location.name)
)
).run(as_dict=True)
reserved_sb_details: set[tuple] = {
@@ -567,6 +565,90 @@
# Test - 3: Reserved Serial/Batch Nos should be equal to Picked Serial/Batch Nos.
self.assertSetEqual(picked_sb_details, reserved_sb_details)
+ @change_settings(
+ "Stock Settings",
+ {
+ "allow_negative_stock": 0,
+ "enable_stock_reservation": 1,
+ "auto_reserve_serial_and_batch": 1,
+ "pick_serial_and_batch_based_on": "FIFO",
+ "auto_reserve_stock_for_sales_order_on_purchase": 1,
+ },
+ )
+ def test_stock_reservation_from_purchase_receipt(self):
+ from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+ from erpnext.selling.doctype.sales_order.sales_order import make_material_request
+ from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+
+ items_details = create_items()
+ create_material_receipt(items_details, self.warehouse, qty=10)
+
+ item_list = []
+ for item_code, properties in items_details.items():
+ item_list.append(
+ {
+ "item_code": item_code,
+ "warehouse": self.warehouse,
+ "qty": randint(11, 100),
+ "uom": properties.stock_uom,
+ "rate": randint(10, 400),
+ }
+ )
+
+ so = make_sales_order(
+ item_list=item_list,
+ warehouse=self.warehouse,
+ )
+
+ mr = make_material_request(so.name)
+ mr.schedule_date = today()
+ mr.save().submit()
+
+ po = make_purchase_order(mr.name)
+ po.supplier = "_Test Supplier"
+ po.save().submit()
+
+ pr = make_purchase_receipt(po.name)
+ pr.save().submit()
+
+ for item in pr.items:
+ sre, status, reserved_qty = frappe.db.get_value(
+ "Stock Reservation Entry",
+ {
+ "from_voucher_type": "Purchase Receipt",
+ "from_voucher_no": pr.name,
+ "from_voucher_detail_no": item.name,
+ },
+ ["name", "status", "reserved_qty"],
+ )
+
+ # Test - 1: SRE status should be `Reserved`.
+ self.assertEqual(status, "Reserved")
+
+ # Test - 2: SRE Reserved Qty should be equal to PR Item Qty.
+ self.assertEqual(reserved_qty, item.qty)
+
+ if item.serial_and_batch_bundle:
+ sb_details = frappe.db.get_all(
+ "Serial and Batch Entry",
+ filters={"parent": item.serial_and_batch_bundle},
+ fields=["serial_no", "batch_no", "qty"],
+ as_list=True,
+ )
+ reserved_sb_details = frappe.db.get_all(
+ "Serial and Batch Entry",
+ filters={"parent": sre},
+ fields=["serial_no", "batch_no", "qty"],
+ as_list=True,
+ )
+
+ # Test - 3: Reserved Serial/Batch Nos should be equal to PR Item Serial/Batch Nos.
+ self.assertEqual(set(sb_details), set(reserved_sb_details))
+
+ def tearDown(self) -> None:
+ cancel_all_stock_reservation_entries()
+ return super().tearDown()
+
def create_items() -> dict:
items_properties = [
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index 2052daa..1228290 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -38,8 +38,8 @@
"stock_reservation_tab",
"enable_stock_reservation",
"column_break_rx3e",
- "auto_reserve_stock_for_sales_order",
"allow_partial_reservation",
+ "auto_reserve_stock_for_sales_order_on_purchase",
"serial_and_batch_reservation_section",
"auto_reserve_serial_and_batch",
"serial_and_batch_item_settings_tab",
@@ -65,8 +65,7 @@
"stock_frozen_upto_days",
"column_break_26",
"role_allowed_to_create_edit_back_dated_transactions",
- "stock_auth_role",
- "section_break_plhx"
+ "stock_auth_role"
],
"fields": [
{
@@ -356,7 +355,7 @@
{
"default": "1",
"depends_on": "eval: doc.enable_stock_reservation",
- "description": "If enabled, <b>Partial Stock Reservation Entries</b> can be created. For example, If you have a Sales Order of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ",
+ "description": "Partial stock can be reserved. For example, If you have a Sales Order of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ",
"fieldname": "allow_partial_reservation",
"fieldtype": "Check",
"label": "Allow Partial Reservation"
@@ -383,7 +382,7 @@
{
"default": "1",
"depends_on": "eval: doc.enable_stock_reservation",
- "description": "If enabled, Serial and Batch Nos will be auto-reserved based on <b>Pick Serial / Batch Based On</b>",
+ "description": "Serial and Batch Nos will be auto-reserved based on <b>Pick Serial / Batch Based On</b>",
"fieldname": "auto_reserve_serial_and_batch",
"fieldtype": "Check",
"label": "Auto Reserve Serial and Batch Nos"
@@ -394,14 +393,6 @@
"label": "Serial and Batch Reservation"
},
{
- "default": "0",
- "depends_on": "eval: doc.enable_stock_reservation",
- "description": "If enabled, <b>Stock Reservation Entries</b> will be created on submission of <b>Sales Order</b>",
- "fieldname": "auto_reserve_stock_for_sales_order",
- "fieldtype": "Check",
- "label": "Auto Reserve Stock for Sales Order"
- },
- {
"fieldname": "conversion_factor_section",
"fieldtype": "Section Break",
"label": "Stock UOM Quantity"
@@ -421,6 +412,14 @@
"fieldname": "allow_to_edit_stock_uom_qty_for_purchase",
"fieldtype": "Check",
"label": "Allow to Edit Stock UOM Qty for Purchase Documents"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.enable_stock_reservation",
+ "description": "Stock will be reserved on submission of <b>Purchase Receipt</b> created against Material Receipt for Sales Order.",
+ "fieldname": "auto_reserve_stock_for_sales_order_on_purchase",
+ "fieldtype": "Check",
+ "label": "Auto Reserve Stock for Sales Order on Purchase"
}
],
"icon": "icon-cog",
@@ -428,7 +427,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2023-10-01 14:22:36.136111",
+ "modified": "2023-10-18 12:35:30.068799",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
@@ -453,4 +452,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
-}
+}
\ No newline at end of file
diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.js b/erpnext/stock/report/reserved_stock/reserved_stock.js
index 2199f52..6872741 100644
--- a/erpnext/stock/report/reserved_stock/reserved_stock.js
+++ b/erpnext/stock/report/reserved_stock/reserved_stock.js
@@ -91,16 +91,30 @@
},
},
{
- fieldname: "against_pick_list",
- label: __("Against Pick List"),
+ fieldname: "from_voucher_type",
+ label: __("From Voucher Type"),
fieldtype: "Link",
- options: "Pick List",
+ options: "DocType",
+ get_query: () => ({
+ filters: {
+ name: ["in", ["Pick List", "Purchase Receipt"]],
+ }
+ }),
+ },
+ {
+ fieldname: "from_voucher_no",
+ label: __("From Voucher No"),
+ fieldtype: "Dynamic Link",
+ options: "from_voucher_type",
get_query: () => ({
filters: {
docstatus: 1,
company: frappe.query_report.get_filter_value("company"),
},
}),
+ get_options: function () {
+ return frappe.query_report.get_filter_value("from_voucher_type");
+ },
},
{
fieldname: "reservation_based_on",
diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.py b/erpnext/stock/report/reserved_stock/reserved_stock.py
index d93ee1c..21ce203 100644
--- a/erpnext/stock/report/reserved_stock/reserved_stock.py
+++ b/erpnext/stock/report/reserved_stock/reserved_stock.py
@@ -44,7 +44,8 @@
(sre.available_qty - sre.reserved_qty).as_("available_qty"),
sre.voucher_type,
sre.voucher_no,
- sre.against_pick_list,
+ sre.from_voucher_type,
+ sre.from_voucher_no,
sre.name.as_("stock_reservation_entry"),
sre.status,
sre.project,
@@ -65,7 +66,8 @@
"warehouse",
"voucher_type",
"voucher_no",
- "against_pick_list",
+ "from_voucher_type",
+ "from_voucher_no",
"reservation_based_on",
"status",
"project",
@@ -142,7 +144,6 @@
"fieldname": "voucher_type",
"label": _("Voucher Type"),
"fieldtype": "Data",
- "options": "Warehouse",
"width": 110,
},
{
@@ -153,11 +154,17 @@
"width": 120,
},
{
- "fieldname": "against_pick_list",
- "label": _("Against Pick List"),
- "fieldtype": "Link",
- "options": "Pick List",
- "width": 130,
+ "fieldname": "from_voucher_type",
+ "label": _("From Voucher Type"),
+ "fieldtype": "Data",
+ "width": 110,
+ },
+ {
+ "fieldname": "from_voucher_no",
+ "label": _("From Voucher No"),
+ "fieldtype": "Dynamic Link",
+ "options": "from_voucher_type",
+ "width": 120,
},
{
"fieldname": "stock_reservation_entry",