diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
index e548b4c7e9a8..b3cc1cbb1beb 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
@@ -13,10 +13,11 @@ frappe.ui.form.on("Bank Transaction", {
});
},
refresh(frm) {
- frm.add_custom_button(__('Unreconcile Transaction'), () => {
- frm.call('remove_payment_entries')
- .then( () => frm.refresh() );
- });
+ if (!frm.is_dirty() && frm.doc.payment_entries.length > 0) {
+ frm.add_custom_button(__("Unreconcile Transaction"), () => {
+ frm.call("remove_payment_entries").then(() => frm.refresh());
+ });
+ }
},
bank_account: function (frm) {
set_bank_statement_filter(frm);
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index f131be2dfeda..9a0adf5815d8 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -535,15 +535,21 @@ frappe.ui.form.on('Payment Entry', {
},
source_exchange_rate: function(frm) {
+ let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.paid_amount) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
// target exchange rate should always be same as source if both account currencies is same
if(frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
+ } else if (company_currency == frm.doc.paid_to_account_currency) {
+ frm.set_value("received_amount", frm.doc.base_paid_amount);
+ frm.set_value("base_received_amount", frm.doc.base_paid_amount);
}
- frm.events.set_unallocated_amount(frm);
+ // set_unallocated_amount is called by below method,
+ // no need trigger separately
+ frm.events.set_total_allocated_amount(frm);
}
// Make read only if Accounts Settings doesn't allow stale rates
@@ -552,6 +558,7 @@ frappe.ui.form.on('Payment Entry', {
target_exchange_rate: function(frm) {
frm.set_paid_amount_based_on_received_amount = true;
+ let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.received_amount) {
frm.set_value("base_received_amount",
@@ -561,9 +568,14 @@ frappe.ui.form.on('Payment Entry', {
(frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency)) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
+ } else if (company_currency == frm.doc.paid_from_account_currency) {
+ frm.set_value("paid_amount", frm.doc.base_received_amount);
+ frm.set_value("base_paid_amount", frm.doc.base_received_amount);
}
- frm.events.set_unallocated_amount(frm);
+ // set_unallocated_amount is called by below method,
+ // no need trigger separately
+ frm.events.set_total_allocated_amount(frm);
}
frm.set_paid_amount_based_on_received_amount = false;
@@ -879,12 +891,18 @@ frappe.ui.form.on('Payment Entry', {
},
set_total_allocated_amount: function(frm) {
+ let exchange_rate = 1;
+ if (frm.doc.payment_type == "Receive") {
+ exchange_rate = frm.doc.source_exchange_rate;
+ } else if (frm.doc.payment_type == "Pay") {
+ exchange_rate = frm.doc.target_exchange_rate;
+ }
var total_allocated_amount = 0.0;
var base_total_allocated_amount = 0.0;
$.each(frm.doc.references || [], function(i, row) {
if (row.allocated_amount) {
total_allocated_amount += flt(row.allocated_amount);
- base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(row.exchange_rate),
+ base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(exchange_rate),
precision("base_paid_amount"));
}
});
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 9ed3d32c57fc..2c2efc06455d 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -856,6 +856,11 @@ def calculate_base_allocated_amount_for_reference(self, d) -> float:
flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
)
+ # on rare case, when `exchange_rate` is unset, gain/loss amount is incorrectly calculated
+ # for base currency transactions
+ if d.exchange_rate is None:
+ d.exchange_rate = 1
+
allocated_amount_in_pe_exchange_rate = flt(
flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount")
)
@@ -2300,7 +2305,7 @@ def set_paid_amount_and_received_amount(
if bank_amount:
received_amount = bank_amount
else:
- if company_currency != bank.account_currency:
+ if bank and company_currency != bank.account_currency:
received_amount = paid_amount / doc.get("conversion_rate", 1)
else:
received_amount = paid_amount * doc.get("conversion_rate", 1)
@@ -2309,7 +2314,7 @@ def set_paid_amount_and_received_amount(
if bank_amount:
paid_amount = bank_amount
else:
- if company_currency != bank.account_currency:
+ if bank and company_currency != bank.account_currency:
paid_amount = received_amount / doc.get("conversion_rate", 1)
else:
# if party account currency and bank currency is different then populate paid amount as well
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index 7ef5278d2524..96ae0c30f954 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -116,7 +116,7 @@ def get_jv_entries(self):
"Journal Entry" as reference_type, t1.name as reference_name,
t1.posting_date, t1.remark as remarks, t2.name as reference_row,
{dr_or_cr} as amount, t2.is_advance, t2.exchange_rate,
- t2.account_currency as currency
+ t2.account_currency as currency, t2.cost_center as cost_center
from
`tabJournal Entry` t1, `tabJournal Entry Account` t2
where
@@ -209,6 +209,7 @@ def get_dr_or_cr_notes(self):
"amount": -(inv.outstanding_in_account_currency),
"posting_date": inv.posting_date,
"currency": inv.currency,
+ "cost_center": inv.cost_center,
}
)
)
@@ -357,6 +358,7 @@ def get_allocated_entry(self, pay, inv, allocated_amount):
"allocated_amount": allocated_amount,
"difference_amount": pay.get("difference_amount"),
"currency": inv.get("currency"),
+ "cost_center": pay.get("cost_center"),
}
)
@@ -431,6 +433,7 @@ def get_payment_details(self, row, dr_or_cr):
"allocated_amount": flt(row.get("allocated_amount")),
"difference_amount": flt(row.get("difference_amount")),
"difference_account": row.get("difference_account"),
+ "cost_center": row.get("cost_center"),
}
)
@@ -603,7 +606,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
inv.dr_or_cr: abs(inv.allocated_amount),
"reference_type": inv.against_voucher_type,
"reference_name": inv.against_voucher,
- "cost_center": erpnext.get_default_cost_center(company),
+ "cost_center": inv.cost_center or erpnext.get_default_cost_center(company),
"exchange_rate": inv.exchange_rate,
"user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} against {inv.against_voucher}",
},
@@ -618,7 +621,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
),
"reference_type": inv.voucher_type,
"reference_name": inv.voucher_no,
- "cost_center": erpnext.get_default_cost_center(company),
+ "cost_center": inv.cost_center or erpnext.get_default_cost_center(company),
"exchange_rate": inv.exchange_rate,
"user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} from {inv.voucher_no}",
},
@@ -644,6 +647,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
create_gain_loss_journal(
company,
+ today(),
inv.party_type,
inv.party,
inv.account,
@@ -657,4 +661,5 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
inv.against_voucher_type,
inv.against_voucher,
None,
+ inv.cost_center,
)
diff --git a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json
index 0f7e47acfee4..ec718aa70d31 100644
--- a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json
+++ b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json
@@ -22,7 +22,8 @@
"column_break_7",
"difference_account",
"exchange_rate",
- "currency"
+ "currency",
+ "cost_center"
],
"fields": [
{
@@ -144,11 +145,17 @@
"fieldtype": "Float",
"label": "Exchange Rate",
"read_only": 1
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
}
],
"istable": 1,
"links": [],
- "modified": "2022-12-24 21:01:14.882747",
+ "modified": "2023-09-03 07:52:33.684217",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Allocation",
diff --git a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
index d300ea97abc4..17f3900880c6 100644
--- a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
+++ b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json
@@ -16,7 +16,8 @@
"sec_break1",
"remark",
"currency",
- "exchange_rate"
+ "exchange_rate",
+ "cost_center"
],
"fields": [
{
@@ -98,11 +99,17 @@
"fieldtype": "Float",
"hidden": 1,
"label": "Exchange Rate"
+ },
+ {
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "label": "Cost Center",
+ "options": "Cost Center"
}
],
"istable": 1,
"links": [],
- "modified": "2022-11-08 18:18:36.268760",
+ "modified": "2023-09-03 07:43:29.965353",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Reconciliation Payment",
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
index 49472484ef44..af1c06643a1c 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
@@ -126,7 +126,7 @@ def check_if_previous_year_closed(self):
def make_gl_entries(self, get_opening_entries=False):
gl_entries = self.get_gl_entries()
closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries)
- if len(gl_entries) > 5000:
+ if len(gl_entries + closing_entries) > 3000:
frappe.enqueue(
process_gl_entries,
gl_entries=gl_entries,
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index 8bd7b5a3fee5..b99bb83c5b22 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -409,7 +409,7 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company)
- if include_advance and party_type in ["Customer", "Supplier"]:
+ if include_advance and party_type in ["Customer", "Supplier", "Student"]:
advance_account = get_party_advance_account(party_type, party, company)
if advance_account:
return [account, advance_account]
diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js
index e1a30a4b77e0..27a85701edda 100644
--- a/erpnext/accounts/report/accounts_payable/accounts_payable.js
+++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js
@@ -37,24 +37,6 @@ frappe.query_reports["Accounts Payable"] = {
}
}
},
- {
- "fieldname": "supplier",
- "label": __("Supplier"),
- "fieldtype": "Link",
- "options": "Supplier",
- on_change: () => {
- var supplier = frappe.query_report.get_filter_value('supplier');
- if (supplier) {
- frappe.db.get_value('Supplier', supplier, "tax_id", function(value) {
- frappe.query_report.set_filter_value('tax_id', value["tax_id"]);
- });
- } else {
- frappe.query_report.set_filter_value('tax_id', "");
- }
-
- frappe.query_report.refresh();
- }
- },
{
"fieldname": "party_account",
"label": __("Payable Account"),
@@ -112,11 +94,38 @@ frappe.query_reports["Accounts Payable"] = {
"fieldtype": "Link",
"options": "Payment Terms Template"
},
+ {
+ "fieldname": "party_type",
+ "label": __("Party Type"),
+ "fieldtype": "Link",
+ "options": "Party Type",
+ get_query: () => {
+ return {
+ filters: {
+ 'account_type': 'Payable'
+ }
+ };
+ },
+ on_change: () => {
+ frappe.query_report.set_filter_value('party', "");
+ let party_type = frappe.query_report.get_filter_value('party_type');
+ frappe.query_report.toggle_filter_display('supplier_group', frappe.query_report.get_filter_value('party_type') !== "Supplier");
+
+ }
+
+ },
+ {
+ "fieldname":"party",
+ "label": __("Party"),
+ "fieldtype": "Dynamic Link",
+ "options": "party_type",
+ },
{
"fieldname": "supplier_group",
"label": __("Supplier Group"),
"fieldtype": "Link",
- "options": "Supplier Group"
+ "options": "Supplier Group",
+ "hidden": 1
},
{
"fieldname": "group_by_party",
@@ -133,12 +142,6 @@ frappe.query_reports["Accounts Payable"] = {
"label": __("Show Remarks"),
"fieldtype": "Check",
},
- {
- "fieldname": "tax_id",
- "label": __("Tax Id"),
- "fieldtype": "Data",
- "hidden": 1
- },
{
"fieldname": "show_future_payments",
"label": __("Show Future Payments"),
diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py
new file mode 100644
index 000000000000..cb84cf4fc0a6
--- /dev/null
+++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py
@@ -0,0 +1,67 @@
+import unittest
+
+import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils import add_days, flt, getdate, today
+
+from erpnext import get_default_cost_center
+from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+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.report.accounts_payable.accounts_payable import execute
+from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+
+
+class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
+ def setUp(self):
+ self.create_company()
+ self.create_customer()
+ self.create_item()
+ self.create_supplier(currency="USD", supplier_name="Test Supplier2")
+ self.create_usd_payable_account()
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def test_accounts_receivable_with_supplier(self):
+ pi = self.create_purchase_invoice(do_not_submit=True)
+ pi.currency = "USD"
+ pi.conversion_rate = 80
+ pi.credit_to = self.creditors_usd
+ pi = pi.save().submit()
+
+ filters = {
+ "company": self.company,
+ "party_type": "Supplier",
+ "party": self.supplier,
+ "report_date": today(),
+ "range1": 30,
+ "range2": 60,
+ "range3": 90,
+ "range4": 120,
+ }
+
+ data = execute(filters)
+ self.assertEqual(data[1][0].get("outstanding"), 300)
+ self.assertEqual(data[1][0].get("currency"), "USD")
+
+ def create_purchase_invoice(self, do_not_submit=False):
+ frappe.set_user("Administrator")
+ pi = make_purchase_invoice(
+ item=self.item,
+ company=self.company,
+ supplier=self.supplier,
+ is_return=False,
+ update_stock=False,
+ posting_date=frappe.utils.datetime.date(2021, 5, 1),
+ do_not_save=1,
+ rate=300,
+ price_list_rate=300,
+ qty=1,
+ )
+
+ pi = pi.save()
+ if not do_not_submit:
+ pi = pi.submit()
+ return pi
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
index 0b4e577f6cb3..cb8ec876e9ee 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js
@@ -46,8 +46,7 @@ frappe.query_reports["Accounts Receivable"] = {
var customer = frappe.query_report.get_filter_value('customer');
var company = frappe.query_report.get_filter_value('company');
if (customer) {
- frappe.db.get_value('Customer', customer, ["tax_id", "customer_name", "payment_terms"], function(value) {
- frappe.query_report.set_filter_value('tax_id', value["tax_id"]);
+ frappe.db.get_value('Customer', customer, ["customer_name", "payment_terms"], function(value) {
frappe.query_report.set_filter_value('customer_name', value["customer_name"]);
frappe.query_report.set_filter_value('payment_terms', value["payment_terms"]);
});
@@ -59,7 +58,6 @@ frappe.query_reports["Accounts Receivable"] = {
}
}, "Customer");
} else {
- frappe.query_report.set_filter_value('tax_id', "");
frappe.query_report.set_filter_value('customer_name', "");
frappe.query_report.set_filter_value('credit_limit', "");
frappe.query_report.set_filter_value('payment_terms', "");
@@ -172,12 +170,6 @@ frappe.query_reports["Accounts Receivable"] = {
"label": __("Show Sales Person"),
"fieldtype": "Check",
},
- {
- "fieldname": "tax_id",
- "label": __("Tax Id"),
- "fieldtype": "Data",
- "hidden": 1
- },
{
"fieldname": "show_remarks",
"label": __("Show Remarks"),
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index 751063ad8e64..3700f00ee226 100755
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -211,11 +211,10 @@ def update_voucher_balance(self, ple):
return
# amount in "Party Currency", if its supplied. If not, amount in company currency
- for party_type in self.party_type:
- if self.filters.get(scrub(party_type)):
- amount = ple.amount_in_account_currency
- else:
- amount = ple.amount
+ if self.filters.get("party_type") and self.filters.get("party"):
+ amount = ple.amount_in_account_currency
+ else:
+ amount = ple.amount
amount_in_account_currency = ple.amount_in_account_currency
# update voucher
@@ -426,10 +425,9 @@ def set_party_details(self, row):
# customer / supplier name
party_details = self.get_party_details(row.party) or {}
row.update(party_details)
- for party_type in self.party_type:
- if self.filters.get(scrub(party_type)):
- row.currency = row.account_currency
- break
+
+ if self.filters.get("party_type") and self.filters.get("party"):
+ row.currency = row.account_currency
else:
row.currency = self.company_currency
@@ -765,6 +763,7 @@ def get_sales_invoices_or_customers_based_on_sales_person(self):
def prepare_conditions(self):
self.qb_selection_filter = []
self.or_filters = []
+
for party_type in self.party_type:
party_type_field = scrub(party_type)
self.or_filters.append(self.ple.party_type == party_type)
@@ -800,6 +799,12 @@ def add_common_filters(self, party_type_field):
if self.filters.get(party_type_field):
self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field))
+ if self.filters.get("party_type"):
+ self.qb_selection_filter.append(self.filters.party_type == self.ple.party_type)
+
+ if self.filters.get("party"):
+ self.qb_selection_filter.append(self.filters.party == self.ple.party)
+
if self.filters.party_account:
self.qb_selection_filter.append(self.ple.account == self.filters.party_account)
else:
diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.js b/erpnext/accounts/report/balance_sheet/balance_sheet.js
index c65b9e8ccc74..ecc13d7dc8aa 100644
--- a/erpnext/accounts/report/balance_sheet/balance_sheet.js
+++ b/erpnext/accounts/report/balance_sheet/balance_sheet.js
@@ -15,7 +15,6 @@ frappe.require("assets/erpnext/js/financial_statements.js", function () {
fieldtype: "Check",
default: 1,
});
- console.log(frappe.query_reports["Balance Sheet"]["filters"]);
frappe.query_reports["Balance Sheet"]["filters"].push({
fieldname: "include_default_book_entries",
diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
index 8b929bf472fa..5d3d4d74978c 100644
--- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
+++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
@@ -287,7 +287,7 @@ def get_conditions(filters):
conditions = ""
for opts in (
- ("company", " and company=%(company)s"),
+ ("company", " and `tabPurchase Invoice`.company=%(company)s"),
("supplier", " and `tabPurchase Invoice`.supplier = %(supplier)s"),
("item_code", " and `tabPurchase Invoice Item`.item_code = %(item_code)s"),
("from_date", " and `tabPurchase Invoice`.posting_date>=%(from_date)s"),
diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
index 1e7ac33c3256..ce22d7566c16 100644
--- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
+++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
@@ -332,7 +332,7 @@ def get_conditions(filters, additional_conditions=None):
conditions = ""
for opts in (
- ("company", " and company=%(company)s"),
+ ("company", " and `tabSales Invoice`.company=%(company)s"),
("customer", " and `tabSales Invoice`.customer = %(customer)s"),
("item_code", " and `tabSales Invoice Item`.item_code = %(item_code)s"),
("from_date", " and `tabSales Invoice`.posting_date>=%(from_date)s"),
diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py
index c7b7e2f7c128..162e5b57c7a3 100644
--- a/erpnext/accounts/report/purchase_register/purchase_register.py
+++ b/erpnext/accounts/report/purchase_register/purchase_register.py
@@ -10,8 +10,8 @@
from erpnext.accounts.party import get_party_account
from erpnext.accounts.report.utils import (
+ apply_common_conditions,
get_advance_taxes_and_charges,
- get_conditions,
get_journal_entries,
get_opening_row,
get_party_details,
@@ -378,11 +378,8 @@ def get_account_columns(invoice_list, include_payments):
def get_invoices(filters, additional_query_columns):
pi = frappe.qb.DocType("Purchase Invoice")
- invoice_item = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(pi)
- .inner_join(invoice_item)
- .on(pi.name == invoice_item.parent)
.select(
ConstantColumn("Purchase Invoice").as_("doctype"),
pi.name,
@@ -396,29 +393,46 @@ def get_invoices(filters, additional_query_columns):
pi.remarks,
pi.base_net_total,
pi.base_grand_total,
+ pi.base_rounded_total,
pi.outstanding_amount,
pi.mode_of_payment,
)
.where((pi.docstatus == 1))
.orderby(pi.posting_date, pi.name, order=Order.desc)
)
+
if additional_query_columns:
for col in additional_query_columns:
query = query.select(col)
+
if filters.get("supplier"):
query = query.where(pi.supplier == filters.supplier)
- query = get_conditions(
+
+ query = get_conditions(filters, query, "Purchase Invoice")
+
+ query = apply_common_conditions(
filters, query, doctype="Purchase Invoice", child_doctype="Purchase Invoice Item"
)
+
if filters.get("include_payments"):
party_account = get_party_account(
"Supplier", filters.get("supplier"), filters.get("company"), include_advance=True
)
query = query.where(pi.credit_to.isin(party_account))
+
invoices = query.run(as_dict=True)
return invoices
+def get_conditions(filters, query, doctype):
+ parent_doc = frappe.qb.DocType(doctype)
+
+ if filters.get("mode_of_payment"):
+ query = query.where(parent_doc.mode_of_payment == filters.mode_of_payment)
+
+ return query
+
+
def get_payments(filters):
args = frappe._dict(
account="credit_to",
diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py
index 35d8d164794d..0ba7186fa674 100644
--- a/erpnext/accounts/report/sales_register/sales_register.py
+++ b/erpnext/accounts/report/sales_register/sales_register.py
@@ -11,8 +11,8 @@
from erpnext.accounts.party import get_party_account
from erpnext.accounts.report.utils import (
+ apply_common_conditions,
get_advance_taxes_and_charges,
- get_conditions,
get_journal_entries,
get_opening_row,
get_party_details,
@@ -38,7 +38,7 @@ def _execute(filters, additional_table_columns=None):
if filters.get("include_payments"):
invoice_list += get_payments(filters)
- columns, income_accounts, tax_accounts, unrealized_profit_loss_accounts = get_columns(
+ columns, income_accounts, unrealized_profit_loss_accounts, tax_accounts = get_columns(
invoice_list, additional_table_columns, include_payments
)
@@ -415,14 +415,8 @@ def get_account_columns(invoice_list, include_payments):
def get_invoices(filters, additional_query_columns):
si = frappe.qb.DocType("Sales Invoice")
- invoice_item = frappe.qb.DocType("Sales Invoice Item")
- invoice_payment = frappe.qb.DocType("Sales Invoice Payment")
query = (
frappe.qb.from_(si)
- .inner_join(invoice_item)
- .on(si.name == invoice_item.parent)
- .left_join(invoice_payment)
- .on(si.name == invoice_payment.parent)
.select(
ConstantColumn("Sales Invoice").as_("doctype"),
si.name,
@@ -447,18 +441,36 @@ def get_invoices(filters, additional_query_columns):
.where((si.docstatus == 1))
.orderby(si.posting_date, si.name, order=Order.desc)
)
+
if additional_query_columns:
for col in additional_query_columns:
query = query.select(col)
+
if filters.get("customer"):
query = query.where(si.customer == filters.customer)
- query = get_conditions(
+
+ query = get_conditions(filters, query, "Sales Invoice")
+ query = apply_common_conditions(
filters, query, doctype="Sales Invoice", child_doctype="Sales Invoice Item"
)
+
invoices = query.run(as_dict=True)
return invoices
+def get_conditions(filters, query, doctype):
+ parent_doc = frappe.qb.DocType(doctype)
+ if filters.get("owner"):
+ query = query.where(parent_doc.owner == filters.owner)
+
+ if filters.get("mode_of_payment"):
+ payment_doc = frappe.qb.DocType("Sales Invoice Payment")
+ query = query.inner_join(payment_doc).on(parent_doc.name == payment_doc.parent)
+ query = query.where(payment_doc.mode_of_payment == filters.mode_of_payment).distinct()
+
+ return query
+
+
def get_payments(filters):
args = frappe._dict(
account="debit_to",
diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py
index 0753fff83444..9f96449ba7c4 100644
--- a/erpnext/accounts/report/utils.py
+++ b/erpnext/accounts/report/utils.py
@@ -256,7 +256,8 @@ def get_journal_entries(filters, args):
)
.orderby(je.posting_date, je.name, order=Order.desc)
)
- query = get_conditions(filters, query, doctype="Journal Entry", payments=True)
+ query = apply_common_conditions(filters, query, doctype="Journal Entry", payments=True)
+
journal_entries = query.run(as_dict=True)
return journal_entries
@@ -284,28 +285,17 @@ def get_payment_entries(filters, args):
)
.orderby(pe.posting_date, pe.name, order=Order.desc)
)
- query = get_conditions(filters, query, doctype="Payment Entry", payments=True)
+ query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True)
payment_entries = query.run(as_dict=True)
return payment_entries
-def get_conditions(filters, query, doctype, child_doctype=None, payments=False):
+def apply_common_conditions(filters, query, doctype, child_doctype=None, payments=False):
parent_doc = frappe.qb.DocType(doctype)
if child_doctype:
child_doc = frappe.qb.DocType(child_doctype)
- if parent_doc.get_table_name() == "tabSales Invoice":
- if filters.get("owner"):
- query = query.where(parent_doc.owner == filters.owner)
- if filters.get("mode_of_payment"):
- payment_doc = frappe.qb.DocType("Sales Invoice Payment")
- query = query.where(payment_doc.mode_of_payment == filters.mode_of_payment)
- if not payments:
- if filters.get("brand"):
- query = query.where(child_doc.brand == filters.brand)
- else:
- if filters.get("mode_of_payment"):
- query = query.where(parent_doc.mode_of_payment == filters.mode_of_payment)
+ join_required = False
if filters.get("company"):
query = query.where(parent_doc.company == filters.company)
@@ -320,13 +310,26 @@ def get_conditions(filters, query, doctype, child_doctype=None, payments=False):
else:
if filters.get("cost_center"):
query = query.where(child_doc.cost_center == filters.cost_center)
+ join_required = True
if filters.get("warehouse"):
query = query.where(child_doc.warehouse == filters.warehouse)
+ join_required = True
if filters.get("item_group"):
query = query.where(child_doc.item_group == filters.item_group)
+ join_required = True
+
+ if not payments:
+ if filters.get("brand"):
+ query = query.where(child_doc.brand == filters.brand)
+ join_required = True
+
+ if join_required:
+ query = query.inner_join(child_doc).on(parent_doc.name == child_doc.parent)
+ query = query.distinct()
if parent_doc.get_table_name() != "tabJournal Entry":
query = filter_invoices_based_on_dimensions(filters, query, parent_doc)
+
return query
diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py
index bf01362c97ff..08688608f4b1 100644
--- a/erpnext/accounts/test/accounts_mixin.py
+++ b/erpnext/accounts/test/accounts_mixin.py
@@ -126,6 +126,28 @@ def create_usd_receivable_account(self):
acc = frappe.get_doc("Account", name)
self.debtors_usd = acc.name
+ def create_usd_payable_account(self):
+ account_name = "Creditors USD"
+ 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 Payable - " + self.company_abbr
+ acc.company = self.company
+ acc.account_currency = "USD"
+ acc.account_type = "Payable"
+ 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.creditors_usd = acc.name
+
def clear_old_entries(self):
doctype_list = [
"GL Entry",
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 1aefeaacf787..eed74a5f0177 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -474,10 +474,12 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
# update ref in advance entry
if voucher_type == "Journal Entry":
- update_reference_in_journal_entry(entry, doc, do_not_save=True)
+ referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False)
# advance section in sales/purchase invoice and reconciliation tool,both pass on exchange gain/loss
# amount and account in args
- doc.make_exchange_gain_loss_journal(args)
+ # referenced_row is used to deduplicate gain/loss journal
+ entry.update({"referenced_row": referenced_row})
+ doc.make_exchange_gain_loss_journal([entry])
else:
update_reference_in_payment_entry(
entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe
@@ -627,6 +629,8 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
if not do_not_save:
journal_entry.save(ignore_permissions=True)
+ return new_row.name
+
def update_reference_in_payment_entry(
d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False
@@ -1164,7 +1168,7 @@ def parse_naming_series_variable(doc, variable):
@frappe.whitelist()
-def get_coa(doctype, parent, is_root, chart=None):
+def get_coa(doctype, parent, is_root=None, chart=None):
from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import (
build_tree_from_json,
)
@@ -1750,6 +1754,7 @@ def query_for_outstanding(self):
ple.posting_date,
ple.due_date,
ple.account_currency.as_("currency"),
+ ple.cost_center.as_("cost_center"),
Sum(ple.amount).as_("amount"),
Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"),
)
@@ -1812,6 +1817,7 @@ def query_for_outstanding(self):
).as_("paid_amount_in_account_currency"),
Table("vouchers").due_date,
Table("vouchers").currency,
+ Table("vouchers").cost_center.as_("cost_center"),
)
.where(Criterion.all(filter_on_outstanding_amount))
)
@@ -1882,6 +1888,7 @@ def get_voucher_outstandings(
def create_gain_loss_journal(
company,
+ posting_date,
party_type,
party,
party_account,
@@ -1895,12 +1902,14 @@ def create_gain_loss_journal(
ref2_dt,
ref2_dn,
ref2_detail_no,
+ cost_center,
) -> str:
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Exchange Gain Or Loss"
journal_entry.company = company
- journal_entry.posting_date = nowdate()
+ journal_entry.posting_date = posting_date or nowdate()
journal_entry.multi_currency = 1
+ journal_entry.is_system_generated = True
party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency")
@@ -1919,7 +1928,7 @@ def create_gain_loss_journal(
"party": party,
"account_currency": party_account_currency,
"exchange_rate": 0,
- "cost_center": erpnext.get_default_cost_center(company),
+ "cost_center": cost_center or erpnext.get_default_cost_center(company),
"reference_type": ref1_dt,
"reference_name": ref1_dn,
"reference_detail_no": ref1_detail_no,
@@ -1935,7 +1944,7 @@ def create_gain_loss_journal(
"account": gain_loss_account,
"account_currency": gain_loss_account_currency,
"exchange_rate": 1,
- "cost_center": erpnext.get_default_cost_center(company),
+ "cost_center": cost_center or erpnext.get_default_cost_center(company),
"reference_type": ref2_dt,
"reference_name": ref2_dn,
"reference_detail_no": ref2_detail_no,
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index ddb09c1f44b7..b5a4c2df76be 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -840,7 +840,7 @@ def make_journal_entry(asset_name):
je.voucher_type = "Depreciation Entry"
je.naming_series = depreciation_series
je.company = asset.company
- je.remark = _("Depreciation Entry against asset {0}").format(asset_name)
+ je.remark = ("Depreciation Entry against asset {0}").format(asset_name)
je.append(
"accounts",
diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
index 0bf2fbb14b67..662e4b983b1e 100644
--- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
+++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
@@ -404,14 +404,11 @@ def get_gl_entries_for_consumed_stock_items(
def get_gl_entries_for_consumed_asset_items(
self, gl_entries, target_account, target_against, precision
):
- self.are_all_asset_items_non_depreciable = True
-
# Consumed Assets
for item in self.asset_items:
asset = frappe.get_doc("Asset", item.asset)
if asset.calculate_depreciation:
- self.are_all_asset_items_non_depreciable = False
notes = _(
"This schedule was created when Asset {0} was consumed through Asset Capitalization {1}."
).format(
diff --git a/erpnext/assets/doctype/location/location.json b/erpnext/assets/doctype/location/location.json
index f56fd05d98c8..9202fb9d95f6 100644
--- a/erpnext/assets/doctype/location/location.json
+++ b/erpnext/assets/doctype/location/location.json
@@ -39,6 +39,7 @@
{
"fieldname": "parent_location",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Parent Location",
"options": "Location",
"search_index": 1
@@ -141,11 +142,11 @@
],
"is_tree": 1,
"links": [],
- "modified": "2020-05-08 16:11:11.375701",
+ "modified": "2023-08-29 12:49:33.290527",
"modified_by": "Administrator",
"module": "Assets",
"name": "Location",
- "name_case": "Title Case",
+ "naming_rule": "By fieldname",
"nsm_parent_field": "parent_location",
"owner": "Administrator",
"permissions": [
@@ -224,5 +225,6 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js
index f6a195143922..88faeee982bc 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.js
@@ -118,6 +118,24 @@ frappe.ui.form.on("Purchase Order", {
frm.set_value("tax_withholding_category", frm.supplier_tds);
}
},
+
+ get_subcontracting_boms_for_finished_goods: function(fg_item) {
+ return frappe.call({
+ method:"erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_finished_goods",
+ args: {
+ fg_items: fg_item
+ },
+ });
+ },
+
+ get_subcontracting_boms_for_service_item: function(service_item) {
+ return frappe.call({
+ method:"erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_service_item",
+ args: {
+ service_item: service_item
+ },
+ });
+ },
});
frappe.ui.form.on("Purchase Order Item", {
@@ -132,15 +150,83 @@ frappe.ui.form.on("Purchase Order Item", {
}
},
- qty: function(frm, cdt, cdn) {
+ item_code: async function(frm, cdt, cdn) {
if (frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow) {
var row = locals[cdt][cdn];
- if (row.qty) {
- row.fg_item_qty = row.qty;
+ if (row.item_code && !row.fg_item) {
+ var result = await frm.events.get_subcontracting_boms_for_service_item(row.item_code)
+
+ if (result.message && Object.keys(result.message).length) {
+ var finished_goods = Object.keys(result.message);
+
+ // Set FG if only one active Subcontracting BOM is found
+ if (finished_goods.length === 1) {
+ row.fg_item = result.message[finished_goods[0]].finished_good;
+ row.uom = result.message[finished_goods[0]].finished_good_uom;
+ refresh_field("items");
+ } else {
+ const dialog = new frappe.ui.Dialog({
+ title: __("Select Finished Good"),
+ size: "small",
+ fields: [
+ {
+ fieldname: "finished_good",
+ fieldtype: "Autocomplete",
+ label: __("Finished Good"),
+ options: finished_goods,
+ }
+ ],
+ primary_action_label: __("Select"),
+ primary_action: () => {
+ var subcontracting_bom = result.message[dialog.get_value("finished_good")];
+
+ if (subcontracting_bom) {
+ row.fg_item = subcontracting_bom.finished_good;
+ row.uom = subcontracting_bom.finished_good_uom;
+ refresh_field("items");
+ }
+
+ dialog.hide();
+ },
+ });
+
+ dialog.show();
+ }
+ }
}
}
- }
+ },
+
+ fg_item: async function(frm, cdt, cdn) {
+ if (frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow) {
+ var row = locals[cdt][cdn];
+
+ if (row.fg_item) {
+ var result = await frm.events.get_subcontracting_boms_for_finished_goods(row.fg_item)
+
+ if (result.message && Object.keys(result.message).length) {
+ frappe.model.set_value(cdt, cdn, "item_code", result.message.service_item);
+ frappe.model.set_value(cdt, cdn, "qty", flt(row.fg_item_qty) * flt(result.message.conversion_factor));
+ frappe.model.set_value(cdt, cdn, "uom", result.message.service_item_uom);
+ }
+ }
+ }
+ },
+
+ fg_item_qty: async function(frm, cdt, cdn) {
+ if (frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow) {
+ var row = locals[cdt][cdn];
+
+ if (row.fg_item) {
+ var result = await frm.events.get_subcontracting_boms_for_finished_goods(row.fg_item)
+
+ if (result.message && row.item_code == result.message.service_item && row.uom == result.message.service_item_uom) {
+ frappe.model.set_value(cdt, cdn, "qty", flt(row.fg_item_qty) * flt(result.message.conversion_factor));
+ }
+ }
+ }
+ },
});
erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends erpnext.buying.BuyingController {
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 3576cd426d6f..465fe96b58b5 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -28,6 +28,9 @@
from erpnext.stock.doctype.item.item import get_item_defaults, get_last_purchase_details
from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty
from erpnext.stock.utils import get_bin
+from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
+ get_subcontracting_boms_for_finished_goods,
+)
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -451,6 +454,25 @@ def update_receiving_percentage(self):
else:
self.db_set("per_received", 0, update_modified=False)
+ def set_service_items_for_finished_goods(self):
+ if not self.is_subcontracted or self.is_old_subcontracting_flow:
+ return
+
+ finished_goods_without_service_item = {
+ d.fg_item for d in self.items if (not d.item_code and d.fg_item)
+ }
+
+ if subcontracting_boms := get_subcontracting_boms_for_finished_goods(
+ finished_goods_without_service_item
+ ):
+ for item in self.items:
+ if not item.item_code and item.fg_item in subcontracting_boms:
+ subcontracting_bom = subcontracting_boms[item.fg_item]
+
+ item.item_code = subcontracting_bom.service_item
+ item.qty = flt(item.fg_item_qty) * flt(subcontracting_bom.conversion_factor)
+ item.uom = subcontracting_bom.service_item_uom
+
def can_update_items(self) -> bool:
result = True
diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
index c645b04e1294..414f0866ccb2 100644
--- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
+++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json
@@ -7,13 +7,13 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
+ "fg_item",
+ "fg_item_qty",
"item_code",
"supplier_part_no",
"item_name",
"brand",
"product_bundle",
- "fg_item",
- "fg_item_qty",
"column_break_4",
"schedule_date",
"expected_delivery_date",
@@ -862,7 +862,7 @@
"depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow",
"fieldname": "fg_item",
"fieldtype": "Link",
- "label": "Finished Good Item",
+ "label": "Finished Good",
"mandatory_depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow",
"options": "Item"
},
@@ -871,7 +871,7 @@
"depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow",
"fieldname": "fg_item_qty",
"fieldtype": "Float",
- "label": "Finished Good Item Qty",
+ "label": "Finished Good Qty",
"mandatory_depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow"
},
{
@@ -902,7 +902,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-11-29 16:47:41.364387",
+ "modified": "2023-08-17 10:17:40.893393",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 1d5063904387..0ca1e94427ef 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -1023,6 +1023,44 @@ def make_precision_loss_gl_entry(self, gl_entries):
)
)
+ def gain_loss_journal_already_booked(
+ self,
+ gain_loss_account,
+ exc_gain_loss,
+ ref2_dt,
+ ref2_dn,
+ ref2_detail_no,
+ ) -> bool:
+ """
+ Check if gain/loss is booked
+ """
+ if res := frappe.db.get_all(
+ "Journal Entry Account",
+ filters={
+ "docstatus": 1,
+ "account": gain_loss_account,
+ "reference_type": ref2_dt, # this will be Journal Entry
+ "reference_name": ref2_dn,
+ "reference_detail_no": ref2_detail_no,
+ },
+ pluck="parent",
+ ):
+ # deduplicate
+ res = list({x for x in res})
+ if exc_vouchers := frappe.db.get_all(
+ "Journal Entry",
+ filters={"name": ["in", res], "voucher_type": "Exchange Gain Or Loss"},
+ fields=["voucher_type", "total_debit", "total_credit"],
+ ):
+ booked_voucher = exc_vouchers[0]
+ if (
+ booked_voucher.total_debit == exc_gain_loss
+ and booked_voucher.total_credit == exc_gain_loss
+ and booked_voucher.voucher_type == "Exchange Gain Or Loss"
+ ):
+ return True
+ return False
+
def make_exchange_gain_loss_journal(self, args: dict = None) -> None:
"""
Make Exchange Gain/Loss journal for Invoices and Payments
@@ -1051,27 +1089,37 @@ def make_exchange_gain_loss_journal(self, args: dict = None) -> None:
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
- je = create_gain_loss_journal(
- self.company,
- arg.get("party_type"),
- arg.get("party"),
- party_account,
+ if not self.gain_loss_journal_already_booked(
gain_loss_account,
difference_amount,
- dr_or_cr,
- reverse_dr_or_cr,
- arg.get("against_voucher_type"),
- arg.get("against_voucher"),
- arg.get("idx"),
self.doctype,
self.name,
- arg.get("idx"),
- )
- frappe.msgprint(
- _("Exchange Gain/Loss amount has been booked through {0}").format(
- get_link_to_form("Journal Entry", je)
+ arg.get("referenced_row"),
+ ):
+ posting_date = frappe.db.get_value(arg.voucher_type, arg.voucher_no, "posting_date")
+ je = create_gain_loss_journal(
+ self.company,
+ posting_date,
+ arg.get("party_type"),
+ arg.get("party"),
+ party_account,
+ gain_loss_account,
+ difference_amount,
+ dr_or_cr,
+ reverse_dr_or_cr,
+ arg.get("against_voucher_type"),
+ arg.get("against_voucher"),
+ arg.get("idx"),
+ self.doctype,
+ self.name,
+ arg.get("referenced_row"),
+ arg.get("cost_center"),
+ )
+ frappe.msgprint(
+ _("Exchange Gain/Loss amount has been booked through {0}").format(
+ get_link_to_form("Journal Entry", je)
+ )
)
- )
if self.get("doctype") == "Payment Entry":
# For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation
@@ -1131,6 +1179,7 @@ def make_exchange_gain_loss_journal(self, args: dict = None) -> None:
je = create_gain_loss_journal(
self.company,
+ self.posting_date,
self.party_type,
self.party,
party_account,
@@ -1144,6 +1193,7 @@ def make_exchange_gain_loss_journal(self, args: dict = None) -> None:
self.doctype,
self.name,
d.idx,
+ self.cost_center,
)
frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format(
@@ -1381,7 +1431,7 @@ def make_discount_gl_entries(self, gl_entries):
{
"account": self.additional_discount_account,
"against": supplier_or_customer,
- dr_or_cr: self.discount_amount,
+ dr_or_cr: self.base_discount_amount,
"cost_center": self.cost_center,
},
item=self,
@@ -1653,6 +1703,7 @@ def validate_currency(self):
and party_account_currency != self.company_currency
and self.currency != party_account_currency
):
+
frappe.throw(
_("Accounting Entry for {0}: {1} can only be made in currency: {2}").format(
party_type, party, party_account_currency
@@ -2386,7 +2437,8 @@ def get_common_query(
limit,
condition,
):
- payment_type = "Receive" if party_type == "Customer" else "Pay"
+ account_type = frappe.db.get_value("Party Type", party_type, "account_type")
+ payment_type = "Receive" if account_type == "Receivable" else "Pay"
payment_entry = frappe.qb.DocType("Payment Entry")
q = (
@@ -2403,7 +2455,7 @@ def get_common_query(
.where(payment_entry.docstatus == 1)
)
- if party_type == "Customer":
+ if payment_type == "Receive":
q = q.select((payment_entry.paid_from_account_currency).as_("currency"))
q = q.select(payment_entry.paid_from)
q = q.where(payment_entry.paid_from.isin(party_account))
@@ -3095,7 +3147,9 @@ def validate_fg_item_for_subcontracting(new_data, is_new):
if has_reserved_stock(parent.doctype, parent.name):
cancel_stock_reservation_entries(parent.doctype, parent.name)
- parent.create_stock_reservation_entries()
+
+ if parent.per_picked == 0:
+ parent.create_stock_reservation_entries()
@erpnext.allow_regional
diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py
index 0f8e133e0fdc..391258fde778 100644
--- a/erpnext/controllers/tests/test_accounts_controller.py
+++ b/erpnext/controllers/tests/test_accounts_controller.py
@@ -55,6 +55,7 @@ class TestAccountsController(FrappeTestCase):
10 series - Sales Invoice against Payment Entries
20 series - Sales Invoice against Journals
30 series - Sales Invoice against Credit Notes
+ 40 series - Company default Cost center is unset
"""
def setUp(self):
@@ -941,6 +942,60 @@ def test_23_same_journal_split_against_single_invoice(self):
self.assertEqual(exc_je_for_si, [])
self.assertEqual(exc_je_for_je, [])
+ def test_24_journal_against_multiple_invoices(self):
+ si1 = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)
+ si2 = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)
+
+ # Payment
+ je = self.create_journal_entry(
+ acc1=self.debit_usd,
+ acc1_exc_rate=75,
+ acc2=self.cash,
+ acc1_amount=-2,
+ acc2_amount=-150,
+ acc2_exc_rate=1,
+ )
+ je.accounts[0].party_type = "Customer"
+ je.accounts[0].party = self.customer
+ je = je.save().submit()
+
+ pr = self.create_payment_reconciliation()
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 2)
+ self.assertEqual(len(pr.payments), 1)
+ 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()
+ self.assertEqual(len(pr.invoices), 0)
+ self.assertEqual(len(pr.payments), 0)
+
+ si1.reload()
+ si2.reload()
+
+ self.assertEqual(si1.outstanding_amount, 0)
+ self.assertEqual(si2.outstanding_amount, 0)
+ self.assert_ledger_outstanding(si1.doctype, si1.name, 0.0, 0.0)
+ self.assert_ledger_outstanding(si2.doctype, si2.name, 0.0, 0.0)
+
+ # Exchange Gain/Loss Journal should've been created
+ # remove payment JE from list
+ exc_je_for_si1 = [x for x in self.get_journals_for(si1.doctype, si1.name) if x.parent != je.name]
+ exc_je_for_si2 = [x for x in self.get_journals_for(si2.doctype, si2.name) if x.parent != je.name]
+ exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name]
+ self.assertEqual(len(exc_je_for_si1), 1)
+ self.assertEqual(len(exc_je_for_si2), 1)
+ self.assertEqual(len(exc_je_for_je), 2)
+
+ si1.cancel()
+ # Gain/Loss JE of si1 should've been cancelled
+ exc_je_for_si1 = [x for x in self.get_journals_for(si1.doctype, si1.name) if x.parent != je.name]
+ exc_je_for_si2 = [x for x in self.get_journals_for(si2.doctype, si2.name) if x.parent != je.name]
+ exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name]
+ self.assertEqual(len(exc_je_for_si1), 0)
+ self.assertEqual(len(exc_je_for_si2), 1)
+ self.assertEqual(len(exc_je_for_je), 1)
+
def test_30_cr_note_against_sales_invoice(self):
"""
Reconciling Cr Note against Sales Invoice, both having different exchange rates
@@ -997,3 +1052,139 @@ def test_30_cr_note_against_sales_invoice(self):
si.reload()
self.assertEqual(si.outstanding_amount, 1)
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
+
+ def test_40_cost_center_from_payment_entry(self):
+ """
+ Gain/Loss JE should inherit cost center from payment if company default is unset
+ """
+ # remove default cost center
+ cc = frappe.db.get_value("Company", self.company, "cost_center")
+ frappe.db.set_value("Company", self.company, "cost_center", None)
+
+ rate_in_account_currency = 1
+ si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
+ si.cost_center = None
+ si.save().submit()
+
+ pe = get_payment_entry(si.doctype, si.name)
+ pe.source_exchange_rate = 75
+ pe.received_amount = 75
+ pe.cost_center = self.cost_center
+ pe = pe.save().submit()
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_pe), 1)
+ self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0])
+
+ self.assertEqual(
+ [self.cost_center, self.cost_center],
+ frappe.db.get_all(
+ "Journal Entry Account", filters={"parent": exc_je_for_si[0].parent}, pluck="cost_center"
+ ),
+ )
+ frappe.db.set_value("Company", self.company, "cost_center", cc)
+
+ def test_41_cost_center_from_journal_entry(self):
+ """
+ Gain/Loss JE should inherit cost center from payment if company default is unset
+ """
+ # remove default cost center
+ cc = frappe.db.get_value("Company", self.company, "cost_center")
+ frappe.db.set_value("Company", self.company, "cost_center", None)
+
+ rate_in_account_currency = 1
+ si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
+ si.cost_center = None
+ si.save().submit()
+
+ je = self.create_journal_entry(
+ acc1=self.debit_usd,
+ acc1_exc_rate=75,
+ acc2=self.cash,
+ acc1_amount=-1,
+ acc2_amount=-75,
+ acc2_exc_rate=1,
+ )
+ je.accounts[0].party_type = "Customer"
+ je.accounts[0].party = self.customer
+ je.accounts[0].cost_center = self.cost_center
+ je = je.save().submit()
+
+ # Reconcile
+ pr = self.create_payment_reconciliation()
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+ 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()
+ self.assertEqual(len(pr.invoices), 0)
+ self.assertEqual(len(pr.payments), 0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name]
+ exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name]
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 1)
+ self.assertEqual(len(exc_je_for_je), 1)
+ self.assertEqual(exc_je_for_si[0], exc_je_for_je[0])
+
+ self.assertEqual(
+ [self.cost_center, self.cost_center],
+ frappe.db.get_all(
+ "Journal Entry Account", filters={"parent": exc_je_for_si[0].parent}, pluck="cost_center"
+ ),
+ )
+ frappe.db.set_value("Company", self.company, "cost_center", cc)
+
+ def test_42_cost_center_from_cr_note(self):
+ """
+ Gain/Loss JE should inherit cost center from payment if company default is unset
+ """
+ # remove default cost center
+ cc = frappe.db.get_value("Company", self.company, "cost_center")
+ frappe.db.set_value("Company", self.company, "cost_center", None)
+
+ rate_in_account_currency = 1
+ si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True)
+ si.cost_center = None
+ si.save().submit()
+
+ cr_note = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True)
+ cr_note.cost_center = self.cost_center
+ cr_note.is_return = 1
+ cr_note.save().submit()
+
+ # Reconcile
+ pr = self.create_payment_reconciliation()
+ pr.get_unreconciled_entries()
+ self.assertEqual(len(pr.invoices), 1)
+ self.assertEqual(len(pr.payments), 1)
+ 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()
+ self.assertEqual(len(pr.invoices), 0)
+ self.assertEqual(len(pr.payments), 0)
+
+ # Exchange Gain/Loss Journal should've been created.
+ exc_je_for_si = self.get_journals_for(si.doctype, si.name)
+ exc_je_for_cr_note = self.get_journals_for(cr_note.doctype, cr_note.name)
+ self.assertNotEqual(exc_je_for_si, [])
+ self.assertEqual(len(exc_je_for_si), 2)
+ self.assertEqual(len(exc_je_for_cr_note), 2)
+ self.assertEqual(exc_je_for_si, exc_je_for_cr_note)
+
+ for x in exc_je_for_si + exc_je_for_cr_note:
+ with self.subTest(x=x):
+ self.assertEqual(
+ [self.cost_center, self.cost_center],
+ frappe.db.get_all("Journal Entry Account", filters={"parent": x.parent}, pluck="cost_center"),
+ )
+
+ frappe.db.set_value("Company", self.company, "cost_center", cc)
diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json
index 0cb8824577a0..dafbd9f06d90 100644
--- a/erpnext/crm/doctype/lead/lead.json
+++ b/erpnext/crm/doctype/lead/lead.json
@@ -516,7 +516,7 @@
"idx": 5,
"image_field": "image",
"links": [],
- "modified": "2023-04-14 18:20:05.044791",
+ "modified": "2023-08-28 22:28:00.104413",
"modified_by": "Administrator",
"module": "CRM",
"name": "Lead",
@@ -527,7 +527,7 @@
"permlevel": 1,
"read": 1,
"report": 1,
- "role": "All"
+ "role": "Desk User"
},
{
"create": 1,
@@ -583,4 +583,4 @@
"states": [],
"subject_field": "title",
"title_field": "title"
-}
+}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
index 5c4be6ffaa29..510317f5c2ec 100644
--- a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
+++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
@@ -1,6 +1,6 @@
{
"charts": [],
- "content": "[{\"id\":\"e88ADOJ7WC\",\"type\":\"header\",\"data\":{\"text\":\"Integrations\",\"col\":12}},{\"id\":\"G0tyx9WOfm\",\"type\":\"card\",\"data\":{\"card_name\":\"Backup\",\"col\":4}},{\"id\":\"nu4oSjH5Rd\",\"type\":\"card\",\"data\":{\"card_name\":\"Authentication\",\"col\":4}},{\"id\":\"nG8cdkpzoc\",\"type\":\"card\",\"data\":{\"card_name\":\"Google Services\",\"col\":4}},{\"id\":\"4hwuQn6E95\",\"type\":\"card\",\"data\":{\"card_name\":\"Communication Channels\",\"col\":4}},{\"id\":\"sEGAzTJRmq\",\"type\":\"card\",\"data\":{\"card_name\":\"Payments\",\"col\":4}},{\"id\":\"ZC6xu-cLBR\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]",
+ "content": "[{\"id\":\"e88ADOJ7WC\",\"type\":\"header\",\"data\":{\"text\":\"Integrations\",\"col\":12}},{\"id\":\"pZEYOOCdB0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Browse Apps\",\"col\":3}},{\"id\":\"St7AHbhVOr\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"nu4oSjH5Rd\",\"type\":\"card\",\"data\":{\"card_name\":\"Authentication\",\"col\":4}},{\"id\":\"G0tyx9WOfm\",\"type\":\"card\",\"data\":{\"card_name\":\"Backup\",\"col\":4}},{\"id\":\"nG8cdkpzoc\",\"type\":\"card\",\"data\":{\"card_name\":\"Google Services\",\"col\":4}},{\"id\":\"4hwuQn6E95\",\"type\":\"card\",\"data\":{\"card_name\":\"Communication Channels\",\"col\":4}},{\"id\":\"sEGAzTJRmq\",\"type\":\"card\",\"data\":{\"card_name\":\"Payments\",\"col\":4}}]",
"creation": "2020-08-20 19:30:48.138801",
"custom_blocks": [],
"docstatus": 0,
@@ -221,27 +221,9 @@
"link_type": "DocType",
"onboard": 0,
"type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Settings",
- "link_count": 2,
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Woocommerce Settings",
- "link_count": 0,
- "link_to": "Woocommerce Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
}
],
- "modified": "2023-05-24 14:47:26.984717",
+ "modified": "2023-08-29 15:48:59.010704",
"modified_by": "Administrator",
"module": "ERPNext Integrations",
"name": "ERPNext Integrations",
@@ -253,6 +235,14 @@
"restrict_to_domain": "",
"roles": [],
"sequence_id": 21.0,
- "shortcuts": [],
+ "shortcuts": [
+ {
+ "color": "Grey",
+ "doc_view": "List",
+ "label": "Browse Apps",
+ "type": "URL",
+ "url": "https://frappecloud.com/marketplace"
+ }
+ ],
"title": "ERPNext Integrations"
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 34e94232c45a..b7a248901e7d 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -673,6 +673,7 @@ def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
po.append("items", po_data)
+ po.set_service_items_for_finished_goods()
po.set_missing_values()
po.flags.ignore_mandatory = True
po.flags.ignore_validate = True
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 2871a29d7682..dccb90311937 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -412,11 +412,15 @@ def test_production_plan_subassembly_default_supplier(self):
def test_production_plan_for_subcontracting_po(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
+ from erpnext.subcontracting.doctype.subcontracting_bom.test_subcontracting_bom import (
+ create_subcontracting_bom,
+ )
- bom_tree_1 = {"Test Laptop 1": {"Test Motherboard 1": {"Test Motherboard Wires 1": {}}}}
+ fg_item = "Test Motherboard 1"
+ bom_tree_1 = {"Test Laptop 1": {fg_item: {"Test Motherboard Wires 1": {}}}}
create_nested_bom(bom_tree_1, prefix="")
- item_doc = frappe.get_doc("Item", "Test Motherboard 1")
+ item_doc = frappe.get_doc("Item", fg_item)
company = "_Test Company"
item_doc.is_sub_contracted_item = 1
@@ -429,6 +433,12 @@ def test_production_plan_for_subcontracting_po(self):
item_doc.save()
+ service_item = make_item(properties={"is_stock_item": 0}).name
+ create_subcontracting_bom(
+ finished_good=fg_item,
+ service_item=service_item,
+ )
+
plan = create_production_plan(
item_code="Test Laptop 1", planned_qty=10, use_multi_level_bom=1, do_not_submit=True
)
@@ -445,7 +455,8 @@ def test_production_plan_for_subcontracting_po(self):
self.assertEqual(po_doc.items[0].qty, 10.0)
self.assertEqual(po_doc.items[0].fg_item_qty, 10.0)
self.assertEqual(po_doc.items[0].fg_item_qty, 10.0)
- self.assertEqual(po_doc.items[0].fg_item, "Test Motherboard 1")
+ self.assertEqual(po_doc.items[0].fg_item, fg_item)
+ self.assertEqual(po_doc.items[0].item_code, service_item)
def test_production_plan_combine_subassembly(self):
"""
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index c8cf7bc6be3f..b3d6d3e80a45 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -263,7 +263,6 @@ erpnext.patches.v15_0.saudi_depreciation_warning
erpnext.patches.v15_0.delete_saudi_doctypes
erpnext.patches.v14_0.show_loan_management_deprecation_warning
execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Details", force=True)
-erpnext.patches.v14_0.delete_education_module_portal_menu_items
[post_model_sync]
execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings')
@@ -340,5 +339,6 @@ execute:frappe.defaults.clear_default("fiscal_year")
erpnext.patches.v15_0.remove_exotel_integration
erpnext.patches.v14_0.single_to_multi_dunning
execute:frappe.db.set_single_value('Selling Settings', 'allow_negative_rates_for_items', 0)
+erpnext.patches.v15_0.correct_asset_value_if_je_with_workflow
# below migration patch should always run last
erpnext.patches.v14_0.migrate_gl_to_payment_ledger
diff --git a/erpnext/patches/v14_0/delete_education_module_portal_menu_items.py b/erpnext/patches/v14_0/delete_education_module_portal_menu_items.py
deleted file mode 100644
index d964f1494415..000000000000
--- a/erpnext/patches/v14_0/delete_education_module_portal_menu_items.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
-# License: MIT. See LICENSE
-
-import frappe
-
-
-def execute():
- doctypes = frappe.get_all("DocType", {"module": "education", "custom": 0}, pluck="name")
- items = frappe.get_all(
- "Portal Menu Item", filters={"reference_doctype": ("in", doctypes)}, pluck="name"
- )
- for item in items:
- frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True)
diff --git a/erpnext/patches/v15_0/correct_asset_value_if_je_with_workflow.py b/erpnext/patches/v15_0/correct_asset_value_if_je_with_workflow.py
new file mode 100644
index 000000000000..aededa2287d6
--- /dev/null
+++ b/erpnext/patches/v15_0/correct_asset_value_if_je_with_workflow.py
@@ -0,0 +1,119 @@
+import frappe
+from frappe.model.workflow import get_workflow_name
+from frappe.query_builder.functions import IfNull, Sum
+
+
+def execute():
+ active_je_workflow = get_workflow_name("Journal Entry")
+ if not active_je_workflow:
+ return
+
+ correct_value_for_assets_with_manual_depr_entries()
+
+ finance_books = frappe.db.get_all("Finance Book", pluck="name")
+
+ if finance_books:
+ for fb_name in finance_books:
+ correct_value_for_assets_with_auto_depr(fb_name)
+
+ correct_value_for_assets_with_auto_depr()
+
+
+def correct_value_for_assets_with_manual_depr_entries():
+ asset = frappe.qb.DocType("Asset")
+ gle = frappe.qb.DocType("GL Entry")
+ aca = frappe.qb.DocType("Asset Category Account")
+ company = frappe.qb.DocType("Company")
+
+ asset_details_and_depr_amount_map = (
+ frappe.qb.from_(gle)
+ .join(asset)
+ .on(gle.against_voucher == asset.name)
+ .join(aca)
+ .on((aca.parent == asset.asset_category) & (aca.company_name == asset.company))
+ .join(company)
+ .on(company.name == asset.company)
+ .select(
+ asset.name.as_("asset_name"),
+ asset.gross_purchase_amount.as_("gross_purchase_amount"),
+ asset.opening_accumulated_depreciation.as_("opening_accumulated_depreciation"),
+ Sum(gle.debit).as_("depr_amount"),
+ )
+ .where(
+ gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account)
+ )
+ .where(gle.debit != 0)
+ .where(gle.is_cancelled == 0)
+ .where(asset.docstatus == 1)
+ .where(asset.calculate_depreciation == 0)
+ .groupby(asset.name)
+ )
+
+ frappe.qb.update(asset).join(asset_details_and_depr_amount_map).on(
+ asset_details_and_depr_amount_map.asset_name == asset.name
+ ).set(
+ asset.value_after_depreciation,
+ asset_details_and_depr_amount_map.gross_purchase_amount
+ - asset_details_and_depr_amount_map.opening_accumulated_depreciation
+ - asset_details_and_depr_amount_map.depr_amount,
+ ).run()
+
+
+def correct_value_for_assets_with_auto_depr(fb_name=None):
+ asset = frappe.qb.DocType("Asset")
+ gle = frappe.qb.DocType("GL Entry")
+ aca = frappe.qb.DocType("Asset Category Account")
+ company = frappe.qb.DocType("Company")
+ afb = frappe.qb.DocType("Asset Finance Book")
+
+ asset_details_and_depr_amount_map = (
+ frappe.qb.from_(gle)
+ .join(asset)
+ .on(gle.against_voucher == asset.name)
+ .join(aca)
+ .on((aca.parent == asset.asset_category) & (aca.company_name == asset.company))
+ .join(company)
+ .on(company.name == asset.company)
+ .select(
+ asset.name.as_("asset_name"),
+ asset.gross_purchase_amount.as_("gross_purchase_amount"),
+ asset.opening_accumulated_depreciation.as_("opening_accumulated_depreciation"),
+ Sum(gle.debit).as_("depr_amount"),
+ )
+ .where(
+ gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account)
+ )
+ .where(gle.debit != 0)
+ .where(gle.is_cancelled == 0)
+ .where(asset.docstatus == 1)
+ .where(asset.calculate_depreciation == 1)
+ .groupby(asset.name)
+ )
+
+ if fb_name:
+ asset_details_and_depr_amount_map = asset_details_and_depr_amount_map.where(
+ gle.finance_book == fb_name
+ )
+ else:
+ asset_details_and_depr_amount_map = asset_details_and_depr_amount_map.where(
+ (gle.finance_book.isin([""])) | (gle.finance_book.isnull())
+ )
+
+ query = (
+ frappe.qb.update(afb)
+ .join(asset_details_and_depr_amount_map)
+ .on(asset_details_and_depr_amount_map.asset_name == afb.parent)
+ .set(
+ afb.value_after_depreciation,
+ asset_details_and_depr_amount_map.gross_purchase_amount
+ - asset_details_and_depr_amount_map.opening_accumulated_depreciation
+ - asset_details_and_depr_amount_map.depr_amount,
+ )
+ )
+
+ if fb_name:
+ query = query.where(afb.finance_book == fb_name)
+ else:
+ query = query.where((afb.finance_book.isin([""])) | (afb.finance_book.isnull()))
+
+ query.run()
diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json
index 502ee574159e..715b09c64bc6 100644
--- a/erpnext/projects/doctype/project/project.json
+++ b/erpnext/projects/doctype/project/project.json
@@ -453,7 +453,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
- "modified": "2023-06-28 18:57:11.603497",
+ "modified": "2023-08-28 22:27:28.370849",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
@@ -475,7 +475,7 @@
"permlevel": 1,
"read": 1,
"report": 1,
- "role": "All"
+ "role": "Desk User"
},
{
"create": 1,
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index ac5735b70727..80d7b79c1ecb 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -702,7 +702,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
on_submit() {
- refresh_field("items");
+ this.refresh_serial_batch_bundle_field();
+ }
+
+ refresh_serial_batch_bundle_field() {
+ frappe.route_hooks.after_submit = (frm_obj) => {
+ frm_obj.reload_doc();
+ }
}
update_qty(cdt, cdn) {
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js
index a3c10c68a7a5..89750f8446c2 100755
--- a/erpnext/public/js/utils.js
+++ b/erpnext/public/js/utils.js
@@ -571,6 +571,7 @@ erpnext.utils.update_child_items = function(opts) {
const cannot_add_row = (typeof opts.cannot_add_row === 'undefined') ? true : opts.cannot_add_row;
const child_docname = (typeof opts.cannot_add_row === 'undefined') ? "items" : opts.child_docname;
const child_meta = frappe.get_meta(`${frm.doc.doctype} Item`);
+ const has_reserved_stock = opts.has_reserved_stock ? true : false;
const get_precision = (fieldname) => child_meta.fields.find(f => f.fieldname == fieldname).precision;
this.data = frm.doc[opts.child_docname].map((d) => {
@@ -734,6 +735,17 @@ erpnext.utils.update_child_items = function(opts) {
},
],
primary_action: function() {
+ if (frm.doctype == "Sales Order" && has_reserved_stock) {
+ this.hide();
+ frappe.confirm(
+ __('The reserved stock will be released when you update items. Are you certain you wish to proceed?'),
+ () => this.update_items(),
+ )
+ } else {
+ this.update_items();
+ }
+ },
+ update_items: function() {
const trans_items = this.get_values()["trans_items"].filter((item) => !!item.item_code);
frappe.call({
method: 'erpnext.controllers.accounts_controller.update_child_qty_rate',
@@ -823,6 +835,8 @@ erpnext.utils.map_current_doc = function(opts) {
"target_doc": cur_frm.doc,
"args": opts.args
},
+ freeze: true,
+ freeze_message: __("Mapping {0} ...", [opts.source_doctype]),
callback: function(r) {
if(!r.exc) {
var doc = frappe.model.sync(r.message);
diff --git a/erpnext/quality_management/doctype/quality_action/quality_action.json b/erpnext/quality_management/doctype/quality_action/quality_action.json
index 0cc2a98cd249..f0b33b9eaf7f 100644
--- a/erpnext/quality_management/doctype/quality_action/quality_action.json
+++ b/erpnext/quality_management/doctype/quality_action/quality_action.json
@@ -91,7 +91,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-10-27 16:21:59.533937",
+ "modified": "2023-08-28 22:33:14.358143",
"modified_by": "Administrator",
"module": "Quality Management",
"name": "Quality Action",
@@ -117,12 +117,13 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "All",
+ "role": "Desk User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json
index f3bd0ddb2ede..5fe6375fccf3 100644
--- a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json
+++ b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json
@@ -61,7 +61,7 @@
"link_fieldname": "feedback"
}
],
- "modified": "2020-10-27 16:20:10.918544",
+ "modified": "2023-08-28 22:21:36.144820",
"modified_by": "Administrator",
"module": "Quality Management",
"name": "Quality Feedback",
@@ -87,12 +87,13 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "All",
+ "role": "Desk User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/quality_management/doctype/quality_goal/quality_goal.json b/erpnext/quality_management/doctype/quality_goal/quality_goal.json
index 26802550dcaa..f2b6ebc589dc 100644
--- a/erpnext/quality_management/doctype/quality_goal/quality_goal.json
+++ b/erpnext/quality_management/doctype/quality_goal/quality_goal.json
@@ -76,7 +76,7 @@
"link_fieldname": "goal"
}
],
- "modified": "2020-10-27 15:57:59.368605",
+ "modified": "2023-08-28 22:33:27.718899",
"modified_by": "Administrator",
"module": "Quality Management",
"name": "Quality Goal",
@@ -102,12 +102,13 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "All",
+ "role": "Desk User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json b/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json
index e2125c3933ae..7ab28d8443b5 100644
--- a/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json
+++ b/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json
@@ -48,7 +48,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-02-27 16:36:45.657883",
+ "modified": "2023-08-28 22:33:57.447634",
"modified_by": "Administrator",
"module": "Quality Management",
"name": "Quality Meeting",
@@ -74,7 +74,7 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "All",
+ "role": "Desk User",
"share": 1,
"write": 1
}
@@ -82,5 +82,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
-}
+}
\ No newline at end of file
diff --git a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json
index f588f9aea1a4..fd5a5959b8a8 100644
--- a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json
+++ b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json
@@ -23,6 +23,7 @@
{
"fieldname": "parent_quality_procedure",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"label": "Parent Procedure",
"options": "Quality Procedure"
},
@@ -115,7 +116,7 @@
"link_fieldname": "procedure"
}
],
- "modified": "2020-10-26 15:25:39.316088",
+ "modified": "2023-08-28 22:33:36.483420",
"modified_by": "Administrator",
"module": "Quality Management",
"name": "Quality Procedure",
@@ -142,12 +143,13 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "All",
+ "role": "Desk User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/quality_management/doctype/quality_review/quality_review.json b/erpnext/quality_management/doctype/quality_review/quality_review.json
index 31ad34136279..f38e8a50d646 100644
--- a/erpnext/quality_management/doctype/quality_review/quality_review.json
+++ b/erpnext/quality_management/doctype/quality_review/quality_review.json
@@ -84,7 +84,7 @@
"link_fieldname": "review"
}
],
- "modified": "2020-10-21 12:56:47.046172",
+ "modified": "2023-08-28 22:33:22.472980",
"modified_by": "Administrator",
"module": "Quality Management",
"name": "Quality Review",
@@ -110,7 +110,7 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "All",
+ "role": "Desk User",
"share": 1,
"write": 1
},
@@ -129,6 +129,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "goal",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 27163dbf2839..ba8bc339f38f 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -59,19 +59,27 @@ frappe.ui.form.on("Sales Order", {
child_docname: "items",
child_doctype: "Sales Order Detail",
cannot_add_row: false,
+ has_reserved_stock: frm.doc.__onload && frm.doc.__onload.has_reserved_stock
})
});
- // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock.
- if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) {
+ // Stock Reservation > Reserve button should only be visible if the SO has unreserved stock and no Pick List is created against the SO.
+ if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock && flt(frm.doc.per_picked) === 0) {
frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation'));
}
}
- // Stock Reservation > Unreserve button will be only visible if the SO has reserved stock.
+ // Stock Reservation > Unreserve button will be only visible if the SO has un-delivered reserved stock.
if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) {
frm.add_custom_button(__('Unreserve'), () => frm.events.cancel_stock_reservation_entries(frm), __('Stock Reservation'));
}
+
+ frm.doc.items.forEach(item => {
+ if (flt(item.stock_reserved_qty) > 0) {
+ frm.add_custom_button(__('Reserved Stock'), () => frm.events.show_reserved_stock(frm), __('Stock Reservation'));
+ return;
+ }
+ });
}
if (frm.doc.docstatus === 0) {
@@ -82,7 +90,7 @@ frappe.ui.form.on("Sales Order", {
if (frm.is_new()) {
frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => {
if (value) {
- frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission").then((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);
})
@@ -94,6 +102,11 @@ frappe.ui.form.on("Sales Order", {
})
}
}
+
+ // Hide `Reserve Stock` field description in submitted or cancelled Sales Order.
+ if (frm.doc.docstatus > 0) {
+ frm.set_df_property("reserve_stock", "description", null);
+ }
},
get_items_from_internal_purchase_order(frm) {
@@ -171,76 +184,115 @@ frappe.ui.form.on("Sales Order", {
},
create_stock_reservation_entries(frm) {
- let items_data = [];
-
- const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'),
+ const dialog = new frappe.ui.Dialog({
+ title: __("Stock Reservation"),
+ size: "large",
fields: [
{
- fieldtype: 'Data',
- fieldname: 'name',
- label: __('Name'),
- reqd: 1,
- read_only: 1,
- },
- {
- fieldtype: 'Link',
- fieldname: 'item_code',
- label: __('Item Code'),
- options: 'Item',
- reqd: 1,
- read_only: 1,
- in_list_view: 1,
- },
- {
- fieldtype: 'Link',
- fieldname: 'warehouse',
- label: __('Warehouse'),
- options: 'Warehouse',
- reqd: 1,
- in_list_view: 1,
- get_query: function () {
+ fieldname: "set_warehouse",
+ fieldtype: "Link",
+ label: __("Set Warehouse"),
+ options: "Warehouse",
+ default: frm.doc.set_warehouse,
+ get_query: () => {
return {
filters: [
["Warehouse", "is_group", "!=", 1]
]
};
},
+ onchange: () => {
+ if (dialog.get_value("set_warehouse")) {
+ dialog.fields_dict.items.df.data.forEach((row) => {
+ row.warehouse = dialog.get_value("set_warehouse");
+ });
+ dialog.fields_dict.items.grid.refresh();
+ }
+ },
},
+ {fieldtype: "Column Break"},
+ {fieldtype: "Section Break"},
{
- fieldtype: 'Float',
- fieldname: 'qty_to_reserve',
- label: __('Qty'),
- reqd: 1,
- in_list_view: 1
- }
+ fieldname: "items",
+ fieldtype: "Table",
+ label: __("Items to Reserve"),
+ allow_bulk_edit: false,
+ cannot_add_rows: true,
+ cannot_delete_rows: true,
+ data: [],
+ fields: [
+ {
+ fieldname: "name",
+ fieldtype: "Data",
+ label: __("Name"),
+ reqd: 1,
+ read_only: 1,
+ },
+ {
+ fieldname: "item_code",
+ fieldtype: "Link",
+ label: __("Item Code"),
+ options: "Item",
+ reqd: 1,
+ read_only: 1,
+ in_list_view: 1,
+ },
+ {
+ fieldname: "warehouse",
+ fieldtype: "Link",
+ label: __("Warehouse"),
+ options: "Warehouse",
+ reqd: 1,
+ in_list_view: 1,
+ get_query: () => {
+ return {
+ filters: [
+ ["Warehouse", "is_group", "!=", 1]
+ ]
+ };
+ },
+ },
+ {
+ fieldname: "qty_to_reserve",
+ fieldtype: "Float",
+ label: __("Qty"),
+ reqd: 1,
+ in_list_view: 1
+ }
+ ],
+ },
],
- data: items_data,
- in_place_edit: true,
- get_data: function() {
- return items_data;
- }
- }, function(data) {
- if (data.items.length > 0) {
- frappe.call({
- doc: frm.doc,
- method: 'create_stock_reservation_entries',
- args: {
- items_details: data.items,
- notify: true
- },
- freeze: true,
- freeze_message: __('Reserving Stock...'),
- callback: (r) => {
- frm.doc.__onload.has_unreserved_stock = false;
- frm.reload_doc();
- }
- });
- }
- }, __("Stock Reservation"), __("Reserve Stock"));
+ primary_action_label: __("Reserve Stock"),
+ primary_action: () => {
+ var data = {items: dialog.fields_dict.items.grid.get_selected_children()};
+
+ if (data.items && data.items.length > 0) {
+ frappe.call({
+ doc: frm.doc,
+ method: "create_stock_reservation_entries",
+ args: {
+ items_details: data.items,
+ notify: true
+ },
+ freeze: true,
+ freeze_message: __("Reserving Stock..."),
+ callback: (r) => {
+ frm.doc.__onload.has_unreserved_stock = false;
+ frm.reload_doc();
+ }
+ });
+ }
+ else {
+ frappe.msgprint(__("Please select items to reserve."));
+ }
+
+ dialog.hide();
+ },
+ });
frm.doc.items.forEach(item => {
if (item.reserve_stock) {
- let unreserved_qty = (flt(item.stock_qty) - (flt(item.delivered_qty) * flt(item.conversion_factor)) - flt(item.stock_reserved_qty))
+ let unreserved_qty = (flt(item.stock_qty) - (item.stock_reserved_qty ? flt(item.stock_reserved_qty) : (flt(item.delivered_qty) * flt(item.conversion_factor))))
if (unreserved_qty > 0) {
dialog.fields_dict.items.df.data.push({
@@ -254,22 +306,127 @@ frappe.ui.form.on("Sales Order", {
});
dialog.fields_dict.items.grid.refresh();
+ dialog.show();
},
cancel_stock_reservation_entries(frm) {
+ const dialog = new frappe.ui.Dialog({
+ title: __("Stock Unreservation"),
+ size: "large",
+ fields: [
+ {
+ fieldname: "sr_entries",
+ fieldtype: "Table",
+ label: __("Reserved Stock"),
+ allow_bulk_edit: false,
+ cannot_add_rows: true,
+ cannot_delete_rows: true,
+ in_place_edit: true,
+ data: [],
+ fields: [
+ {
+ fieldname: "name",
+ fieldtype: "Link",
+ label: __("SRE"),
+ options: "Stock Reservation Entry",
+ reqd: 1,
+ read_only: 1,
+ in_list_view: 1,
+ },
+ {
+ fieldname: "item_code",
+ fieldtype: "Link",
+ label: __("Item Code"),
+ options: "Item",
+ reqd: 1,
+ read_only: 1,
+ in_list_view: 1,
+ },
+ {
+ fieldname: "warehouse",
+ fieldtype: "Link",
+ label: __("Warehouse"),
+ options: "Warehouse",
+ reqd: 1,
+ read_only: 1,
+ in_list_view: 1,
+ },
+ {
+ fieldname: "qty",
+ fieldtype: "Float",
+ label: __("Qty"),
+ reqd: 1,
+ read_only: 1,
+ in_list_view: 1
+ }
+ ]
+ }
+ ],
+ primary_action_label: __("Unreserve Stock"),
+ primary_action: () => {
+ var data = {sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children()};
+
+ if (data.sr_entries && data.sr_entries.length > 0) {
+ frappe.call({
+ doc: frm.doc,
+ method: "cancel_stock_reservation_entries",
+ args: {
+ sre_list: data.sr_entries,
+ },
+ freeze: true,
+ freeze_message: __('Unreserving Stock...'),
+ callback: (r) => {
+ frm.doc.__onload.has_reserved_stock = false;
+ frm.reload_doc();
+ }
+ });
+ }
+ else {
+ frappe.msgprint(__("Please select items to unreserve."));
+ }
+
+ dialog.hide();
+ },
+ });
+
frappe.call({
- method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries',
+ method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.get_stock_reservation_entries_for_voucher',
args: {
voucher_type: frm.doctype,
- voucher_no: frm.docname
+ voucher_no: frm.docname,
},
- freeze: true,
- freeze_message: __('Unreserving Stock...'),
callback: (r) => {
- frm.doc.__onload.has_reserved_stock = false;
- frm.reload_doc();
+ if (!r.exc && r.message) {
+ r.message.forEach(sre => {
+ if (flt(sre.reserved_qty) > flt(sre.delivered_qty)) {
+ dialog.fields_dict.sr_entries.df.data.push({
+ 'name': sre.name,
+ 'item_code': sre.item_code,
+ 'warehouse': sre.warehouse,
+ 'qty': (flt(sre.reserved_qty) - flt(sre.delivered_qty))
+ });
+ }
+ });
+ }
}
- })
+ }).then(r => {
+ dialog.fields_dict.sr_entries.grid.refresh();
+ dialog.show();
+ });
+ },
+
+ show_reserved_stock(frm) {
+ // Get the latest modified date from the items table.
+ var to_date = moment(new Date(Math.max(...frm.doc.items.map(e => new Date(e.modified))))).format('YYYY-MM-DD');
+
+ frappe.route_options = {
+ company: frm.doc.company,
+ from_date: frm.doc.transaction_date,
+ to_date: to_date,
+ voucher_type: frm.doc.doctype,
+ voucher_no: frm.doc.name,
+ }
+ frappe.set_route("query-report", "Reserved Stock");
}
});
@@ -335,8 +492,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
}
- if (flt(doc.per_picked, 2) < 100 && flt(doc.per_delivered, 2) < 100) {
- this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create'));
+ if (!doc.__onload || !doc.__onload.has_reserved_stock) {
+ // Don't show the `Reserve` button if the Sales Order has Picked Items.
+ if (flt(doc.per_picked, 2) < 100 && flt(doc.per_delivered, 2) < 100) {
+ this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create'));
+ }
}
const order_is_a_sale = ["Sales", "Shopping Cart"].indexOf(doc.order_type) !== -1;
@@ -346,7 +506,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
// delivery note
if(flt(doc.per_delivered, 2) < 100 && (order_is_a_sale || order_is_a_custom_sale) && allow_delivery) {
- this.frm.add_custom_button(__('Delivery Note'), () => this.make_delivery_note_based_on_delivery_date(), __('Create'));
+ this.frm.add_custom_button(__('Delivery Note'), () => this.make_delivery_note_based_on_delivery_date(true), __('Create'));
this.frm.add_custom_button(__('Work Order'), () => this.make_work_order(), __('Create'));
}
@@ -639,7 +799,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
d.show();
}
- make_delivery_note_based_on_delivery_date() {
+ make_delivery_note_based_on_delivery_date(for_reserved_stock=false) {
var me = this;
var delivery_dates = this.frm.doc.items.map(i => i.delivery_date);
@@ -681,22 +841,25 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
if(!dates) return;
- me.make_delivery_note(dates);
+ me.make_delivery_note(dates, for_reserved_stock);
dialog.hide();
});
dialog.show();
} else {
- this.make_delivery_note();
+ this.make_delivery_note([], for_reserved_stock);
}
}
- make_delivery_note(delivery_dates) {
+ make_delivery_note(delivery_dates, for_reserved_stock=false) {
frappe.model.open_mapped_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
frm: this.frm,
args: {
- delivery_dates
- }
+ delivery_dates,
+ for_reserved_stock: for_reserved_stock
+ },
+ freeze: true,
+ freeze_message: __("Creating Delivery Note ...")
})
}
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index f65969e9938d..a74084d21fdd 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -1027,7 +1027,6 @@
"length": 240,
"oldfieldname": "in_words_export",
"oldfieldtype": "Data",
- "print_hide": 1,
"read_only": 1,
"width": "200px"
},
@@ -1635,6 +1634,7 @@
"description": "If checked, Stock Reservation Entries will be created on Submit",
"fieldname": "reserve_stock",
"fieldtype": "Check",
+ "hidden": 1,
"label": "Reserve Stock",
"no_copy": 1,
"print_hide": 1,
@@ -1645,7 +1645,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2023-06-03 16:16:23.411247",
+ "modified": "2023-07-24 08:59:11.599875",
"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 cc141ffa111d..aae0fee4673d 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -31,7 +31,6 @@
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- cancel_stock_reservation_entries,
get_sre_reserved_qty_details_for_voucher,
has_reserved_stock,
)
@@ -283,7 +282,7 @@ def on_cancel(self):
self.db_set("status", "Cancelled")
self.update_blanket_order()
- cancel_stock_reservation_entries("Sales Order", self.name)
+ self.cancel_stock_reservation_entries()
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_order_reference)
if self.coupon_code:
@@ -535,138 +534,26 @@ def has_unreserved_stock(self) -> bool:
return False
@frappe.whitelist()
- def create_stock_reservation_entries(self, items_details=None, notify=True):
+ def create_stock_reservation_entries(self, items_details=None, notify=True) -> None:
"""Creates Stock Reservation Entries for Sales Order Items."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- get_available_qty_to_reserve,
- validate_stock_reservation_settings,
+ create_stock_reservation_entries_for_so_items as create_stock_reservation_entries,
)
- validate_stock_reservation_settings(self)
+ create_stock_reservation_entries(so=self, items_details=items_details, notify=notify)
- allow_partial_reservation = frappe.db.get_single_value(
- "Stock Settings", "allow_partial_reservation"
- )
-
- items = []
- if items_details:
- for item in items_details:
- so_item = frappe.get_doc("Sales Order Item", item["name"])
- so_item.reserve_stock = 1
- so_item.warehouse = item["warehouse"]
- so_item.qty_to_reserve = flt(item["qty_to_reserve"]) * flt(so_item.conversion_factor)
- items.append(so_item)
-
- sre_count = 0
- reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name)
- for item in items or self.get("items"):
- # Skip if `Reserved Stock` is not checked for the item.
- if not item.get("reserve_stock"):
- continue
-
- # Skip if Non-Stock Item.
- if not frappe.get_cached_value("Item", item.item_code, "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",
- )
- item.db_set("reserve_stock", 0)
- continue
-
- # Skip if Group Warehouse.
- if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
- frappe.msgprint(
- _("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format(
- item.idx, frappe.bold(item.warehouse)
- ),
- title=_("Stock Reservation"),
- indicator="yellow",
- )
- continue
-
- unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
-
- # 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",
- )
- continue
-
- available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse)
-
- # 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(
- item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse)
- ),
- title=_("Stock Reservation"),
- indicator="orange",
- )
- continue
-
- # The quantity which can be reserved.
- qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve)
-
- if hasattr(item, "qty_to_reserve"):
- if item.qty_to_reserve <= 0:
- frappe.msgprint(
- _("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format(
- item.idx, frappe.bold(item.item_code)
- ),
- title=_("Stock Reservation"),
- indicator="orange",
- )
- continue
- else:
- qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve)
-
- # 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")):
- frappe.msgprint(
- _("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),
- frappe.bold(item.item_code),
- ),
- title=_("Stock Reservation"),
- indicator="orange",
- )
-
- # Skip the item if `Partial Reservation` is disabled in the Stock Settings.
- if not allow_partial_reservation:
- continue
-
- # Create and Submit Stock Reservation Entry
- sre = frappe.new_doc("Stock Reservation Entry")
- sre.item_code = item.item_code
- sre.warehouse = item.warehouse
- sre.voucher_type = self.doctype
- sre.voucher_no = self.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 = self.company
- sre.stock_uom = item.stock_uom
- sre.project = self.project
- sre.save()
- sre.submit()
+ @frappe.whitelist()
+ def cancel_stock_reservation_entries(self, sre_list=None, notify=True) -> None:
+ """Cancel Stock Reservation Entries for Sales Order Items."""
- sre_count += 1
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ )
- if sre_count and notify:
- frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green")
+ cancel_stock_reservation_entries(
+ voucher_type=self.doctype, voucher_no=self.name, sre_list=sre_list, notify=notify
+ )
def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float:
@@ -813,8 +700,31 @@ def postprocess(source, doc):
@frappe.whitelist()
-def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
+def make_delivery_note(source_name, target_doc=None, kwargs=None):
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_details_for_voucher,
+ get_sre_reserved_qty_details_for_voucher,
+ get_ssb_bundle_for_voucher,
+ )
+
+ if not kwargs:
+ kwargs = {
+ "for_reserved_stock": frappe.flags.args and frappe.flags.args.for_reserved_stock,
+ "skip_item_mapping": frappe.flags.args and frappe.flags.args.skip_item_mapping,
+ }
+
+ kwargs = frappe._dict(kwargs)
+
+ sre_details = {}
+ if kwargs.for_reserved_stock:
+ sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name)
+
+ mapper = {
+ "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
+ "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
+ }
def set_missing_values(source, target):
target.run_method("set_missing_values")
@@ -832,6 +742,18 @@ def set_missing_values(source, target):
make_packing_list(target)
+ def condition(doc):
+ if doc.name in sre_details:
+ del sre_details[doc.name]
+ return False
+
+ # make_mapped_doc sets js `args` into `frappe.flags.args`
+ if frappe.flags.args and frappe.flags.args.delivery_dates:
+ if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates:
+ return False
+
+ return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1
+
def update_item(source, target, source_parent):
target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
@@ -847,21 +769,7 @@ def update_item(source, target, source_parent):
or item_group.get("buying_cost_center")
)
- mapper = {
- "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
- "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
- }
-
- if not skip_item_mapping:
-
- def condition(doc):
- # make_mapped_doc sets js `args` into `frappe.flags.args`
- if frappe.flags.args and frappe.flags.args.delivery_dates:
- if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates:
- return False
- return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1
-
+ if not kwargs.skip_item_mapping:
mapper["Sales Order Item"] = {
"doctype": "Delivery Note Item",
"field_map": {
@@ -869,11 +777,56 @@ def condition(doc):
"name": "so_detail",
"parent": "against_sales_order",
},
- "postprocess": update_item,
"condition": condition,
+ "postprocess": update_item,
}
- target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values)
+ so = frappe.get_doc("Sales Order", source_name)
+ target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc)
+
+ if not kwargs.skip_item_mapping and kwargs.for_reserved_stock:
+ sre_list = get_sre_details_for_voucher("Sales Order", source_name)
+
+ if sre_list:
+
+ def update_dn_item(source, target, source_parent):
+ update_item(source, target, so)
+
+ so_items = {d.name: d for d in so.items if d.stock_reserved_qty}
+
+ for sre in sre_list:
+ if not condition(so_items[sre.voucher_detail_no]):
+ continue
+
+ dn_item = get_mapped_doc(
+ "Sales Order Item",
+ sre.voucher_detail_no,
+ {
+ "Sales Order Item": {
+ "doctype": "Delivery Note Item",
+ "field_map": {
+ "rate": "rate",
+ "name": "so_detail",
+ "parent": "against_sales_order",
+ },
+ "postprocess": update_dn_item,
+ }
+ },
+ )
+
+ dn_item.qty = flt(sre.reserved_qty) * flt(dn_item.get("conversion_factor", 1))
+
+ if sre.reservation_based_on == "Serial and Batch" and (sre.has_serial_no or sre.has_batch_no):
+ dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre)
+
+ target_doc.append("items", dn_item)
+ else:
+ # Correct rows index.
+ for idx, item in enumerate(target_doc.items):
+ item.idx = idx + 1
+
+ # Should be called after mapping items.
+ set_missing_values(so, target_doc)
target_doc.set_onload("ignore_price_list", True)
return target_doc
@@ -1436,6 +1389,16 @@ def make_inter_company_purchase_order(source_name, target_doc=None):
def create_pick_list(source_name, target_doc=None):
from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
+ def validate_sales_order():
+ so = frappe.get_doc("Sales Order", source_name)
+ for item in so.items:
+ if item.stock_reserved_qty > 0:
+ frappe.throw(
+ _(
+ "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list."
+ ).format(frappe.bold(source_name))
+ )
+
def update_item_quantity(source, target, source_parent) -> None:
picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1)
qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty))
@@ -1459,6 +1422,9 @@ def should_pick_order_item(item) -> bool:
and not is_product_bundle(item.item_code)
)
+ # Don't allow a Pick List to be created against a Sales Order that has reserved stock.
+ validate_sales_order()
+
doc = get_mapped_doc(
"Sales Order",
source_name,
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 954393f5730c..ed270bec50bd 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -1789,147 +1789,6 @@ def test_sales_order_partial_advance_payment(self):
self.assertEqual(pe.references[1].reference_name, so.name)
self.assertEqual(pe.references[1].allocated_amount, 300)
- @change_settings(
- "Stock Settings",
- {
- "enable_stock_reservation": 1,
- "auto_create_serial_and_batch_bundle_for_outward": 1,
- "pick_serial_and_batch_based_on": "FIFO",
- },
- )
- def test_stock_reservation_against_sales_order(self) -> None:
- from random import randint, uniform
-
- from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- cancel_stock_reservation_entries,
- get_sre_reserved_qty_details_for_voucher,
- get_stock_reservation_entries_for_voucher,
- has_reserved_stock,
- )
- from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import (
- create_items,
- create_material_receipt,
- )
-
- items_details, warehouse = create_items(), "_Test Warehouse - _TC"
- se = create_material_receipt(items_details, warehouse, qty=10)
-
- item_list = []
- for item_code, properties in items_details.items():
- stock_uom = properties.stock_uom
- item_list.append(
- {
- "item_code": item_code,
- "warehouse": warehouse,
- "qty": flt(uniform(11, 100), 0 if stock_uom == "Nos" else 3),
- "uom": stock_uom,
- "rate": randint(10, 200),
- }
- )
-
- so = make_sales_order(
- item_list=item_list,
- warehouse="_Test Warehouse - _TC",
- )
-
- # Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings.
- with change_settings("Stock Settings", {"allow_partial_reservation": 0}):
- so.create_stock_reservation_entries()
- self.assertFalse(has_reserved_stock("Sales Order", so.name))
-
- # Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings.
- with change_settings("Stock Settings", {"allow_partial_reservation": 1}):
- so.create_stock_reservation_entries()
- so.load_from_db()
- self.assertTrue(has_reserved_stock("Sales Order", so.name))
-
- for item in so.items:
- sre_details = get_stock_reservation_entries_for_voucher(
- "Sales Order", so.name, item.name, fields=["reserved_qty", "status"]
- )
- self.assertEqual(item.stock_reserved_qty, sre_details[0].reserved_qty)
- self.assertEqual(sre_details[0].status, "Partially Reserved")
-
- se.cancel()
-
- # Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty.
- create_material_receipt(items_details, warehouse, qty=110)
- so.create_stock_reservation_entries()
- so.load_from_db()
-
- reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
- for item in so.items:
- reserved_qty = reserved_qty_details[item.name]
- self.assertEqual(item.stock_reserved_qty, reserved_qty)
- self.assertEqual(item.stock_qty, item.stock_reserved_qty)
-
- # Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries.
- cancel_stock_reservation_entries("Sales Order", so.name)
- so.load_from_db()
- self.assertFalse(has_reserved_stock("Sales Order", so.name))
-
- for item in so.items:
- self.assertEqual(item.stock_reserved_qty, 0)
-
- # Test - 5: Re-reserve the stock.
- so.create_stock_reservation_entries()
- self.assertTrue(has_reserved_stock("Sales Order", so.name))
-
- # Test - 6: Stock should get unreserved on cancellation of Sales Order.
- so.cancel()
- so.load_from_db()
- self.assertFalse(has_reserved_stock("Sales Order", so.name))
-
- for item in so.items:
- self.assertEqual(item.stock_reserved_qty, 0)
-
- # Create Sales Order and Reserve Stock.
- so = make_sales_order(
- item_list=item_list,
- warehouse="_Test Warehouse - _TC",
- )
- so.create_stock_reservation_entries()
-
- # Test - 7: Partial Delivery against Sales Order.
- dn1 = make_delivery_note(so.name)
-
- for item in dn1.items:
- item.qty = flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
-
- dn1.save()
- dn1.submit()
-
- for item in so.items:
- sre_details = get_stock_reservation_entries_for_voucher(
- "Sales Order", so.name, item.name, fields=["delivered_qty", "status"]
- )
- self.assertGreater(sre_details[0].delivered_qty, 0)
- self.assertEqual(sre_details[0].status, "Partially Delivered")
-
- # Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty.
- with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}):
- dn2 = make_delivery_note(so.name)
-
- for item in dn2.items:
- item.qty += flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3)
-
- dn2.save()
- dn2.submit()
-
- for item in so.items:
- sre_details = frappe.db.get_all(
- "Stock Reservation Entry",
- filters={
- "voucher_type": "Sales Order",
- "voucher_no": so.name,
- "voucher_detail_no": item.name,
- },
- fields=["reserved_qty", "delivered_qty"],
- )
-
- for sre_detail in sre_details:
- self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
-
def test_delivered_item_material_request(self):
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
from erpnext.manufacturing.doctype.work_order.work_order import (
diff --git a/erpnext/setup/doctype/department/department.json b/erpnext/setup/doctype/department/department.json
index 5a16bae0f2e6..99deca5c19d4 100644
--- a/erpnext/setup/doctype/department/department.json
+++ b/erpnext/setup/doctype/department/department.json
@@ -25,18 +25,15 @@
"label": "Department",
"oldfieldname": "department_name",
"oldfieldtype": "Data",
- "reqd": 1,
- "show_days": 1,
- "show_seconds": 1
+ "reqd": 1
},
{
"fieldname": "parent_department",
"fieldtype": "Link",
+ "ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Parent Department",
- "options": "Department",
- "show_days": 1,
- "show_seconds": 1
+ "options": "Department"
},
{
"fieldname": "company",
@@ -44,9 +41,7 @@
"in_standard_filter": 1,
"label": "Company",
"options": "Company",
- "reqd": 1,
- "show_days": 1,
- "show_seconds": 1
+ "reqd": 1
},
{
"bold": 1,
@@ -54,17 +49,13 @@
"fieldname": "is_group",
"fieldtype": "Check",
"in_list_view": 1,
- "label": "Is Group",
- "show_days": 1,
- "show_seconds": 1
+ "label": "Is Group"
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
- "label": "Disabled",
- "show_days": 1,
- "show_seconds": 1
+ "label": "Disabled"
},
{
"fieldname": "lft",
@@ -72,9 +63,7 @@
"hidden": 1,
"label": "lft",
"print_hide": 1,
- "read_only": 1,
- "show_days": 1,
- "show_seconds": 1
+ "read_only": 1
},
{
"fieldname": "rgt",
@@ -82,9 +71,7 @@
"hidden": 1,
"label": "rgt",
"print_hide": 1,
- "read_only": 1,
- "show_days": 1,
- "show_seconds": 1
+ "read_only": 1
},
{
"fieldname": "old_parent",
@@ -92,22 +79,18 @@
"hidden": 1,
"ignore_user_permissions": 1,
"label": "Old Parent",
- "print_hide": 1,
- "show_days": 1,
- "show_seconds": 1
+ "print_hide": 1
},
{
"fieldname": "column_break_3",
- "fieldtype": "Column Break",
- "show_days": 1,
- "show_seconds": 1
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-sitemap",
"idx": 1,
"is_tree": 1,
"links": [],
- "modified": "2020-06-10 12:28:00.563272",
+ "modified": "2023-08-28 17:26:46.826501",
"modified_by": "Administrator",
"module": "Setup",
"name": "Department",
@@ -147,12 +130,12 @@
"read": 1,
"report": 1,
"role": "HR Manager",
- "set_user_permissions": 1,
"share": 1,
"write": 1
}
],
"show_name_in_global_search": 1,
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/setup/doctype/item_group/item_group.json b/erpnext/setup/doctype/item_group/item_group.json
index 2986087277c8..e0f509047498 100644
--- a/erpnext/setup/doctype/item_group/item_group.json
+++ b/erpnext/setup/doctype/item_group/item_group.json
@@ -233,7 +233,7 @@
"is_tree": 1,
"links": [],
"max_attachments": 3,
- "modified": "2023-01-05 12:21:30.458628",
+ "modified": "2023-08-28 22:27:48.382985",
"modified_by": "Administrator",
"module": "Setup",
"name": "Item Group",
@@ -266,7 +266,6 @@
"read": 1,
"report": 1,
"role": "Item Manager",
- "set_user_permissions": 1,
"share": 1,
"write": 1
},
@@ -296,7 +295,7 @@
"export": 1,
"print": 1,
"report": 1,
- "role": "All",
+ "role": "Desk User",
"select": 1,
"share": 1
}
diff --git a/erpnext/setup/doctype/print_heading/print_heading.json b/erpnext/setup/doctype/print_heading/print_heading.json
index dc07f0c8d83d..1083583037b4 100644
--- a/erpnext/setup/doctype/print_heading/print_heading.json
+++ b/erpnext/setup/doctype/print_heading/print_heading.json
@@ -1,131 +1,68 @@
{
- "allow_copy": 0,
- "allow_import": 1,
- "allow_rename": 1,
- "autoname": "field:print_heading",
- "beta": 0,
- "creation": "2013-01-10 16:34:24",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "Setup",
- "editable_grid": 0,
+ "actions": [],
+ "allow_import": 1,
+ "allow_rename": 1,
+ "autoname": "field:print_heading",
+ "creation": "2013-01-10 16:34:24",
+ "doctype": "DocType",
+ "document_type": "Setup",
+ "engine": "InnoDB",
+ "field_order": [
+ "print_heading",
+ "description"
+ ],
"fields": [
{
- "allow_on_submit": 1,
- "bold": 0,
- "collapsible": 0,
- "fieldname": "print_heading",
- "fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 1,
- "in_list_view": 1,
- "label": "Print Heading",
- "length": 0,
- "no_copy": 0,
- "oldfieldname": "print_heading",
- "oldfieldtype": "Data",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
- },
+ "allow_on_submit": 1,
+ "fieldname": "print_heading",
+ "fieldtype": "Data",
+ "in_filter": 1,
+ "in_list_view": 1,
+ "label": "Print Heading",
+ "oldfieldname": "print_heading",
+ "oldfieldtype": "Data",
+ "reqd": 1,
+ "unique": 1
+ },
{
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "fieldname": "description",
- "fieldtype": "Small Text",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_list_view": 1,
- "label": "Description",
- "length": 0,
- "no_copy": 0,
- "oldfieldname": "description",
- "oldfieldtype": "Small Text",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0,
+ "fieldname": "description",
+ "fieldtype": "Small Text",
+ "in_list_view": 1,
+ "label": "Description",
+ "oldfieldname": "description",
+ "oldfieldtype": "Small Text",
"width": "300px"
}
- ],
- "hide_heading": 0,
- "hide_toolbar": 0,
- "icon": "fa fa-font",
- "idx": 1,
- "image_view": 0,
- "in_create": 0,
-
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2016-07-25 05:24:25.628101",
- "modified_by": "Administrator",
- "module": "Setup",
- "name": "Print Heading",
- "owner": "Administrator",
+ ],
+ "icon": "fa fa-font",
+ "idx": 1,
+ "links": [],
+ "modified": "2023-08-28 22:17:42.041255",
+ "modified_by": "Administrator",
+ "module": "Setup",
+ "name": "Print Heading",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
"write": 1
- },
+ },
{
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 0,
- "delete": 0,
- "email": 0,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 0,
- "read": 1,
- "report": 0,
- "role": "All",
- "set_user_permissions": 0,
- "share": 0,
- "submit": 0,
- "write": 0
+ "read": 1,
+ "role": "Desk User"
}
- ],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "search_fields": "print_heading",
- "track_seen": 0
+ ],
+ "quick_entry": 1,
+ "search_fields": "print_heading",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/stock/dashboard/item_dashboard.py b/erpnext/stock/dashboard/item_dashboard.py
index 8fbb56ced0fa..62bd61f19e3a 100644
--- a/erpnext/stock/dashboard/item_dashboard.py
+++ b/erpnext/stock/dashboard/item_dashboard.py
@@ -2,6 +2,10 @@
from frappe.model.db_query import DatabaseQuery
from frappe.utils import cint, flt
+from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock,
+)
+
@frappe.whitelist()
def get_data(
@@ -57,6 +61,7 @@ def get_data(
limit_page_length=21,
)
+ sre_reserved_stock_details = get_reserved_stock(item_code, warehouse)
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
for item in items:
@@ -70,6 +75,7 @@ def get_data(
"reserved_qty_for_production": flt(item.reserved_qty_for_production, precision),
"reserved_qty_for_sub_contract": flt(item.reserved_qty_for_sub_contract, precision),
"actual_qty": flt(item.actual_qty, precision),
+ "reserved_stock": sre_reserved_stock_details.get((item.item_code, item.warehouse), 0),
}
)
return items
diff --git a/erpnext/stock/dashboard/item_dashboard_list.html b/erpnext/stock/dashboard/item_dashboard_list.html
index 0c10be462a11..3b2619133bf4 100644
--- a/erpnext/stock/dashboard/item_dashboard_list.html
+++ b/erpnext/stock/dashboard/item_dashboard_list.html
@@ -12,7 +12,10 @@
{% endif %}
-
+
+
{{ d.total_reserved }}
diff --git a/erpnext/stock/doctype/batch/batch.js b/erpnext/stock/doctype/batch/batch.js
index 3b07e4e80c15..7bf7a1f65df0 100644
--- a/erpnext/stock/doctype/batch/batch.js
+++ b/erpnext/stock/doctype/batch/batch.js
@@ -41,7 +41,7 @@ frappe.ui.form.on('Batch', {
if(!frm.is_new()) {
frappe.call({
method: 'erpnext.stock.doctype.batch.batch.get_batch_qty',
- args: {batch_no: frm.doc.name},
+ args: {batch_no: frm.doc.name, item_code: frm.doc.item},
callback: (r) => {
if(!r.message) {
return;
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js
index 7ef1c9ba5876..ec68549846fe 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.js
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.js
@@ -150,6 +150,9 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends erpn
}
erpnext.utils.map_current_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
+ args: {
+ for_reserved_stock: 1
+ },
source_doctype: "Sales Order",
target: me.frm,
setters: {
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index ea20a264674b..190575eb94ee 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -279,6 +279,8 @@ def on_cancel(self):
self.update_prevdoc_status()
self.update_billing_status()
+ self.update_stock_reservation_entries()
+
# Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO
self.update_stock_ledger()
@@ -297,55 +299,141 @@ def on_cancel(self):
def update_stock_reservation_entries(self) -> None:
"""Updates Delivered Qty in Stock Reservation Entries."""
- # Don't update Delivered Qty on Return or Cancellation.
- if self.is_return or self._action == "cancel":
+ # Don't update Delivered Qty on Return.
+ if self.is_return:
return
- for item in self.get("items"):
- # Skip if `Sales Order` or `Sales Order Item` reference is not set.
- if not item.against_sales_order or not item.so_detail:
- continue
-
- sre_list = frappe.db.get_all(
- "Stock Reservation Entry",
- {
- "docstatus": 1,
- "voucher_type": "Sales Order",
- "voucher_no": item.against_sales_order,
- "voucher_detail_no": item.so_detail,
- "warehouse": item.warehouse,
- "status": ["not in", ["Delivered", "Cancelled"]],
- },
- order_by="creation",
- )
-
- # Skip if no Stock Reservation Entries.
- if not sre_list:
- continue
+ if self._action == "submit":
+ for item in self.get("items"):
+ # Skip if `Sales Order` or `Sales Order Item` reference is not set.
+ if not item.against_sales_order or not item.so_detail:
+ continue
- available_qty_to_deliver = item.stock_qty
- for sre in sre_list:
- if available_qty_to_deliver <= 0:
- break
-
- sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
-
- # `Delivered Qty` should be less than or equal to `Reserved Qty`.
- qty_to_be_deliver = min(sre_doc.reserved_qty - sre_doc.delivered_qty, available_qty_to_deliver)
-
- sre_doc.delivered_qty += qty_to_be_deliver
- sre_doc.db_update()
+ sre_list = frappe.db.get_all(
+ "Stock Reservation Entry",
+ {
+ "docstatus": 1,
+ "voucher_type": "Sales Order",
+ "voucher_no": item.against_sales_order,
+ "voucher_detail_no": item.so_detail,
+ "warehouse": item.warehouse,
+ "status": ["not in", ["Delivered", "Cancelled"]],
+ },
+ order_by="creation",
+ )
- # Update Stock Reservation Entry `Status` based on `Delivered Qty`.
- sre_doc.update_status()
+ # Skip if no Stock Reservation Entries.
+ if not sre_list:
+ continue
+
+ qty_to_deliver = item.stock_qty
+ for sre in sre_list:
+ if qty_to_deliver <= 0:
+ break
+
+ sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
+
+ qty_can_be_deliver = 0
+ if sre_doc.reservation_based_on == "Serial and Batch":
+ sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
+ if sre_doc.has_serial_no:
+ delivered_serial_nos = [d.serial_no for d in sbb.entries]
+ for entry in sre_doc.sb_entries:
+ if entry.serial_no in delivered_serial_nos:
+ entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No.
+ entry.db_update()
+ qty_can_be_deliver += 1
+ delivered_serial_nos.remove(entry.serial_no)
+ else:
+ delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries}
+ for entry in sre_doc.sb_entries:
+ if entry.batch_no in delivered_batch_qty:
+ delivered_qty = min(
+ (entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no]
+ )
+ entry.delivered_qty += delivered_qty
+ entry.db_update()
+ qty_can_be_deliver += delivered_qty
+ delivered_batch_qty[entry.batch_no] -= delivered_qty
+ else:
+ # `Delivered Qty` should be less than or equal to `Reserved Qty`.
+ qty_can_be_deliver = min((sre_doc.reserved_qty - sre_doc.delivered_qty), qty_to_deliver)
+
+ sre_doc.delivered_qty += qty_can_be_deliver
+ sre_doc.db_update()
+
+ # Update Stock Reservation Entry `Status` based on `Delivered Qty`.
+ sre_doc.update_status()
+
+ qty_to_deliver -= qty_can_be_deliver
+
+ if self._action == "cancel":
+ for item in self.get("items"):
+ # Skip if `Sales Order` or `Sales Order Item` reference is not set.
+ if not item.against_sales_order or not item.so_detail:
+ continue
+
+ sre_list = frappe.db.get_all(
+ "Stock Reservation Entry",
+ {
+ "docstatus": 1,
+ "voucher_type": "Sales Order",
+ "voucher_no": item.against_sales_order,
+ "voucher_detail_no": item.so_detail,
+ "warehouse": item.warehouse,
+ "status": ["in", ["Partially Delivered", "Delivered"]],
+ },
+ order_by="creation",
+ )
- available_qty_to_deliver -= qty_to_be_deliver
+ # Skip if no Stock Reservation Entries.
+ if not sre_list:
+ continue
+
+ qty_to_undelivered = item.stock_qty
+ for sre in sre_list:
+ if qty_to_undelivered <= 0:
+ break
+
+ sre_doc = frappe.get_doc("Stock Reservation Entry", sre)
+
+ qty_can_be_undelivered = 0
+ if sre_doc.reservation_based_on == "Serial and Batch":
+ sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
+ if sre_doc.has_serial_no:
+ serial_nos_to_undelivered = [d.serial_no for d in sbb.entries]
+ for entry in sre_doc.sb_entries:
+ if entry.serial_no in serial_nos_to_undelivered:
+ entry.delivered_qty = 0 # Qty will always be 0 or 1 for Serial No.
+ entry.db_update()
+ qty_can_be_undelivered += 1
+ serial_nos_to_undelivered.remove(entry.serial_no)
+ else:
+ batch_qty_to_undelivered = {d.batch_no: -1 * d.qty for d in sbb.entries}
+ for entry in sre_doc.sb_entries:
+ if entry.batch_no in batch_qty_to_undelivered:
+ undelivered_qty = min(entry.delivered_qty, batch_qty_to_undelivered[entry.batch_no])
+ entry.delivered_qty -= undelivered_qty
+ entry.db_update()
+ qty_can_be_undelivered += undelivered_qty
+ batch_qty_to_undelivered[entry.batch_no] -= undelivered_qty
+ else:
+ # `Qty to Undelivered` should be less than or equal to `Delivered Qty`.
+ qty_can_be_undelivered = min(sre_doc.delivered_qty, qty_to_undelivered)
+
+ sre_doc.delivered_qty -= qty_can_be_undelivered
+ sre_doc.db_update()
+
+ # Update Stock Reservation Entry `Status` based on `Delivered Qty`.
+ sre_doc.update_status()
+
+ qty_to_undelivered -= qty_can_be_undelivered
def validate_against_stock_reservation_entries(self):
"""Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- get_sre_reserved_qty_details_for_voucher_detail_no,
+ get_sre_reserved_warehouses_for_voucher,
)
# Don't validate if Return
@@ -357,26 +445,30 @@ def validate_against_stock_reservation_entries(self):
if not item.against_sales_order or not item.so_detail:
continue
- sre_data = get_sre_reserved_qty_details_for_voucher_detail_no(
+ reserved_warehouses = get_sre_reserved_warehouses_for_voucher(
"Sales Order", item.against_sales_order, item.so_detail
)
# Skip if stock is not reserved.
- if not sre_data:
+ if not reserved_warehouses:
continue
# Set `Warehouse` from SRE if not set.
if not item.warehouse:
- item.warehouse = sre_data[0]
+ item.warehouse = reserved_warehouses[0]
else:
- # Throw if `Warehouse` is different from SRE.
- if item.warehouse != sre_data[0]:
- frappe.throw(
- _("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format(
- item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0])
+ # Throw if `Warehouse` not in Reserved Warehouses.
+ if item.warehouse not in reserved_warehouses:
+ msg = _("Row #{0}: Stock is reserved for item {1} in warehouse {2}.").format(
+ item.idx,
+ frappe.bold(item.item_code),
+ frappe.bold(reserved_warehouses[0])
+ if len(reserved_warehouses) == 1
+ else _("{0} and {1}").format(
+ frappe.bold(", ".join(reserved_warehouses[:-1])), frappe.bold(reserved_warehouses[-1])
),
- title=_("Stock Reservation Warehouse Mismatch"),
)
+ frappe.throw(msg, title=_("Stock Reservation Warehouse Mismatch"))
def check_credit_limit(self):
from erpnext.selling.doctype.customer.customer import check_credit_limit
diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 31a3ecbc47ec..76e8866ece6e 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -3,6 +3,9 @@
frappe.provide("erpnext.item");
+const SALES_DOCTYPES = ['Quotation', 'Sales Order', 'Delivery Note', 'Sales Invoice'];
+const PURCHASE_DOCTYPES = ['Purchase Order', 'Purchase Receipt', 'Purchase Invoice'];
+
frappe.ui.form.on("Item", {
setup: function(frm) {
frm.add_fetch('attribute', 'numeric_values', 'numeric_values');
@@ -888,7 +891,13 @@ function open_form(frm, doctype, child_doctype, parentfield) {
let new_child_doc = frappe.model.add_child(new_doc, child_doctype, parentfield);
new_child_doc.item_code = frm.doc.name;
new_child_doc.item_name = frm.doc.item_name;
- new_child_doc.uom = frm.doc.stock_uom;
+ if (in_list(SALES_DOCTYPES, doctype) && frm.doc.sales_uom) {
+ new_child_doc.uom = frm.doc.sales_uom;
+ } else if (in_list(PURCHASE_DOCTYPES, doctype) && frm.doc.purchase_uom) {
+ new_child_doc.uom = frm.doc.purchase_uom;
+ } else {
+ new_child_doc.uom = frm.doc.stock_uom;
+ }
new_child_doc.description = frm.doc.description;
if (!new_child_doc.qty) {
new_child_doc.qty = 1.0;
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index 87c2a7ea691b..756d0040f1ea 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -912,7 +912,7 @@
"index_web_pages_for_search": 1,
"links": [],
"make_attachments_public": 1,
- "modified": "2023-07-14 17:18:18.658942",
+ "modified": "2023-08-28 22:16:40.305094",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
@@ -971,7 +971,7 @@
"export": 1,
"print": 1,
"report": 1,
- "role": "All",
+ "role": "Desk User",
"select": 1,
"share": 1
}
diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index 35c35a6f0795..4eed285fdab4 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -115,6 +115,22 @@ frappe.ui.form.on('Pick List', {
frm.add_custom_button(__('Stock Entry'), () => frm.trigger('create_stock_entry'), __('Create'));
}
});
+
+ if (frm.doc.purpose === 'Delivery' && frm.doc.status === 'Open') {
+ if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) {
+ frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation'));
+ }
+
+ if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) {
+ frm.add_custom_button(__('Unreserve'), () => {
+ frappe.confirm(
+ __('The reserved stock will be released. Are you certain you wish to proceed?'),
+ () => frm.events.cancel_stock_reservation_entries(frm)
+ )
+ }, __('Stock Reservation'));
+ frm.add_custom_button(__('Reserved Stock'), () => frm.events.show_reserved_stock(frm), __('Stock Reservation'));
+ }
+ }
}
},
work_order: (frm) => {
@@ -209,6 +225,49 @@ frappe.ui.form.on('Pick List', {
};
const barcode_scanner = new erpnext.utils.BarcodeScanner(opts);
barcode_scanner.process_scan();
+ },
+ create_stock_reservation_entries: (frm) => {
+ frappe.call({
+ doc: frm.doc,
+ method: "create_stock_reservation_entries",
+ args: {
+ notify: true
+ },
+ freeze: true,
+ freeze_message: __("Reserving Stock..."),
+ callback: (r) => {
+ frm.doc.__onload.has_unreserved_stock = false;
+ frm.reload_doc();
+ }
+ });
+ },
+ cancel_stock_reservation_entries: (frm) => {
+ frappe.call({
+ doc: frm.doc,
+ method: "cancel_stock_reservation_entries",
+ args: {
+ notify: true
+ },
+ freeze: true,
+ freeze_message: __('Unreserving Stock...'),
+ callback: (r) => {
+ frm.doc.__onload.has_reserved_stock = false;
+ frm.reload_doc();
+ }
+ });
+ },
+ show_reserved_stock(frm) {
+ // Get the latest modified date from the locations table.
+ var to_date = moment(new Date(Math.max(...frm.doc.locations.map(e => new Date(e.modified))))).format('YYYY-MM-DD');
+
+ frappe.route_options = {
+ company: frm.doc.company,
+ from_date: moment(frm.doc.creation).format('YYYY-MM-DD'),
+ to_date: to_date,
+ voucher_type: "Sales Order",
+ against_pick_list: 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 922f76cff2e0..2fcd1025a0e4 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -29,6 +29,14 @@
class PickList(Document):
+ def onload(self) -> None:
+ if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"):
+ if self.has_unreserved_stock():
+ self.set_onload("has_unreserved_stock", True)
+
+ if self.has_reserved_stock():
+ self.set_onload("has_reserved_stock", True)
+
def validate(self):
self.validate_for_qty()
@@ -47,8 +55,28 @@ def before_save(self):
)
def before_submit(self):
+ self.validate_sales_order()
self.validate_picked_items()
+ def validate_sales_order(self):
+ """Raises an exception if the `Sales Order` has reserved stock."""
+
+ if self.purpose != "Delivery":
+ return
+
+ so_list = set(location.sales_order for location in self.locations if location.sales_order)
+
+ if so_list:
+ for so in so_list:
+ so_doc = frappe.get_doc("Sales Order", so)
+ for item in so_doc.items:
+ if item.stock_reserved_qty > 0:
+ frappe.throw(
+ _(
+ "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list."
+ ).format(frappe.bold(so))
+ )
+
def validate_picked_items(self):
for item in self.locations:
if self.scan_mode and item.picked_qty < item.stock_qty:
@@ -70,8 +98,19 @@ def on_submit(self):
self.update_reference_qty()
self.update_sales_order_picking_status()
+ def on_update_after_submit(self) -> None:
+ if self.has_reserved_stock():
+ msg = _(
+ "The Pick List having Stock Reservation Entries cannot be updated. If you need to make changes, we recommend canceling the existing Stock Reservation Entries before updating the Pick List."
+ )
+ frappe.throw(msg)
+
def on_cancel(self):
- self.ignore_linked_doctypes = "Serial and Batch Bundle"
+ self.ignore_linked_doctypes = [
+ "Serial and Batch Bundle",
+ "Stock Reservation Entry",
+ "Delivery Note",
+ ]
self.update_status()
self.update_bundle_picked_qty()
@@ -186,6 +225,36 @@ def update_sales_order_picking_status(self) -> None:
for sales_order in sales_orders:
frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status()
+ @frappe.whitelist()
+ 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 = {}
+ 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)
+
+ if so_details:
+ for so, locations in so_details.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
+ )
+
+ @frappe.whitelist()
+ def cancel_stock_reservation_entries(self, notify=True) -> None:
+ """Cancel Stock Reservation Entries for Sales Order Items created against Pick List."""
+
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ )
+
+ cancel_stock_reservation_entries(against_pick_list=self.name, notify=notify)
+
def validate_picked_qty(self, data):
over_delivery_receipt_allowance = 100 + flt(
frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance")
@@ -448,6 +517,26 @@ def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int:
possible_bundles.append(0)
return int(flt(min(possible_bundles), precision or 6))
+ def has_unreserved_stock(self):
+ if self.purpose == "Delivery":
+ for location in self.locations:
+ if (
+ location.sales_order
+ and location.sales_order_item
+ and (flt(location.picked_qty) - flt(location.stock_reserved_qty)) > 0
+ ):
+ return True
+
+ return False
+
+ def has_reserved_stock(self):
+ if self.purpose == "Delivery":
+ for location in self.locations:
+ if location.sales_order and location.sales_order_item and flt(location.stock_reserved_qty) > 0:
+ return True
+
+ return False
+
def update_pick_list_status(pick_list):
if pick_list:
@@ -781,7 +870,8 @@ def create_dn_with_so(sales_dict, pick_list):
for customer in sales_dict:
for so in sales_dict[customer]:
delivery_note = None
- delivery_note = create_delivery_note_from_sales_order(so, delivery_note, skip_item_mapping=True)
+ kwargs = {"skip_item_mapping": True}
+ delivery_note = create_delivery_note_from_sales_order(so, delivery_note, kwargs=kwargs)
break
if delivery_note:
# map all items of all sales orders of that customer
diff --git a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py
index 7fbcbafbac1f..0830fa21430f 100644
--- a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py
+++ b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py
@@ -1,10 +1,13 @@
def get_data():
return {
"fieldname": "pick_list",
+ "non_standard_fieldnames": {
+ "Stock Reservation Entry": "against_pick_list",
+ },
"internal_links": {
"Sales Order": ["locations", "sales_order"],
},
"transactions": [
- {"items": ["Stock Entry", "Sales Order", "Delivery Note"]},
+ {"items": ["Stock Entry", "Sales Order", "Delivery Note", "Stock Reservation Entry"]},
],
}
diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
index 2e56af3395c5..e8e4afc6e3f3 100644
--- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json
+++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json
@@ -16,6 +16,7 @@
"qty",
"stock_qty",
"picked_qty",
+ "stock_reserved_qty",
"column_break_11",
"uom",
"conversion_factor",
@@ -46,7 +47,7 @@
"fieldname": "picked_qty",
"fieldtype": "Float",
"in_list_view": 1,
- "label": "Picked Qty"
+ "label": "Picked Qty (in Stock UOM)"
},
{
"fieldname": "warehouse",
@@ -154,8 +155,7 @@
"fieldtype": "Data",
"hidden": 1,
"label": "Sales Order Item",
- "read_only": 1,
- "search_index": 1
+ "read_only": 1
},
{
"fieldname": "serial_no_and_batch_section",
@@ -207,6 +207,17 @@
"fieldname": "pick_serial_and_batch",
"fieldtype": "Button",
"label": "Pick Serial / Batch No"
+ },
+ {
+ "default": "0",
+ "fieldname": "stock_reserved_qty",
+ "fieldtype": "Float",
+ "label": "Stock Reserved Qty (in Stock UOM)",
+ "no_copy": 1,
+ "non_negative": 1,
+ "print_hide": 1,
+ "read_only": 1,
+ "report_hide": 1
}
],
"istable": 1,
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 1f90c5bf7a53..96e4a556306c 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
@@ -66,7 +66,7 @@ def validate_serial_nos_inventory(self):
serial_nos = [d.serial_no for d in self.entries if d.serial_no]
kwargs = {"item_code": self.item_code, "warehouse": self.warehouse}
if self.voucher_type == "POS Invoice":
- kwargs["ignore_voucher_no"] = self.voucher_no
+ kwargs["ignore_voucher_nos"] = [self.voucher_no]
available_serial_nos = get_available_serial_nos(frappe._dict(kwargs))
@@ -1098,8 +1098,8 @@ def get_available_serial_nos(kwargs):
if kwargs.warehouse:
filters["warehouse"] = kwargs.warehouse
- # Since SLEs are not present against POS invoices, need to ignore serial nos present in the POS invoice
- ignore_serial_nos = get_reserved_serial_nos_for_pos(kwargs)
+ # Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos.
+ ignore_serial_nos = get_reserved_serial_nos(kwargs)
# To ignore serial nos in the same record for the draft state
if kwargs.get("ignore_serial_nos"):
@@ -1180,6 +1180,20 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos):
return serial_nos
+def get_reserved_serial_nos(kwargs) -> list:
+ """Returns a list of `Serial No` reserved in POS Invoice and Stock Reservation Entry."""
+
+ ignore_serial_nos = []
+
+ # Extend the list by serial nos reserved in POS Invoice
+ ignore_serial_nos.extend(get_reserved_serial_nos_for_pos(kwargs))
+
+ # Extend the list by serial nos reserved via SRE
+ ignore_serial_nos.extend(get_reserved_serial_nos_for_sre(kwargs))
+
+ return ignore_serial_nos
+
+
def get_reserved_serial_nos_for_pos(kwargs):
from erpnext.controllers.sales_and_purchase_return import get_returned_serial_nos
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -1199,7 +1213,7 @@ def get_reserved_serial_nos_for_pos(kwargs):
["POS Invoice", "docstatus", "=", 1],
["POS Invoice", "is_return", "=", 0],
["POS Invoice Item", "item_code", "=", kwargs.item_code],
- ["POS Invoice", "name", "!=", kwargs.ignore_voucher_no],
+ ["POS Invoice", "name", "not in", kwargs.ignore_voucher_nos],
],
)
@@ -1251,7 +1265,37 @@ def get_reserved_serial_nos_for_pos(kwargs):
return list(ignore_serial_nos_counter - returned_serial_nos_counter)
-def get_reserved_batches_for_pos(kwargs):
+def get_reserved_serial_nos_for_sre(kwargs) -> list:
+ """Returns a list of `Serial No` reserved in Stock Reservation Entry."""
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sb_entry = frappe.qb.DocType("Serial and Batch Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .inner_join(sb_entry)
+ .on(sre.name == sb_entry.parent)
+ .select(sb_entry.serial_no)
+ .where(
+ (sre.docstatus == 1)
+ & (sre.item_code == kwargs.item_code)
+ & (sre.reserved_qty >= sre.delivered_qty)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ & (sre.reservation_based_on == "Serial and Batch")
+ )
+ )
+
+ if kwargs.warehouse:
+ query = query.where(sre.warehouse == kwargs.warehouse)
+
+ if kwargs.ignore_voucher_nos:
+ query = query.where(sre.name.notin(kwargs.ignore_voucher_nos))
+
+ return [row[0] for row in query.run()]
+
+
+def get_reserved_batches_for_pos(kwargs) -> dict:
+ """Returns a dict of `Batch No` followed by the `Qty` reserved in POS Invoices."""
+
pos_batches = frappe._dict()
pos_invoices = frappe.get_all(
"POS Invoice",
@@ -1267,7 +1311,7 @@ def get_reserved_batches_for_pos(kwargs):
["POS Invoice", "consolidated_invoice", "is", "not set"],
["POS Invoice", "docstatus", "=", 1],
["POS Invoice Item", "item_code", "=", kwargs.item_code],
- ["POS Invoice", "name", "!=", kwargs.ignore_voucher_no],
+ ["POS Invoice", "name", "not in", kwargs.ignore_voucher_nos],
],
)
@@ -1278,7 +1322,7 @@ def get_reserved_batches_for_pos(kwargs):
]
if not ids:
- return []
+ return {}
if ids:
for d in get_serial_batch_ledgers(kwargs.item_code, docstatus=1, name=ids):
@@ -1314,14 +1358,65 @@ def get_reserved_batches_for_pos(kwargs):
return pos_batches
+def get_reserved_batches_for_sre(kwargs) -> dict:
+ """Returns a dict of `Batch No` followed by the `Qty` reserved in Stock Reservation Entry."""
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sb_entry = frappe.qb.DocType("Serial and Batch Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .inner_join(sb_entry)
+ .on(sre.name == sb_entry.parent)
+ .select(
+ sb_entry.batch_no, sre.warehouse, (-1 * Sum(sb_entry.qty - sb_entry.delivered_qty)).as_("qty")
+ )
+ .where(
+ (sre.docstatus == 1)
+ & (sre.item_code == kwargs.item_code)
+ & (sre.reserved_qty >= sre.delivered_qty)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ & (sre.reservation_based_on == "Serial and Batch")
+ )
+ .groupby(sb_entry.batch_no, sre.warehouse)
+ )
+
+ if kwargs.batch_no:
+ if isinstance(kwargs.batch_no, list):
+ query = query.where(sb_entry.batch_no.isin(kwargs.batch_no))
+ else:
+ query = query.where(sb_entry.batch_no == kwargs.batch_no)
+
+ if kwargs.warehouse:
+ query = query.where(sre.warehouse == kwargs.warehouse)
+
+ if kwargs.ignore_voucher_nos:
+ query = query.where(sre.name.notin(kwargs.ignore_voucher_nos))
+
+ data = query.run(as_dict=True)
+
+ reserved_batches_details = frappe._dict()
+ if data:
+ reserved_batches_details = frappe._dict(
+ {
+ (d.batch_no, d.warehouse): frappe._dict({"warehouse": d.warehouse, "qty": d.qty}) for d in data
+ }
+ )
+
+ return reserved_batches_details
+
+
def get_auto_batch_nos(kwargs):
available_batches = get_available_batches(kwargs)
qty = flt(kwargs.qty)
- pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
stock_ledgers_batches = get_stock_ledgers_batches(kwargs)
- if stock_ledgers_batches or pos_invoice_batches:
- update_available_batches(available_batches, stock_ledgers_batches, pos_invoice_batches)
+ pos_invoice_batches = get_reserved_batches_for_pos(kwargs)
+ sre_reserved_batches = get_reserved_batches_for_sre(kwargs)
+
+ if stock_ledgers_batches or pos_invoice_batches or sre_reserved_batches:
+ update_available_batches(
+ available_batches, stock_ledgers_batches, pos_invoice_batches, sre_reserved_batches
+ )
available_batches = list(filter(lambda x: x.qty > 0, available_batches))
@@ -1364,8 +1459,8 @@ def get_qty_based_available_batches(available_batches, qty):
return batches
-def update_available_batches(available_batches, reserved_batches=None, pos_invoice_batches=None):
- for batches in [reserved_batches, pos_invoice_batches]:
+def update_available_batches(available_batches, *reserved_batches) -> None:
+ for batches in reserved_batches:
if batches:
for key, data in batches.items():
batch_no, warehouse = key
diff --git a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json
index 6ec212994421..09565cbc8a8b 100644
--- a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json
+++ b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json
@@ -10,6 +10,7 @@
"column_break_2",
"qty",
"warehouse",
+ "delivered_qty",
"section_break_6",
"incoming_rate",
"column_break_8",
@@ -104,12 +105,24 @@
"fieldtype": "Small Text",
"label": "FIFO Stock Queue (qty, rate)",
"read_only": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: parent.doctype == \"Stock Reservation Entry\"",
+ "fieldname": "delivered_qty",
+ "fieldtype": "Float",
+ "label": "Delivered Qty",
+ "no_copy": 1,
+ "non_negative": 1,
+ "print_hide": 1,
+ "read_only": 1,
+ "report_hide": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2023-03-31 11:18:59.809486",
+ "modified": "2023-07-03 15:29:50.199075",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial and Batch Entry",
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index f009bd42e4ef..26ca012d2c46 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -346,7 +346,7 @@ def validate_reserved_stock(self) -> None:
"""Raises an exception if there is any reserved stock for the items in the Stock Reconciliation."""
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- get_sre_reserved_qty_details_for_item_and_warehouse as get_sre_reserved_qty_details,
+ get_sre_reserved_qty_for_item_and_warehouse as get_sre_reserved_qty_details,
)
item_code_list, warehouse_list = [], []
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 666fd24329b5..4d9663602dd3 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js
@@ -3,6 +3,124 @@
frappe.ui.form.on("Stock Reservation Entry", {
refresh(frm) {
- frm.page.btn_primary.hide()
+ frm.trigger("set_queries");
+ frm.trigger("toggle_read_only_fields");
+ frm.trigger("hide_rate_related_fields");
+ frm.trigger("hide_primary_action_button");
+ frm.trigger("make_sb_entries_warehouse_read_only");
+ },
+
+ has_serial_no(frm) {
+ frm.trigger("toggle_read_only_fields");
+ },
+
+ has_batch_no(frm) {
+ frm.trigger("toggle_read_only_fields");
+ },
+
+ warehouse(frm) {
+ if (frm.doc.warehouse) {
+ frm.doc.sb_entries.forEach((row) => {
+ frappe.model.set_value(row.doctype, row.name, "warehouse", frm.doc.warehouse);
+ });
+ }
+ },
+
+ set_queries(frm) {
+ frm.set_query("warehouse", () => {
+ return {
+ filters: {
+ "is_group": 0,
+ "company": frm.doc.company,
+ }
+ };
+ });
+
+ frm.set_query("serial_no", "sb_entries", function(doc, cdt, cdn) {
+ var selected_serial_nos = doc.sb_entries.map(row => {
+ return row.serial_no;
+ });
+ var row = locals[cdt][cdn];
+ return {
+ filters: {
+ item_code: doc.item_code,
+ warehouse: row.warehouse,
+ status: "Active",
+ name: ["not in", selected_serial_nos],
+ }
+ }
+ });
+
+ frm.set_query("batch_no", "sb_entries", function(doc, cdt, cdn) {
+ let filters = {
+ item: doc.item_code,
+ batch_qty: [">", 0],
+ disabled: 0,
+ }
+
+ if (!doc.has_serial_no) {
+ var selected_batch_nos = doc.sb_entries.map(row => {
+ return row.batch_no;
+ });
+
+ filters.name = ["not in", selected_batch_nos];
+ }
+
+ return { filters: filters }
+ });
+ },
+
+ toggle_read_only_fields(frm) {
+ if (frm.doc.has_serial_no) {
+ frm.doc.sb_entries.forEach(row => {
+ if (row.qty !== 1) {
+ frappe.model.set_value(row.doctype, row.name, "qty", 1);
+ }
+ })
+ }
+
+ frm.fields_dict.sb_entries.grid.update_docfield_property(
+ "serial_no", "read_only", !frm.doc.has_serial_no
+ );
+
+ frm.fields_dict.sb_entries.grid.update_docfield_property(
+ "batch_no", "read_only", !frm.doc.has_batch_no
+ );
+
+ // Qty will always be 1 for Serial No.
+ frm.fields_dict.sb_entries.grid.update_docfield_property(
+ "qty", "read_only", frm.doc.has_serial_no
+ );
+
+ frm.set_df_property("sb_entries", "allow_on_submit", frm.doc.against_pick_list ? 0 : 1);
+ },
+
+ hide_rate_related_fields(frm) {
+ ["incoming_rate", "outgoing_rate", "stock_value_difference", "is_outward", "stock_queue"].forEach(field => {
+ frm.fields_dict.sb_entries.grid.update_docfield_property(
+ field, "hidden", 1
+ );
+ });
+ },
+
+ hide_primary_action_button(frm) {
+ // Hide "Amend" button on cancelled document
+ if (frm.doc.docstatus == 2) {
+ frm.page.btn_primary.hide()
+ }
+ },
+
+ make_sb_entries_warehouse_read_only(frm) {
+ frm.fields_dict.sb_entries.grid.update_docfield_property(
+ "warehouse", "read_only", 1
+ );
},
});
+
+frappe.ui.form.on("Serial and Batch Entry", {
+ sb_entries_add(frm, cdt, cdn) {
+ if (frm.doc.warehouse) {
+ frappe.model.set_value(cdt, cdn, "warehouse", frm.doc.warehouse);
+ }
+ },
+});
\ No newline at end of file
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 7c7abacd9127..5c3018f73423 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json
@@ -2,7 +2,7 @@
"actions": [],
"allow_copy": 1,
"autoname": "MAT-SRE-.YYYY.-.#####",
- "creation": "2023-03-20 10:45:59.258959",
+ "creation": "2023-06-06 15:20:48.016846",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
@@ -10,17 +10,26 @@
"field_order": [
"item_code",
"warehouse",
+ "has_serial_no",
+ "has_batch_no",
"column_break_elik",
"voucher_type",
"voucher_no",
"voucher_detail_no",
+ "column_break_7dxj",
+ "against_pick_list",
+ "against_pick_list_item",
"section_break_xt4m",
+ "stock_uom",
+ "column_break_grdt",
"available_qty",
"voucher_qty",
- "stock_uom",
"column_break_o6ex",
"reserved_qty",
"delivered_qty",
+ "serial_and_batch_reservation_section",
+ "reservation_based_on",
+ "sb_entries",
"section_break_3vb3",
"company",
"column_break_jbyr",
@@ -36,6 +45,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Item Code",
+ "no_copy": 1,
"oldfieldname": "item_code",
"oldfieldtype": "Link",
"options": "Item",
@@ -51,6 +61,7 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Warehouse",
+ "no_copy": 1,
"oldfieldname": "warehouse",
"oldfieldtype": "Link",
"options": "Warehouse",
@@ -64,6 +75,7 @@
"fieldtype": "Select",
"in_filter": 1,
"label": "Voucher Type",
+ "no_copy": 1,
"oldfieldname": "voucher_type",
"oldfieldtype": "Data",
"options": "\nSales Order",
@@ -78,17 +90,20 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Voucher No",
+ "no_copy": 1,
"oldfieldname": "voucher_no",
"oldfieldtype": "Data",
"options": "voucher_type",
"print_width": "150px",
"read_only": 1,
+ "search_index": 1,
"width": "150px"
},
{
"fieldname": "voucher_detail_no",
"fieldtype": "Data",
"label": "Voucher Detail No",
+ "no_copy": 1,
"oldfieldname": "voucher_detail_no",
"oldfieldtype": "Data",
"print_width": "150px",
@@ -100,6 +115,7 @@
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
+ "no_copy": 1,
"oldfieldname": "stock_uom",
"oldfieldtype": "Data",
"options": "UOM",
@@ -111,14 +127,17 @@
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
+ "no_copy": 1,
"options": "Project",
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"in_filter": 1,
"label": "Company",
+ "no_copy": 1,
"oldfieldname": "company",
"oldfieldtype": "Data",
"options": "Company",
@@ -128,23 +147,26 @@
"width": "150px"
},
{
+ "allow_on_submit": 1,
"fieldname": "reserved_qty",
"fieldtype": "Float",
"in_filter": 1,
"in_list_view": 1,
"label": "Reserved Qty",
+ "no_copy": 1,
+ "non_negative": 1,
"oldfieldname": "actual_qty",
"oldfieldtype": "Currency",
"print_width": "150px",
- "read_only": 1,
+ "read_only_depends_on": "eval: ((doc.reservation_based_on == \"Serial and Batch\") || (doc.against_pick_list) || (doc.delivered_qty > 0))",
"width": "150px"
},
{
"default": "Draft",
"fieldname": "status",
"fieldtype": "Select",
- "hidden": 1,
"label": "Status",
+ "no_copy": 1,
"options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled",
"read_only": 1
},
@@ -153,6 +175,8 @@
"fieldname": "delivered_qty",
"fieldtype": "Float",
"label": "Delivered Qty",
+ "no_copy": 1,
+ "non_negative": 1,
"read_only": 1
},
{
@@ -170,6 +194,7 @@
"fieldtype": "Float",
"label": "Available Qty to Reserve",
"no_copy": 1,
+ "non_negative": 1,
"read_only": 1
},
{
@@ -178,6 +203,7 @@
"fieldtype": "Float",
"label": "Voucher Qty",
"no_copy": 1,
+ "non_negative": 1,
"read_only": 1
},
{
@@ -193,12 +219,84 @@
"fieldtype": "Column Break"
},
{
+ "collapsible": 1,
"fieldname": "section_break_3vb3",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "label": "More Information"
},
{
"fieldname": "column_break_jbyr",
"fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.has_serial_no",
+ "fieldname": "has_serial_no",
+ "fieldtype": "Check",
+ "label": "Has Serial No",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.has_batch_no",
+ "fieldname": "has_batch_no",
+ "fieldtype": "Check",
+ "label": "Has Batch No",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "depends_on": "eval: (doc.has_serial_no || doc.has_batch_no) && doc.reservation_based_on == \"Serial and Batch\"",
+ "fieldname": "sb_entries",
+ "fieldtype": "Table",
+ "options": "Serial and Batch Entry",
+ "read_only_depends_on": "eval: (doc.delivered_qty > 0)"
+ },
+ {
+ "fieldname": "serial_and_batch_reservation_section",
+ "fieldtype": "Section Break",
+ "label": "Serial and Batch Reservation"
+ },
+ {
+ "allow_on_submit": 1,
+ "default": "Qty",
+ "depends_on": "eval: parent.has_serial_no || parent.has_batch_no",
+ "fieldname": "reservation_based_on",
+ "fieldtype": "Select",
+ "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
+ },
+ {
+ "fieldname": "column_break_7dxj",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_grdt",
+ "fieldtype": "Column Break"
}
],
"hide_toolbar": 1,
@@ -206,7 +304,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2023-03-29 18:36:26.752872",
+ "modified": "2023-08-08 17:15:13.317706",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reservation Entry",
@@ -230,5 +328,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
- "states": []
+ "states": [],
+ "track_changes": 1
}
\ No newline at end of file
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 5819dd734238..bd7bb6683676 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
+++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py
@@ -5,27 +5,57 @@
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
+from frappe.utils import cint, flt
class StockReservationEntry(Document):
def validate(self) -> None:
from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
+ self.validate_amended_doc()
self.validate_mandatory()
self.validate_for_group_warehouse()
validate_disabled_warehouse(self.warehouse)
validate_warehouse_company(self.warehouse, self.company)
+ self.validate_uom_is_integer()
+
+ def before_submit(self) -> None:
+ self.set_reservation_based_on()
+ self.validate_reservation_based_on_qty()
+ self.auto_reserve_serial_and_batch()
+ self.validate_reservation_based_on_serial_and_batch()
def on_submit(self) -> None:
self.update_reserved_qty_in_voucher()
+ self.update_reserved_qty_in_pick_list()
self.update_status()
+ def on_update_after_submit(self) -> None:
+ self.can_be_updated()
+ self.validate_uom_is_integer()
+ self.set_reservation_based_on()
+ self.validate_reservation_based_on_qty()
+ self.validate_reservation_based_on_serial_and_batch()
+ self.update_reserved_qty_in_voucher()
+ self.update_status()
+ self.reload()
+
def on_cancel(self) -> None:
self.update_reserved_qty_in_voucher()
+ self.update_reserved_qty_in_pick_list()
self.update_status()
+ def validate_amended_doc(self) -> None:
+ """Raises an exception if document is amended."""
+
+ if self.amended_from:
+ msg = _("Cannot amend {0} {1}, please create a new one instead.").format(
+ self.doctype, frappe.bold(self.amended_from)
+ )
+ frappe.throw(msg)
+
def validate_mandatory(self) -> None:
- """Raises exception if mandatory fields are not set."""
+ """Raises an exception if mandatory fields are not set."""
mandatory = [
"item_code",
@@ -41,36 +71,217 @@ def validate_mandatory(self) -> None:
]
for d in mandatory:
if not self.get(d):
- frappe.throw(_("{0} is required").format(self.meta.get_label(d)))
+ msg = _("{0} is required").format(self.meta.get_label(d))
+ frappe.throw(msg)
def validate_for_group_warehouse(self) -> None:
- """Raises exception if `Warehouse` is a Group Warehouse."""
+ """Raises an exception if `Warehouse` is a Group Warehouse."""
if frappe.get_cached_value("Warehouse", self.warehouse, "is_group"):
- frappe.throw(
- _("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse)),
- title=_("Invalid Warehouse"),
+ msg = _("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse))
+ frappe.throw(msg, title=_("Invalid Warehouse"))
+
+ def validate_uom_is_integer(self) -> None:
+ """Validates `Reserved Qty` with Stock UOM."""
+
+ if cint(frappe.db.get_value("UOM", self.stock_uom, "must_be_whole_number", cache=True)):
+ if cint(self.reserved_qty) != flt(self.reserved_qty, self.precision("reserved_qty")):
+ msg = _(
+ "Reserved Qty ({0}) cannot be a fraction. To allow this, disable '{1}' in UOM {3}."
+ ).format(
+ flt(self.reserved_qty, self.precision("reserved_qty")),
+ frappe.bold(_("Must be Whole Number")),
+ frappe.bold(self.stock_uom),
+ )
+ frappe.throw(msg)
+
+ def set_reservation_based_on(self) -> None:
+ """Sets `Reservation Based On` based on `Has Serial No` and `Has Batch No`."""
+
+ if (self.reservation_based_on == "Serial and Batch") and (
+ not self.has_serial_no and not self.has_batch_no
+ ):
+ self.db_set("reservation_based_on", "Qty")
+
+ def validate_reservation_based_on_qty(self) -> None:
+ """Validates `Reserved Qty` when `Reservation Based On` is `Qty`."""
+
+ if self.reservation_based_on == "Qty":
+ self.validate_with_max_reserved_qty(self.reserved_qty)
+
+ def auto_reserve_serial_and_batch(self, based_on: str = None) -> None:
+ """Auto pick Serial and Batch Nos to reserve when `Reservation Based On` is `Serial and Batch`."""
+
+ if (
+ not self.against_pick_list
+ 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"))
+ ):
+ from erpnext.stock.doctype.batch.batch import get_available_batches
+ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos_for_outward
+ from erpnext.stock.serial_batch_bundle import get_serial_nos_batch
+
+ self.reservation_based_on = "Serial and Batch"
+ self.sb_entries.clear()
+ kwargs = frappe._dict(
+ {
+ "item_code": self.item_code,
+ "warehouse": self.warehouse,
+ "qty": abs(self.reserved_qty) or 0,
+ "based_on": based_on
+ or frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"),
+ }
)
- def update_status(self, status: str = None, update_modified: bool = True) -> None:
- """Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
+ serial_nos, batch_nos = [], []
+ if self.has_serial_no:
+ serial_nos = get_serial_nos_for_outward(kwargs)
+ if self.has_batch_no:
+ batch_nos = get_available_batches(kwargs)
+
+ if serial_nos:
+ serial_no_wise_batch = frappe._dict({})
+
+ if self.has_batch_no:
+ serial_no_wise_batch = get_serial_nos_batch(serial_nos)
+
+ for serial_no in serial_nos:
+ self.append(
+ "sb_entries",
+ {
+ "serial_no": serial_no,
+ "qty": 1,
+ "batch_no": serial_no_wise_batch.get(serial_no),
+ "warehouse": self.warehouse,
+ },
+ )
+ elif batch_nos:
+ for batch_no, batch_qty in batch_nos.items():
+ self.append(
+ "sb_entries",
+ {
+ "batch_no": batch_no,
+ "qty": batch_qty,
+ "warehouse": self.warehouse,
+ },
+ )
+
+ def validate_reservation_based_on_serial_and_batch(self) -> None:
+ """Validates `Reserved Qty`, `Serial and Batch Nos` when `Reservation Based On` is `Serial and Batch`."""
+
+ if self.reservation_based_on == "Serial and Batch":
+ allow_partial_reservation = frappe.db.get_single_value(
+ "Stock Settings", "allow_partial_reservation"
+ )
- if not status:
- if self.docstatus == 2:
- status = "Cancelled"
- elif self.docstatus == 1:
- if self.reserved_qty == self.delivered_qty:
- status = "Delivered"
- elif self.delivered_qty and self.delivered_qty < self.reserved_qty:
- status = "Partially Delivered"
- elif self.reserved_qty == self.voucher_qty:
- status = "Reserved"
- else:
- status = "Partially Reserved"
- else:
- status = "Draft"
+ available_serial_nos = []
+ if self.has_serial_no:
+ available_serial_nos = get_available_serial_nos_to_reserve(
+ self.item_code, self.warehouse, self.has_batch_no, ignore_sre=self.name
+ )
- frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified)
+ if not available_serial_nos:
+ msg = _("Stock not available for Item {0} in Warehouse {1}.").format(
+ frappe.bold(self.item_code), frappe.bold(self.warehouse)
+ )
+ frappe.throw(msg)
+
+ qty_to_be_reserved = 0
+ selected_batch_nos, selected_serial_nos = [], []
+ for entry in self.sb_entries:
+ entry.warehouse = self.warehouse
+
+ if self.has_serial_no:
+ entry.qty = 1
+
+ key = (
+ (entry.serial_no, self.warehouse, entry.batch_no)
+ if self.has_batch_no
+ else (entry.serial_no, self.warehouse)
+ )
+ if key not in available_serial_nos:
+ msg = _(
+ "Row #{0}: Serial No {1} for Item {2} is not available in {3} {4} or might be reserved in another {5}."
+ ).format(
+ entry.idx,
+ frappe.bold(entry.serial_no),
+ frappe.bold(self.item_code),
+ _("Batch {0} and Warehouse").format(frappe.bold(entry.batch_no))
+ if self.has_batch_no
+ else _("Warehouse"),
+ frappe.bold(self.warehouse),
+ frappe.bold("Stock Reservation Entry"),
+ )
+
+ frappe.throw(msg)
+
+ if entry.serial_no in selected_serial_nos:
+ msg = _("Row #{0}: Serial No {1} is already selected.").format(
+ entry.idx, frappe.bold(entry.serial_no)
+ )
+ frappe.throw(msg)
+ else:
+ selected_serial_nos.append(entry.serial_no)
+
+ elif self.has_batch_no:
+ if cint(frappe.db.get_value("Batch", entry.batch_no, "disabled")):
+ msg = _(
+ "Row #{0}: Stock cannot be reserved for Item {1} against a disabled Batch {2}."
+ ).format(
+ entry.idx, frappe.bold(self.item_code), frappe.bold(entry.batch_no)
+ )
+ frappe.throw(msg)
+
+ available_qty_to_reserve = get_available_qty_to_reserve(
+ self.item_code, self.warehouse, entry.batch_no, ignore_sre=self.name
+ )
+
+ if available_qty_to_reserve <= 0:
+ msg = _(
+ "Row #{0}: Stock not availabe to reserve for Item {1} against Batch {2} in Warehouse {3}."
+ ).format(
+ entry.idx,
+ frappe.bold(self.item_code),
+ frappe.bold(entry.batch_no),
+ frappe.bold(self.warehouse),
+ )
+ frappe.throw(msg)
+
+ if entry.qty > available_qty_to_reserve:
+ if allow_partial_reservation:
+ entry.qty = available_qty_to_reserve
+ if self.get("_action") == "update_after_submit":
+ entry.db_update()
+ else:
+ msg = _(
+ "Row #{0}: Qty should be less than or equal to Available Qty to Reserve (Actual Qty - Reserved Qty) {1} for Iem {2} against Batch {3} in Warehouse {4}."
+ ).format(
+ entry.idx,
+ frappe.bold(available_qty_to_reserve),
+ frappe.bold(self.item_code),
+ frappe.bold(entry.batch_no),
+ frappe.bold(self.warehouse),
+ )
+ frappe.throw(msg)
+
+ if entry.batch_no in selected_batch_nos:
+ msg = _("Row #{0}: Batch No {1} is already selected.").format(
+ entry.idx, frappe.bold(entry.batch_no)
+ )
+ frappe.throw(msg)
+ else:
+ selected_batch_nos.append(entry.batch_no)
+
+ qty_to_be_reserved += entry.qty
+
+ if not qty_to_be_reserved:
+ msg = _("Please select Serial/Batch Nos to reserve or change Reservation Based On to Qty.")
+ frappe.throw(msg)
+
+ # Should be called after validating Serial and Batch Nos.
+ self.validate_with_max_reserved_qty(qty_to_be_reserved)
+ self.db_set("reserved_qty", qty_to_be_reserved)
def update_reserved_qty_in_voucher(
self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True
@@ -100,45 +311,177 @@ def update_reserved_qty_in_voucher(
update_modified=update_modified,
)
+ def update_reserved_qty_in_pick_list(
+ self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True
+ ) -> None:
+ """Updates total reserved qty in the Pick List."""
+
+ if self.against_pick_list and self.against_pick_list_item:
+ 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)
+ )
+ ).run(as_list=True)[0][0] or 0
+
+ frappe.db.set_value(
+ "Pick List Item",
+ self.against_pick_list_item,
+ reserved_qty_field,
+ reserved_qty,
+ update_modified=update_modified,
+ )
+
+ def update_status(self, status: str = None, update_modified: bool = True) -> None:
+ """Updates status based on Voucher Qty, Reserved Qty and Delivered Qty."""
+
+ if not status:
+ if self.docstatus == 2:
+ status = "Cancelled"
+ elif self.docstatus == 1:
+ if self.reserved_qty == self.delivered_qty:
+ status = "Delivered"
+ elif self.delivered_qty and self.delivered_qty < self.reserved_qty:
+ status = "Partially Delivered"
+ elif self.reserved_qty == self.voucher_qty:
+ status = "Reserved"
+ else:
+ status = "Partially Reserved"
+ else:
+ status = "Draft"
+
+ frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified)
+
+ def can_be_updated(self) -> None:
+ """Raises an exception if `Stock Reservation Entry` is not allowed to be updated."""
+
+ if self.status in ("Partially Delivered", "Delivered"):
+ msg = _(
+ "{0} {1} cannot be updated. If you need to make changes, we recommend canceling the existing entry and creating a new one."
+ ).format(self.status, self.doctype)
+ frappe.throw(msg)
+
+ if self.against_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."
+ )
+ frappe.throw(msg)
+
+ if self.delivered_qty > 0:
+ msg = _("Stock Reservation Entry cannot be updated as it has been delivered.")
+ frappe.throw(msg)
+
+ def validate_with_max_reserved_qty(self, qty_to_be_reserved: float) -> None:
+ """Validates `Reserved Qty` with `Max Reserved Qty`."""
+
+ self.db_set(
+ "available_qty",
+ get_available_qty_to_reserve(self.item_code, self.warehouse, ignore_sre=self.name),
+ )
+
+ total_reserved_qty = get_sre_reserved_qty_for_voucher_detail_no(
+ self.voucher_type, self.voucher_no, self.voucher_detail_no, ignore_sre=self.name
+ )
+
+ voucher_delivered_qty = 0
+ if self.voucher_type == "Sales Order":
+ delivered_qty, conversion_factor = frappe.db.get_value(
+ "Sales Order Item", self.voucher_detail_no, ["delivered_qty", "conversion_factor"]
+ )
+ voucher_delivered_qty = flt(delivered_qty) * flt(conversion_factor)
+
+ max_reserved_qty = min(
+ self.available_qty, (self.voucher_qty - voucher_delivered_qty - total_reserved_qty)
+ )
+
+ if max_reserved_qty <= 0 and self.voucher_type == "Sales Order":
+ msg = _("Item {0} is already delivered for Sales Order {1}.").format(
+ frappe.bold(self.item_code), frappe.bold(self.voucher_no)
+ )
+
+ if self.docstatus == 1:
+ self.cancel()
+ return frappe.msgprint(msg)
+ else:
+ frappe.throw(msg)
+
+ if qty_to_be_reserved > max_reserved_qty:
+ msg = """
+ Cannot reserve more than Max Reserved Qty {0} {1}.
+ The Max Reserved Qty is calculated as follows:
+
+ - Available Qty To Reserve = (Actual Stock Qty - Reserved Stock Qty)
+ - Voucher Qty = Voucher Item Qty
+ - Delivered Qty = Qty delivered against the Voucher Item
+ - Total Reserved Qty = Qty reserved against the Voucher Item
+ - Max Reserved Qty = Minimum of (Available Qty To Reserve, (Voucher Qty - Delivered Qty - Total Reserved Qty))
+
+ """.format(
+ frappe.bold(max_reserved_qty), self.stock_uom
+ )
+ frappe.throw(msg)
+
+ if qty_to_be_reserved <= self.delivered_qty:
+ msg = _("Reserved Qty should be greater than Delivered Qty.")
+ frappe.throw(msg)
+
def validate_stock_reservation_settings(voucher: object) -> None:
"""Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed."""
if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
- frappe.throw(
- _("Please enable {0} in the {1}.").format(
- frappe.bold("Stock Reservation"), frappe.bold("Stock Settings")
- )
+ msg = _("Please enable {0} in the {1}.").format(
+ frappe.bold("Stock Reservation"), frappe.bold("Stock Settings")
)
+ frappe.throw(msg)
# Voucher types allowed for stock reservation
allowed_voucher_types = ["Sales Order"]
if voucher.doctype not in allowed_voucher_types:
- frappe.throw(
- _("Stock Reservation can only be created against {0}.").format(", ".join(allowed_voucher_types))
+ msg = _("Stock Reservation can only be created against {0}.").format(
+ ", ".join(allowed_voucher_types)
)
+ frappe.throw(msg)
-def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float:
- """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination."""
+def get_available_qty_to_reserve(
+ item_code: str, warehouse: str, batch_no: str = None, ignore_sre=None
+) -> float:
+ """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item, Warehouse and Batch combination."""
+ from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.utils import get_stock_balance
+ if batch_no:
+ return get_batch_qty(
+ item_code=item_code, warehouse=warehouse, batch_no=batch_no, ignore_voucher_nos=[ignore_sre]
+ )
+
available_qty = get_stock_balance(item_code, warehouse)
if available_qty:
sre = frappe.qb.DocType("Stock Reservation Entry")
- reserved_qty = (
+ query = (
frappe.qb.from_(sre)
.select(Sum(sre.reserved_qty - sre.delivered_qty))
.where(
(sre.docstatus == 1)
& (sre.item_code == item_code)
& (sre.warehouse == warehouse)
+ & (sre.reserved_qty >= sre.delivered_qty)
& (sre.status.notin(["Delivered", "Cancelled"]))
)
- ).run()[0][0] or 0.0
+ )
+
+ if ignore_sre:
+ query = query.where(sre.name != ignore_sre)
+
+ reserved_qty = query.run()[0][0] or 0.0
if reserved_qty:
return available_qty - reserved_qty
@@ -146,93 +489,97 @@ def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float:
return available_qty
-def get_stock_reservation_entries_for_voucher(
- voucher_type: str, voucher_no: str, voucher_detail_no: str = None, fields: list[str] = None
-) -> list[dict]:
- """Returns list of Stock Reservation Entries against a Voucher."""
+def get_available_serial_nos_to_reserve(
+ item_code: str, warehouse: str, has_batch_no: bool = False, ignore_sre=None
+) -> list[tuple]:
+ """Returns Available Serial Nos to Reserve (Available Serial Nos - Reserved Serial Nos)` for Item, Warehouse and Batch combination."""
- if not fields or not isinstance(fields, list):
- fields = [
- "name",
- "item_code",
- "warehouse",
- "voucher_detail_no",
- "reserved_qty",
- "delivered_qty",
- "stock_uom",
- ]
+ from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
+ get_available_serial_nos,
+ )
- sre = frappe.qb.DocType("Stock Reservation Entry")
- query = (
- frappe.qb.from_(sre)
- .where(
- (sre.docstatus == 1)
- & (sre.voucher_type == voucher_type)
- & (sre.voucher_no == voucher_no)
- & (sre.status.notin(["Delivered", "Cancelled"]))
+ available_serial_nos = get_available_serial_nos(
+ frappe._dict(
+ {
+ "item_code": item_code,
+ "warehouse": warehouse,
+ "has_batch_no": has_batch_no,
+ "ignore_voucher_nos": [ignore_sre],
+ }
)
- .orderby(sre.creation)
)
- for field in fields:
- query = query.select(sre[field])
-
- if voucher_detail_no:
- query = query.where(sre.voucher_detail_no == voucher_detail_no)
-
- return query.run(as_dict=True)
-
-
-def get_sre_reserved_qty_details_for_item_and_warehouse(
- item_code_list: list, warehouse_list: list
-) -> dict:
- """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }."""
-
- sre_details = {}
+ available_serial_nos_list = []
+ if available_serial_nos:
+ available_serial_nos_list = [tuple(d.values()) for d in available_serial_nos]
- if item_code_list and warehouse_list:
sre = frappe.qb.DocType("Stock Reservation Entry")
- sre_data = (
+ sb_entry = frappe.qb.DocType("Serial and Batch Entry")
+ query = (
frappe.qb.from_(sre)
- .select(
- sre.item_code,
- sre.warehouse,
- Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"),
- )
+ .left_join(sb_entry)
+ .on(sre.name == sb_entry.parent)
+ .select(sb_entry.serial_no, sre.warehouse)
.where(
(sre.docstatus == 1)
- & (sre.item_code.isin(item_code_list))
- & (sre.warehouse.isin(warehouse_list))
+ & (sre.item_code == item_code)
+ & (sre.warehouse == warehouse)
+ & (sre.reserved_qty >= sre.delivered_qty)
& (sre.status.notin(["Delivered", "Cancelled"]))
+ & (sre.reservation_based_on == "Serial and Batch")
)
- .groupby(sre.item_code, sre.warehouse)
- ).run(as_dict=True)
+ )
- if sre_data:
- sre_details = {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in sre_data}
+ if has_batch_no:
+ query = query.select(sb_entry.batch_no)
- return sre_details
+ if ignore_sre:
+ query = query.where(sre.name != ignore_sre)
+ reserved_serial_nos = query.run()
-def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str) -> float:
- """Returns `Reserved Qty` for Item and Warehouse combination."""
+ if reserved_serial_nos:
+ return list(set(available_serial_nos_list) - set(reserved_serial_nos))
- reserved_qty = 0.0
+ return available_serial_nos_list
- if item_code and warehouse:
- sre = frappe.qb.DocType("Stock Reservation Entry")
- return (
- frappe.qb.from_(sre)
- .select(Sum(sre.reserved_qty - sre.delivered_qty))
- .where(
- (sre.docstatus == 1)
- & (sre.item_code == item_code)
- & (sre.warehouse == warehouse)
- & (sre.status.notin(["Delivered", "Cancelled"]))
- )
- ).run(as_list=True)[0][0] or 0.0
- return reserved_qty
+def get_sre_reserved_qty_for_item_and_warehouse(
+ item_code: str | list, warehouse: str | list = None
+) -> float | dict:
+ """Returns `Reserved Qty` for Item and Warehouse combination OR a dict like {("item_code", "warehouse"): "reserved_qty", ... }."""
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .select(
+ sre.item_code,
+ sre.warehouse,
+ Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"),
+ )
+ .where((sre.docstatus == 1) & (sre.status.notin(["Delivered", "Cancelled"])))
+ .groupby(sre.item_code, sre.warehouse)
+ )
+
+ query = (
+ query.where(sre.item_code.isin(item_code))
+ if isinstance(item_code, list)
+ else query.where(sre.item_code == item_code)
+ )
+
+ if warehouse:
+ query = (
+ query.where(sre.warehouse.isin(warehouse))
+ if isinstance(warehouse, list)
+ else query.where(sre.warehouse == warehouse)
+ )
+
+ data = query.run(as_dict=True)
+
+ if isinstance(item_code, str) and isinstance(warehouse, str):
+ return data[0]["reserved_qty"] if data else 0.0
+ else:
+ return {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in data} if data else {}
def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str) -> dict:
@@ -257,15 +604,44 @@ def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str)
return frappe._dict(data)
-def get_sre_reserved_qty_details_for_voucher_detail_no(
- voucher_type: str, voucher_no: str, voucher_detail_no: str
+def get_sre_reserved_warehouses_for_voucher(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str = None
) -> list:
- """Returns a list like ["warehouse", "reserved_qty"]."""
+ """Returns a list of warehouses where the stock is reserved for the provided voucher."""
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .select(sre.warehouse)
+ .distinct()
+ .where(
+ (sre.docstatus == 1)
+ & (sre.voucher_type == voucher_type)
+ & (sre.voucher_no == voucher_no)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .orderby(sre.creation)
+ )
+
+ if voucher_detail_no:
+ query = query.where(sre.voucher_detail_no == voucher_detail_no)
+
+ warehouses = query.run(as_list=True)
+
+ return [d[0] for d in warehouses] if warehouses else []
+
+
+def get_sre_reserved_qty_for_voucher_detail_no(
+ voucher_type: str, voucher_no: str, voucher_detail_no: str, ignore_sre=None
+) -> float:
+ """Returns `Reserved Qty` against the Voucher."""
sre = frappe.qb.DocType("Stock Reservation Entry")
- reserved_qty_details = (
+ query = (
frappe.qb.from_(sre)
- .select(sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)))
+ .select(
+ (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)),
+ )
.where(
(sre.docstatus == 1)
& (sre.voucher_type == voucher_type)
@@ -273,40 +649,366 @@ def get_sre_reserved_qty_details_for_voucher_detail_no(
& (sre.voucher_detail_no == voucher_detail_no)
& (sre.status.notin(["Delivered", "Cancelled"]))
)
+ )
+
+ if ignore_sre:
+ query = query.where(sre.name != ignore_sre)
+
+ reserved_qty = query.run(as_list=True)
+
+ return flt(reserved_qty[0][0])
+
+
+def get_sre_details_for_voucher(voucher_type: str, voucher_no: str) -> list[dict]:
+ """Returns a list of SREs for the provided voucher."""
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ return (
+ frappe.qb.from_(sre)
+ .select(
+ sre.name,
+ sre.item_code,
+ sre.warehouse,
+ sre.voucher_type,
+ sre.voucher_no,
+ sre.voucher_detail_no,
+ (sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"),
+ sre.has_serial_no,
+ sre.has_batch_no,
+ sre.reservation_based_on,
+ )
+ .where(
+ (sre.docstatus == 1)
+ & (sre.voucher_type == voucher_type)
+ & (sre.voucher_no == voucher_no)
+ & (sre.reserved_qty > sre.delivered_qty)
+ & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
.orderby(sre.creation)
- .groupby(sre.warehouse)
- ).run(as_list=True)
+ ).run(as_dict=True)
+
+
+def get_serial_batch_entries_for_voucher(sre_name: str) -> list[dict]:
+ """Returns a list of `Serial and Batch Entries` for the provided voucher."""
- if reserved_qty_details:
- return reserved_qty_details[0]
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sb_entry = frappe.qb.DocType("Serial and Batch Entry")
+
+ return (
+ frappe.qb.from_(sre)
+ .inner_join(sb_entry)
+ .on(sre.name == sb_entry.parent)
+ .select(
+ sb_entry.serial_no,
+ sb_entry.batch_no,
+ (sb_entry.qty - sb_entry.delivered_qty).as_("qty"),
+ )
+ .where(
+ (sre.docstatus == 1) & (sre.name == sre_name) & (sre.status.notin(["Delivered", "Cancelled"]))
+ )
+ .where(sb_entry.qty > sb_entry.delivered_qty)
+ .orderby(sb_entry.creation)
+ ).run(as_dict=True)
+
+
+def get_ssb_bundle_for_voucher(sre: dict) -> object | None:
+ """Returns a new `Serial and Batch Bundle` against the provided SRE."""
- return reserved_qty_details
+ sb_entries = get_serial_batch_entries_for_voucher(sre["name"])
+
+ if sb_entries:
+ bundle = frappe.new_doc("Serial and Batch Bundle")
+ bundle.type_of_transaction = "Outward"
+ bundle.voucher_type = "Delivery Note"
+
+ for field in ("item_code", "warehouse", "has_serial_no", "has_batch_no"):
+ setattr(bundle, field, sre[field])
+
+ for sb_entry in sb_entries:
+ bundle.append("entries", sb_entry)
+
+ bundle.save()
+
+ return bundle.name
def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool:
"""Returns True if there is any Stock Reservation Entry for the given voucher."""
if get_stock_reservation_entries_for_voucher(
- voucher_type, voucher_no, voucher_detail_no, fields=["name"]
+ voucher_type, voucher_no, voucher_detail_no, fields=["name"], ignore_status=True
):
return True
return False
-@frappe.whitelist()
-def cancel_stock_reservation_entries(
- voucher_type: str, voucher_no: str, voucher_detail_no: str = None, notify: bool = True
+def create_stock_reservation_entries_for_so_items(
+ so: object,
+ items_details: list[dict] = None,
+ against_pick_list: bool = False,
+ notify=True,
) -> None:
- """Cancel Stock Reservation Entries for the given voucher."""
+ """Creates Stock Reservation Entries for Sales Order Items."""
- sre_list = get_stock_reservation_entries_for_voucher(
- voucher_type, voucher_no, voucher_detail_no, fields=["name"]
+ 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"))
+ ):
+ return frappe.msgprint(
+ _("Stock cannot be reserved in the group warehouse {0}.").format(frappe.bold(so.set_warehouse))
+ )
+
+ validate_stock_reservation_settings(so)
+
+ allow_partial_reservation = frappe.db.get_single_value(
+ "Stock Settings", "allow_partial_reservation"
)
+ 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.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))
+ )
+
+ 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")
+
+ items.append(so_item)
+
+ sre_count = 0
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
+
+ for item in items if items_details else so.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:
+ 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))
+ )
+
+ is_stock_item, has_serial_no, has_batch_no = frappe.get_cached_value(
+ "Item", item.item_code, ["is_stock_item", "has_serial_no", "has_batch_no"]
+ )
+
+ # 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",
+ )
+ item.db_set("reserve_stock", 0)
+ continue
+
+ # Skip if Group Warehouse.
+ if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
+ frappe.msgprint(
+ _("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format(
+ item.idx, frappe.bold(item.warehouse)
+ ),
+ title=_("Stock Reservation"),
+ indicator="yellow",
+ )
+ continue
+
+ unreserved_qty = get_unreserved_qty(item, reserved_qty_details)
+
+ # 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",
+ )
+ continue
+
+ available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse)
+
+ # 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(
+ item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse)
+ ),
+ title=_("Stock Reservation"),
+ indicator="orange",
+ )
+ continue
+
+ # The quantity which can be reserved.
+ qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve)
+
+ if hasattr(item, "qty_to_reserve"):
+ if item.qty_to_reserve <= 0:
+ frappe.msgprint(
+ _("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format(
+ item.idx, frappe.bold(item.item_code)
+ ),
+ title=_("Stock Reservation"),
+ indicator="orange",
+ )
+ continue
+ else:
+ qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve)
+
+ # 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")):
+ 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),
+ frappe.bold(item.item_code),
+ )
+ frappe.msgprint(msg, title=_("Stock Reservation"), indicator="orange")
+
+ # Skip the item if `Partial Reservation` is disabled in the Stock Settings.
+ if not allow_partial_reservation:
+ if qty_to_be_reserved == flt(item.get("qty_to_reserve")):
+ msg = _("Enable Allow Partial Reservation in the Stock Settings to reserve partial stock.")
+ frappe.msgprint(msg, title=_("Partial Stock Reservation"), indicator="yellow")
+
+ continue
+
+ sre = frappe.new_doc("Stock Reservation Entry")
+
+ sre.item_code = item.item_code
+ 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_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.stock_uom = item.stock_uom
+ sre.project = so.project
+
+ if against_pick_list:
+ sre.against_pick_list = item.pick_list
+ sre.against_pick_list_item = item.pick_list_item
+
+ 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,
+ },
+ )
+
+ sre.save()
+ sre.submit()
+
+ sre_count += 1
+
+ if sre_count and notify:
+ frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green")
+
+
+def cancel_stock_reservation_entries(
+ voucher_type: str = None,
+ voucher_no: str = None,
+ voucher_detail_no: str = None,
+ against_pick_list: 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"]))
+ )
+ .orderby(sre.creation)
+ ).run(as_dict=True)
+
+ 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 sre_list:
for sre in sre_list:
- frappe.get_doc("Stock Reservation Entry", sre.name).cancel()
+ frappe.get_doc("Stock Reservation Entry", sre["name"]).cancel()
if notify:
- frappe.msgprint(_("Stock Reservation Entries Cancelled"), alert=True, indicator="red")
+ msg = _("Stock Reservation Entries Cancelled")
+ frappe.msgprint(msg, alert=True, indicator="red")
+
+
+@frappe.whitelist()
+def get_stock_reservation_entries_for_voucher(
+ voucher_type: str,
+ voucher_no: str,
+ voucher_detail_no: str = None,
+ fields: list[str] = None,
+ ignore_status: bool = False,
+) -> list[dict]:
+ """Returns list of Stock Reservation Entries against a Voucher."""
+
+ if not fields or not isinstance(fields, list):
+ fields = [
+ "name",
+ "item_code",
+ "warehouse",
+ "voucher_detail_no",
+ "reserved_qty",
+ "delivered_qty",
+ "stock_uom",
+ ]
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .where(
+ (sre.docstatus == 1) & (sre.voucher_type == voucher_type) & (sre.voucher_no == voucher_no)
+ )
+ .orderby(sre.creation)
+ )
+
+ for field in fields:
+ query = query.select(sre[field])
+
+ if voucher_detail_no:
+ query = query.where(sre.voucher_detail_no == voucher_detail_no)
+
+ if ignore_status:
+ query = query.where(sre.status.notin(["Delivered", "Cancelled"]))
+
+ return query.run(as_dict=True)
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 dff407f149db..1168a4e1c618 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
@@ -1,23 +1,38 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
+from random import randint
+
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
+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
+from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
+from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ get_sre_reserved_qty_details_for_voucher,
+ get_stock_reservation_entries_for_voucher,
+ has_reserved_stock,
+)
from erpnext.stock.utils import get_stock_balance
class TestStockReservationEntry(FrappeTestCase):
def setUp(self) -> None:
- self.items = create_items()
- create_material_receipt(self.items)
+ self.warehouse = "_Test Warehouse - _TC"
+ self.sr_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100})
+ create_material_receipt(
+ 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 (
validate_stock_reservation_settings,
@@ -47,28 +62,29 @@ def test_get_available_qty_to_reserve(self) -> None:
get_available_qty_to_reserve,
)
- item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
-
# Case - 1: When `Reserved Qty` is `0`, Available Qty to Reserve = Actual Qty
- cancel_all_stock_reservation_entries()
- available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
- expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse)
+ available_qty_to_reserve = get_available_qty_to_reserve(self.sr_item.name, self.warehouse)
+ expected_available_qty_to_reserve = get_stock_balance(self.sr_item.name, self.warehouse)
self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
# Case - 2: When `Reserved Qty` is `> 0`, Available Qty to Reserve = Actual Qty - Reserved Qty
sre = make_stock_reservation_entry(
- item_code=item_code,
- warehouse=warehouse,
+ item_code=self.sr_item.name,
+ warehouse=self.warehouse,
ignore_validate=True,
)
- available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse)
- expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse) - sre.reserved_qty
+ available_qty_to_reserve = get_available_qty_to_reserve(self.sr_item.name, self.warehouse)
+ expected_available_qty_to_reserve = (
+ get_stock_balance(self.sr_item.name, self.warehouse) - sre.reserved_qty
+ )
self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve)
def test_update_status(self) -> None:
sre = make_stock_reservation_entry(
+ item_code=self.sr_item.name,
+ warehouse=self.warehouse,
reserved_qty=30,
ignore_validate=True,
do_not_submit=True,
@@ -109,14 +125,12 @@ def test_update_status(self) -> None:
sre.load_from_db()
self.assertEqual(sre.status, "Cancelled")
- @change_settings("Stock Settings", {"enable_stock_reservation": 1})
+ @change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1})
def test_update_reserved_qty_in_voucher(self) -> None:
- item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
-
# Step - 1: Create a `Sales Order`
so = make_sales_order(
- item_code=item_code,
- warehouse=warehouse,
+ item_code=self.sr_item.name,
+ warehouse=self.warehouse,
qty=50,
rate=100,
do_not_submit=True,
@@ -128,8 +142,8 @@ def test_update_reserved_qty_in_voucher(self) -> None:
# Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item`
sre1 = make_stock_reservation_entry(
- item_code=item_code,
- warehouse=warehouse,
+ item_code=self.sr_item.name,
+ warehouse=self.warehouse,
voucher_type="Sales Order",
voucher_no=so.name,
voucher_detail_no=so.items[0].name,
@@ -143,8 +157,8 @@ def test_update_reserved_qty_in_voucher(self) -> None:
# Step - 3: Create a `Stock Reservation Entry[2]` for the `Sales Order Item`
sre2 = make_stock_reservation_entry(
- item_code=item_code,
- warehouse=warehouse,
+ item_code=self.sr_item.name,
+ warehouse=self.warehouse,
voucher_type="Sales Order",
voucher_no=so.name,
voucher_detail_no=so.items[0].name,
@@ -163,26 +177,32 @@ def test_update_reserved_qty_in_voucher(self) -> None:
self.assertEqual(sre1.status, "Cancelled")
self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty)
- # Step - 5: Cancel `Stock Reservation Entry[2]`
+ # Step - 5: Update `Stock Reservation Entry[2]` Reserved Qty
+ sre2.reserved_qty += sre1.reserved_qty
+ sre2.save()
+ so.load_from_db()
+ sre1.load_from_db()
+ self.assertEqual(sre2.status, "Reserved")
+ self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty)
+
+ # Step - 6: Cancel `Stock Reservation Entry[2]`
sre2.cancel()
so.load_from_db()
sre2.load_from_db()
self.assertEqual(sre1.status, "Cancelled")
self.assertEqual(so.items[0].stock_reserved_qty, 0)
- @change_settings("Stock Settings", {"enable_stock_reservation": 1})
+ @change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1})
def test_cant_consume_reserved_stock(self) -> None:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
cancel_stock_reservation_entries,
)
from erpnext.stock.stock_ledger import NegativeStockError
- item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC"
-
# Step - 1: Create a `Sales Order`
so = make_sales_order(
- item_code=item_code,
- warehouse=warehouse,
+ item_code=self.sr_item.name,
+ warehouse=self.warehouse,
qty=50,
rate=100,
do_not_submit=True,
@@ -192,13 +212,13 @@ def test_cant_consume_reserved_stock(self) -> None:
so.save()
so.submit()
- actual_qty = get_stock_balance(item_code, warehouse)
+ actual_qty = get_stock_balance(self.sr_item.name, self.warehouse)
# Step - 2: Try to consume (Transfer/Issue/Deliver) the Available Qty via Stock Entry or Delivery Note, should throw `NegativeStockError`.
se = make_stock_entry(
- item_code=item_code,
+ item_code=self.sr_item.name,
qty=actual_qty,
- from_warehouse=warehouse,
+ from_warehouse=self.warehouse,
rate=100,
purpose="Material Issue",
do_not_submit=True,
@@ -210,9 +230,9 @@ def test_cant_consume_reserved_stock(self) -> None:
cancel_stock_reservation_entries(so.doctype, so.name)
se = make_stock_entry(
- item_code=item_code,
+ item_code=self.sr_item.name,
qty=actual_qty,
- from_warehouse=warehouse,
+ from_warehouse=self.warehouse,
rate=100,
purpose="Material Issue",
do_not_submit=True,
@@ -220,52 +240,369 @@ def test_cant_consume_reserved_stock(self) -> None:
se.submit()
se.cancel()
+ @change_settings(
+ "Stock Settings",
+ {
+ "allow_negative_stock": 0,
+ "enable_stock_reservation": 1,
+ "auto_reserve_serial_and_batch": 0,
+ "pick_serial_and_batch_based_on": "FIFO",
+ "auto_create_serial_and_batch_bundle_for_outward": 1,
+ },
+ )
+ def test_stock_reservation_against_sales_order(self) -> None:
+ items_details = create_items()
+ se = 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,
+ )
+
+ # Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings.
+ with change_settings("Stock Settings", {"allow_partial_reservation": 0}):
+ so.create_stock_reservation_entries()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ # Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings.
+ with change_settings("Stock Settings", {"allow_partial_reservation": 1}):
+ so.create_stock_reservation_entries()
+ so.load_from_db()
+ self.assertTrue(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["reserved_qty", "status"]
+ )[0]
+ self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty)
+ self.assertEqual(sre_details.status, "Partially Reserved")
+
+ se.cancel()
+
+ # Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty.
+ create_material_receipt(items_details, self.warehouse, qty=110)
+ so.create_stock_reservation_entries()
+ so.load_from_db()
+
+ reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name)
+ for item in so.items:
+ reserved_qty = reserved_qty_details[item.name]
+ self.assertEqual(item.stock_reserved_qty, reserved_qty)
+ self.assertEqual(item.stock_qty, item.stock_reserved_qty)
+
+ # Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries.
+ cancel_stock_reservation_entries("Sales Order", so.name)
+ so.load_from_db()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ self.assertEqual(item.stock_reserved_qty, 0)
+
+ # Test - 5: Re-reserve the stock.
+ so.create_stock_reservation_entries()
+ self.assertTrue(has_reserved_stock("Sales Order", so.name))
+
+ # Test - 6: Stock should get unreserved on cancellation of Sales Order.
+ so.cancel()
+ so.load_from_db()
+ self.assertFalse(has_reserved_stock("Sales Order", so.name))
+
+ for item in so.items:
+ self.assertEqual(item.stock_reserved_qty, 0)
+
+ # Create Sales Order and Reserve Stock.
+ so = make_sales_order(
+ item_list=item_list,
+ warehouse=self.warehouse,
+ )
+ so.create_stock_reservation_entries()
+
+ # Test - 7: Partial Delivery against Sales Order.
+ dn1 = make_delivery_note(so.name)
+
+ for item in dn1.items:
+ item.qty = randint(1, 10)
+
+ dn1.save()
+ dn1.submit()
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["delivered_qty", "status"]
+ )[0]
+ self.assertGreater(sre_details.delivered_qty, 0)
+ self.assertEqual(sre_details.status, "Partially Delivered")
+
+ # Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty.
+ with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}):
+ dn2 = make_delivery_note(so.name)
+
+ for item in dn2.items:
+ item.qty += randint(1, 10)
+
+ dn2.save()
+ dn2.submit()
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order",
+ so.name,
+ item.name,
+ fields=["reserved_qty", "delivered_qty"],
+ ignore_status=True,
+ )
+
+ for sre_detail in sre_details:
+ self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty)
+
+ @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",
+ },
+ )
+ def test_auto_reserve_serial_and_batch(self) -> None:
+ items_details = create_items()
+ create_material_receipt(items_details, self.warehouse, qty=100)
+
+ 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,
+ )
+ so.create_stock_reservation_entries()
+ so.load_from_db()
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["status", "reserved_qty"]
+ )[0]
+
+ # Test - 1: SRE Reserved Qty should be updated in Sales Order Item.
+ self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty)
+
+ # Test - 2: SRE status should be `Reserved`.
+ self.assertEqual(sre_details.status, "Reserved")
+
+ dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": 1})
+ dn.save()
+ dn.submit()
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["status", "delivered_qty", "reserved_qty"]
+ )[0]
+
+ # Test - 3: After Delivery Note, SRE status should be `Delivered`.
+ self.assertEqual(sre_details.status, "Delivered")
+
+ # Test - 4: After Delivery Note, SRE Delivered Qty should be equal to SRE Reserved Qty.
+ self.assertEqual(sre_details.delivered_qty, sre_details.reserved_qty)
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sb_entry = frappe.qb.DocType("Serial and Batch Entry")
+ for item in dn.items:
+ if item.serial_and_batch_bundle:
+ reserved_sb_entries = (
+ frappe.qb.from_(sre)
+ .inner_join(sb_entry)
+ .on(sre.name == sb_entry.parent)
+ .select(sb_entry.serial_no, sb_entry.batch_no, sb_entry.qty, sb_entry.delivered_qty)
+ .where(
+ (sre.voucher_type == "Sales Order")
+ & (sre.voucher_no == item.against_sales_order)
+ & (sre.voucher_detail_no == item.so_detail)
+ )
+ ).run(as_dict=True)
+
+ reserved_sb_details: set[tuple] = set()
+ for sb_details in reserved_sb_entries:
+ # Test - 5: After Delivery Note, SB Entry Delivered Qty should be equal to SB Entry Reserved Qty.
+ self.assertEqual(sb_details.qty, sb_details.delivered_qty)
+
+ reserved_sb_details.add((sb_details.serial_no, sb_details.batch_no, -1 * sb_details.qty))
+
+ delivered_sb_entries = frappe.db.get_all(
+ "Serial and Batch Entry",
+ filters={"parent": item.serial_and_batch_bundle},
+ fields=["serial_no", "batch_no", "qty"],
+ as_list=True,
+ )
+ delivered_sb_details: set[tuple] = set(delivered_sb_entries)
+
+ # Test - 6: Reserved Serial/Batch Nos should be equal to Delivered Serial/Batch Nos.
+ self.assertSetEqual(reserved_sb_details, delivered_sb_details)
+
+ dn.cancel()
+ so.load_from_db()
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order",
+ so.name,
+ item.name,
+ fields=["name", "status", "delivered_qty", "reservation_based_on"],
+ )[0]
+
+ # Test - 7: After Delivery Note cancellation, SRE status should be `Reserved`.
+ self.assertEqual(sre_details.status, "Reserved")
+
+ # Test - 8: After Delivery Note cancellation, SRE Delivered Qty should be `0`.
+ self.assertEqual(sre_details.delivered_qty, 0)
+
+ if sre_details.reservation_based_on == "Serial and Batch":
+ sb_entries = frappe.db.get_all(
+ "Serial and Batch Entry",
+ filters={"parenttype": "Stock Reservation Entry", "parent": sre_details.name},
+ fields=["delivered_qty"],
+ )
+
+ for sb_entry in sb_entries:
+ # Test - 9: After Delivery Note cancellation, SB Entry Delivered Qty should be `0`.
+ self.assertEqual(sb_entry.delivered_qty, 0)
+
+ @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",
+ },
+ )
+ def test_stock_reservation_from_pick_list(self):
+ items_details = create_items()
+ create_material_receipt(items_details, self.warehouse, qty=100)
+
+ 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,
+ )
+ pl = create_pick_list(so.name)
+ pl.save()
+ pl.submit()
+ pl.create_stock_reservation_entries()
+ pl.load_from_db()
+ so.load_from_db()
+
+ for item in so.items:
+ sre_details = get_stock_reservation_entries_for_voucher(
+ "Sales Order", so.name, item.name, fields=["reserved_qty"]
+ )[0]
+
+ # Test - 1: SRE Reserved Qty should be updated in Sales Order Item.
+ self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty)
+
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ sb_entry = frappe.qb.DocType("Serial and Batch Entry")
+ for location in pl.locations:
+ # Test - 2: Reserved Qty should be updated in Pick List Item.
+ self.assertEqual(location.stock_reserved_qty, location.qty)
+
+ if location.serial_and_batch_bundle:
+ picked_sb_entries = frappe.db.get_all(
+ "Serial and Batch Entry",
+ filters={"parent": location.serial_and_batch_bundle},
+ fields=["serial_no", "batch_no", "qty"],
+ as_list=True,
+ )
+ picked_sb_details: set[tuple] = set(picked_sb_entries)
+
+ reserved_sb_entries = (
+ frappe.qb.from_(sre)
+ .inner_join(sb_entry)
+ .on(sre.name == sb_entry.parent)
+ .select(sb_entry.serial_no, sb_entry.batch_no, sb_entry.qty)
+ .where(
+ (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)
+ )
+ ).run(as_dict=True)
+ reserved_sb_details: set[tuple] = {
+ (sb_details.serial_no, sb_details.batch_no, -1 * sb_details.qty)
+ for sb_details in reserved_sb_entries
+ }
+
+ # Test - 3: Reserved Serial/Batch Nos should be equal to Picked Serial/Batch Nos.
+ self.assertSetEqual(picked_sb_details, reserved_sb_details)
+
def create_items() -> dict:
- from erpnext.stock.doctype.item.test_item import make_item
-
- items_details = {
- # Stock Items
- "SR Item 1": {"is_stock_item": 1, "valuation_rate": 100},
- "SR Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"},
- # Batch Items
- "SR Batch Item 1": {
+ items_properties = [
+ # SR STOCK ITEM
+ {"is_stock_item": 1, "valuation_rate": 100},
+ # SR SERIAL ITEM
+ {
"is_stock_item": 1,
- "valuation_rate": 100,
- "has_batch_no": 1,
- "create_new_batch": 1,
- "batch_number_series": "SRBI-1-.#####.",
+ "valuation_rate": 200,
+ "has_serial_no": 1,
+ "serial_no_series": "SRSI-.#####",
},
- "SR Batch Item 2": {
+ # SR BATCH ITEM
+ {
"is_stock_item": 1,
- "valuation_rate": 200,
+ "valuation_rate": 300,
"has_batch_no": 1,
"create_new_batch": 1,
- "batch_number_series": "SRBI-2-.#####.",
- "stock_uom": "Kg",
+ "batch_number_series": "SRBI-.#####.",
},
- # Serial Item
- "SR Serial Item 1": {
+ # SR SERIAL AND BATCH ITEM
+ {
"is_stock_item": 1,
- "valuation_rate": 100,
+ "valuation_rate": 400,
"has_serial_no": 1,
- "serial_no_series": "SRSI-1-.#####",
- },
- # Batch and Serial Item
- "SR Batch and Serial Item 1": {
- "is_stock_item": 1,
- "valuation_rate": 100,
+ "serial_no_series": "SRSBI-.#####",
"has_batch_no": 1,
"create_new_batch": 1,
- "batch_number_series": "SRBSI-1-.#####.",
- "has_serial_no": 1,
- "serial_no_series": "SRBSI-1-.#####",
+ "batch_number_series": "SRSBI-.#####.",
},
- }
+ ]
items = {}
- for item_code, properties in items_details.items():
- items[item_code] = make_item(item_code, properties)
+ for properties in items_properties:
+ item = make_item(properties=properties)
+ items[item.name] = item
return items
@@ -313,7 +650,7 @@ def make_stock_reservation_entry(**args):
doc = frappe.new_doc("Stock Reservation Entry")
args = frappe._dict(args)
- doc.item_code = args.item_code or "SR Item 1"
+ doc.item_code = args.item_code
doc.warehouse = args.warehouse or "_Test Warehouse - _TC"
doc.voucher_type = args.voucher_type
doc.voucher_no = args.voucher_no
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index 9d67cf9d7a17..88b5575a3730 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -34,8 +34,10 @@
"stock_reservation_tab",
"enable_stock_reservation",
"column_break_rx3e",
- "reserve_stock_on_sales_order_submission",
+ "auto_reserve_stock_for_sales_order",
"allow_partial_reservation",
+ "serial_and_batch_reservation_section",
+ "auto_reserve_serial_and_batch",
"serial_and_batch_item_settings_tab",
"section_break_7",
"auto_create_serial_and_batch_bundle_for_outward",
@@ -59,7 +61,8 @@
"stock_frozen_upto_days",
"column_break_26",
"role_allowed_to_create_edit_back_dated_transactions",
- "stock_auth_role"
+ "stock_auth_role",
+ "section_break_plhx"
],
"fields": [
{
@@ -337,18 +340,11 @@
},
{
"default": "0",
+ "description": "Allows to keep aside a specific quantity of inventory for a particular order.",
"fieldname": "enable_stock_reservation",
"fieldtype": "Check",
"label": "Enable Stock Reservation"
},
- {
- "default": "0",
- "depends_on": "eval: doc.enable_stock_reservation",
- "description": "If enabled, Stock Reservation Entries will be created on submission of Sales Order",
- "fieldname": "reserve_stock_on_sales_order_submission",
- "fieldtype": "Check",
- "label": "Reserve Stock on Sales Order Submission"
- },
{
"fieldname": "column_break_rx3e",
"fieldtype": "Column Break"
@@ -356,7 +352,7 @@
{
"default": "1",
"depends_on": "eval: doc.enable_stock_reservation",
- "description": "If enabled, Partial Stock Reservation Entries 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": "If enabled, Partial Stock Reservation Entries 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. ",
"fieldname": "allow_partial_reservation",
"fieldtype": "Check",
"label": "Allow Partial Reservation"
@@ -383,6 +379,27 @@
"fieldname": "auto_create_serial_and_batch_bundle_for_outward",
"fieldtype": "Check",
"label": "Auto Create Serial and Batch Bundle For Outward"
+ },
+ {
+ "default": "1",
+ "depends_on": "eval: doc.enable_stock_reservation",
+ "description": "If enabled, Serial and Batch Nos will be auto-reserved based on Pick Serial / Batch Based On",
+ "fieldname": "auto_reserve_serial_and_batch",
+ "fieldtype": "Check",
+ "label": "Auto Reserve Serial and Batch Nos"
+ },
+ {
+ "fieldname": "serial_and_batch_reservation_section",
+ "fieldtype": "Section Break",
+ "label": "Serial and Batch Reservation"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.enable_stock_reservation",
+ "description": "If enabled, Stock Reservation Entries will be created on submission of Sales Order",
+ "fieldname": "auto_reserve_stock_for_sales_order",
+ "fieldtype": "Check",
+ "label": "Auto Reserve Stock for Sales Order"
}
],
"icon": "icon-cog",
@@ -390,7 +407,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2023-05-29 15:10:54.959411",
+ "modified": "2023-09-01 16:16:34.018947",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py
index 3b6db64a308f..9ad3c9db2844 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.py
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.py
@@ -69,9 +69,9 @@ def validate_warehouses(self):
)
def cant_change_valuation_method(self):
- db_valuation_method = frappe.db.get_single_value("Stock Settings", "valuation_method")
+ previous_valuation_method = self.get_doc_before_save().get("valuation_method")
- if db_valuation_method and db_valuation_method != self.valuation_method:
+ if previous_valuation_method and previous_valuation_method != self.valuation_method:
# check if there are any stock ledger entries against items
# which does not have it's own valuation method
sle = frappe.db.sql(
@@ -108,13 +108,8 @@ def validate_stock_reservation(self):
if frappe.flags.in_test:
return
- db_allow_negative_stock = frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
- db_enable_stock_reservation = frappe.db.get_single_value(
- "Stock Settings", "enable_stock_reservation"
- )
-
# Change in value of `Allow Negative Stock`
- if db_allow_negative_stock != self.allow_negative_stock:
+ if self.has_value_changed("allow_negative_stock"):
# Disable -> Enable: Don't allow if `Stock Reservation` is enabled
if self.allow_negative_stock and self.enable_stock_reservation:
@@ -125,7 +120,7 @@ def validate_stock_reservation(self):
)
# Change in value of `Enable Stock Reservation`
- if db_enable_stock_reservation != self.enable_stock_reservation:
+ if self.has_value_changed("enable_stock_reservation"):
# Disable -> Enable
if self.enable_stock_reservation:
diff --git a/erpnext/stock/report/reserved_stock/__init__.py b/erpnext/stock/report/reserved_stock/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.js b/erpnext/stock/report/reserved_stock/reserved_stock.js
new file mode 100644
index 000000000000..2199f52df039
--- /dev/null
+++ b/erpnext/stock/report/reserved_stock/reserved_stock.js
@@ -0,0 +1,170 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.query_reports["Reserved Stock"] = {
+ filters: [
+ {
+ fieldname: "company",
+ label: __("Company"),
+ fieldtype: "Link",
+ options: "Company",
+ reqd: 1,
+ default: frappe.defaults.get_user_default("Company"),
+ },
+ {
+ fieldname: "from_date",
+ label: __("From Date"),
+ fieldtype: "Date",
+ default: frappe.datetime.add_months(
+ frappe.datetime.get_today(),
+ -1
+ ),
+ reqd: 1,
+ },
+ {
+ fieldname: "to_date",
+ label: __("To Date"),
+ fieldtype: "Date",
+ default: frappe.datetime.get_today(),
+ reqd: 1,
+ },
+ {
+ fieldname: "item_code",
+ label: __("Item"),
+ fieldtype: "Link",
+ options: "Item",
+ get_query: () => ({
+ filters: {
+ is_stock_item: 1,
+ },
+ }),
+ },
+ {
+ fieldname: "warehouse",
+ label: __("Warehouse"),
+ fieldtype: "Link",
+ options: "Warehouse",
+ get_query: () => ({
+ filters: {
+ is_group: 0,
+ company: frappe.query_report.get_filter_value("company"),
+ },
+ }),
+ },
+ {
+ fieldname: "stock_reservation_entry",
+ label: __("Stock Reservation Entry"),
+ fieldtype: "Link",
+ options: "Stock Reservation Entry",
+ get_query: () => ({
+ filters: {
+ docstatus: 1,
+ company: frappe.query_report.get_filter_value("company"),
+ },
+ }),
+ },
+ {
+ fieldname: "voucher_type",
+ label: __("Voucher Type"),
+ fieldtype: "Link",
+ options: "DocType",
+ default: "Sales Order",
+ get_query: () => ({
+ filters: {
+ name: ["in", ["Sales Order"]],
+ }
+ }),
+ },
+ {
+ fieldname: "voucher_no",
+ label: __("Voucher No"),
+ fieldtype: "Dynamic Link",
+ options: "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("voucher_type");
+ },
+ },
+ {
+ fieldname: "against_pick_list",
+ label: __("Against Pick List"),
+ fieldtype: "Link",
+ options: "Pick List",
+ get_query: () => ({
+ filters: {
+ docstatus: 1,
+ company: frappe.query_report.get_filter_value("company"),
+ },
+ }),
+ },
+ {
+ fieldname: "reservation_based_on",
+ label: __("Reservation Based On"),
+ fieldtype: "Select",
+ options: ["", "Qty", "Serial and Batch"],
+ },
+ {
+ fieldname: "status",
+ label: __("Status"),
+ fieldtype: "Select",
+ options: [
+ "",
+ "Partially Reserved",
+ "Reserved",
+ "Partially Delivered",
+ "Delivered",
+ ],
+ },
+ {
+ fieldname: "project",
+ label: __("Project"),
+ fieldtype: "Link",
+ options: "Project",
+ get_query: () => ({
+ filters: {
+ company: frappe.query_report.get_filter_value("company"),
+ },
+ }),
+ },
+ ],
+ formatter: (value, row, column, data, default_formatter) => {
+ value = default_formatter(value, row, column, data);
+
+ if (column.fieldname == "status") {
+ switch (data.status) {
+ case "Partially Reserved":
+ value = "" + value + "";
+ break;
+ case "Reserved":
+ value = "" + value + "";
+ break;
+ case "Partially Delivered":
+ value = "" + value + "";
+ break;
+ case "Delivered":
+ value = "" + value + "";
+ break;
+ }
+ }
+ else if (column.fieldname == "delivered_qty") {
+ if (data.delivered_qty > 0) {
+ if (data.reserved_qty > data.delivered_qty) {
+ value = "" + value + "";
+ }
+ else {
+ value = "" + value + "";
+ }
+ }
+ else {
+ value = "" + value + "";
+ }
+ }
+
+ return value;
+ },
+};
diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.json b/erpnext/stock/report/reserved_stock/reserved_stock.json
new file mode 100644
index 000000000000..17b916afda80
--- /dev/null
+++ b/erpnext/stock/report/reserved_stock/reserved_stock.json
@@ -0,0 +1,26 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2023-08-02 22:11:19.439620",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letterhead": null,
+ "modified": "2023-08-03 12:46:33.780222",
+ "modified_by": "Administrator",
+ "module": "Stock",
+ "name": "Reserved Stock",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Stock Reservation Entry",
+ "report_name": "Reserved Stock",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "System Manager"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.py b/erpnext/stock/report/reserved_stock/reserved_stock.py
new file mode 100644
index 000000000000..d93ee1c88f8e
--- /dev/null
+++ b/erpnext/stock/report/reserved_stock/reserved_stock.py
@@ -0,0 +1,191 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.query_builder.functions import Date
+
+
+def execute(filters=None):
+ columns, data = [], []
+
+ validate_filters(filters)
+
+ columns = get_columns()
+ data = get_data(filters)
+
+ return columns, data
+
+
+def validate_filters(filters):
+ if not filters:
+ frappe.throw(_("Please set filters"))
+
+ for field in ["company", "from_date", "to_date"]:
+ if not filters.get(field):
+ frappe.throw(_("Please set {0}").format(field))
+
+ if filters.get("from_date") > filters.get("to_date"):
+ frappe.throw(_("From Date cannot be greater than To Date"))
+
+
+def get_data(filters):
+ sre = frappe.qb.DocType("Stock Reservation Entry")
+ query = (
+ frappe.qb.from_(sre)
+ .select(
+ sre.creation,
+ sre.warehouse,
+ sre.item_code,
+ sre.stock_uom,
+ sre.voucher_qty,
+ sre.reserved_qty,
+ sre.delivered_qty,
+ (sre.available_qty - sre.reserved_qty).as_("available_qty"),
+ sre.voucher_type,
+ sre.voucher_no,
+ sre.against_pick_list,
+ sre.name.as_("stock_reservation_entry"),
+ sre.status,
+ sre.project,
+ sre.company,
+ )
+ .where(
+ (sre.docstatus == 1)
+ & (sre.company == filters.get("company"))
+ & (
+ (Date(sre.creation) >= filters.get("from_date"))
+ & (Date(sre.creation) <= filters.get("to_date"))
+ )
+ )
+ )
+
+ for field in [
+ "item_code",
+ "warehouse",
+ "voucher_type",
+ "voucher_no",
+ "against_pick_list",
+ "reservation_based_on",
+ "status",
+ "project",
+ ]:
+ if value := filters.get(field):
+ query = query.where((sre[field] == value))
+
+ if value := filters.get("stock_reservation_entry"):
+ query = query.where((sre.name == value))
+
+ data = query.run(as_list=True)
+
+ return data
+
+
+def get_columns():
+ columns = [
+ {
+ "label": _("Date"),
+ "fieldname": "date",
+ "fieldtype": "Datetime",
+ "width": 150,
+ },
+ {
+ "fieldname": "warehouse",
+ "label": _("Warehouse"),
+ "fieldtype": "Link",
+ "options": "Warehouse",
+ "width": 150,
+ },
+ {
+ "fieldname": "item_code",
+ "label": _("Item"),
+ "fieldtype": "Link",
+ "options": "Item",
+ "width": 100,
+ },
+ {
+ "fieldname": "stock_uom",
+ "label": _("Stock UOM"),
+ "fieldtype": "Link",
+ "options": "UOM",
+ "width": 100,
+ },
+ {
+ "fieldname": "voucher_qty",
+ "label": _("Voucher Qty"),
+ "fieldtype": "Float",
+ "width": 110,
+ "convertible": "qty",
+ },
+ {
+ "fieldname": "reserved_qty",
+ "label": _("Reserved Qty"),
+ "fieldtype": "Float",
+ "width": 110,
+ "convertible": "qty",
+ },
+ {
+ "fieldname": "delivered_qty",
+ "label": _("Delivered Qty"),
+ "fieldtype": "Float",
+ "width": 110,
+ "convertible": "qty",
+ },
+ {
+ "fieldname": "available_qty",
+ "label": _("Available Qty to Reserve"),
+ "fieldtype": "Float",
+ "width": 120,
+ "convertible": "qty",
+ },
+ {
+ "fieldname": "voucher_type",
+ "label": _("Voucher Type"),
+ "fieldtype": "Data",
+ "options": "Warehouse",
+ "width": 110,
+ },
+ {
+ "fieldname": "voucher_no",
+ "label": _("Voucher No"),
+ "fieldtype": "Dynamic Link",
+ "options": "voucher_type",
+ "width": 120,
+ },
+ {
+ "fieldname": "against_pick_list",
+ "label": _("Against Pick List"),
+ "fieldtype": "Link",
+ "options": "Pick List",
+ "width": 130,
+ },
+ {
+ "fieldname": "stock_reservation_entry",
+ "label": _("Stock Reservation Entry"),
+ "fieldtype": "Link",
+ "options": "Stock Reservation Entry",
+ "width": 150,
+ },
+ {
+ "fieldname": "status",
+ "label": _("Status"),
+ "fieldtype": "Data",
+ "width": 120,
+ },
+ {
+ "fieldname": "project",
+ "label": _("Project"),
+ "fieldtype": "Link",
+ "options": "Project",
+ "width": 100,
+ },
+ {
+ "fieldname": "company",
+ "label": _("Company"),
+ "fieldtype": "Link",
+ "options": "Company",
+ "width": 110,
+ },
+ ]
+
+ return columns
diff --git a/erpnext/stock/report/reserved_stock/test_reserved_stock.py b/erpnext/stock/report/reserved_stock/test_reserved_stock.py
new file mode 100644
index 000000000000..6ef89f9aecda
--- /dev/null
+++ b/erpnext/stock/report/reserved_stock/test_reserved_stock.py
@@ -0,0 +1,54 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+from random import randint
+
+import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
+from frappe.utils.data import today
+
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import (
+ cancel_all_stock_reservation_entries,
+ create_items,
+ create_material_receipt,
+)
+from erpnext.stock.report.reserved_stock.reserved_stock import get_data as reserved_stock_report
+
+
+class TestReservedStock(FrappeTestCase):
+ def setUp(self) -> None:
+ super().setUp()
+ self.stock_qty = 100
+ self.warehouse = "_Test Warehouse - _TC"
+
+ def tearDown(self) -> None:
+ cancel_all_stock_reservation_entries()
+ return super().tearDown()
+
+ @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",
+ },
+ )
+ def test_reserved_stock_report(self):
+ items_details = create_items()
+ create_material_receipt(items_details, self.warehouse, qty=self.stock_qty)
+
+ for item_code, properties in items_details.items():
+ so = make_sales_order(
+ item_code=item_code, qty=randint(11, 100), warehouse=self.warehouse, uom=properties.stock_uom
+ )
+ so.create_stock_reservation_entries()
+
+ data = reserved_stock_report(
+ filters={
+ "company": so.company,
+ "from_date": today(),
+ "to_date": today(),
+ }
+ )
+ self.assertEqual(len(data), len(items_details))
diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js
index 33ed955a5c48..6de5f00ece84 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.js
+++ b/erpnext/stock/report/stock_balance/stock_balance.js
@@ -71,6 +71,14 @@ frappe.query_reports["Stock Balance"] = {
"width": "80",
"options": "Warehouse Type"
},
+ {
+ "fieldname": "valuation_field_type",
+ "label": __("Valuation Field Type"),
+ "fieldtype": "Select",
+ "width": "80",
+ "options": "Currency\nFloat",
+ "default": "Currency"
+ },
{
"fieldname":"include_uom",
"label": __("Include UOM"),
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index d60e9b57abec..102c3e14a8e5 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -165,7 +165,7 @@ def get_item_warehouse_map(self):
def get_sre_reserved_qty_details(self) -> dict:
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- get_sre_reserved_qty_details_for_item_and_warehouse as get_reserved_qty_details,
+ get_sre_reserved_qty_for_item_and_warehouse as get_reserved_qty_details,
)
item_code_list, warehouse_list = [], []
@@ -446,9 +446,12 @@ def get_columns(self):
{
"label": _("Valuation Rate"),
"fieldname": "val_rate",
- "fieldtype": "Float",
+ "fieldtype": self.filters.valuation_field_type or "Currency",
"width": 90,
"convertible": "rate",
+ "options": "Company:company:default_currency"
+ if self.filters.valuation_field_type == "Currency"
+ else None,
},
{
"label": _("Reserved Stock"),
diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js
index 0def161d2833..b00b422a67a3 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.js
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.js
@@ -82,7 +82,15 @@ frappe.query_reports["Stock Ledger"] = {
"label": __("Include UOM"),
"fieldtype": "Link",
"options": "UOM"
- }
+ },
+ {
+ "fieldname": "valuation_field_type",
+ "label": __("Valuation Field Type"),
+ "fieldtype": "Select",
+ "width": "80",
+ "options": "Currency\nFloat",
+ "default": "Currency"
+ },
],
"formatter": function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py
index ed28ed3ee46f..eeef39641b01 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.py
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.py
@@ -196,17 +196,21 @@ def get_columns(filters):
{
"label": _("Avg Rate (Balance Stock)"),
"fieldname": "valuation_rate",
- "fieldtype": "Float",
+ "fieldtype": filters.valuation_field_type,
"width": 180,
- "options": "Company:company:default_currency",
+ "options": "Company:company:default_currency"
+ if filters.valuation_field_type == "Currency"
+ else None,
"convertible": "rate",
},
{
"label": _("Valuation Rate"),
"fieldname": "in_out_rate",
- "fieldtype": "Float",
+ "fieldtype": filters.valuation_field_type,
"width": 140,
- "options": "Company:company:default_currency",
+ "options": "Company:company:default_currency"
+ if filters.valuation_field_type == "Currency"
+ else None,
"convertible": "rate",
},
{
diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py
index d6c840f1339c..5998274bb768 100644
--- a/erpnext/stock/serial_batch_bundle.py
+++ b/erpnext/stock/serial_batch_bundle.py
@@ -862,7 +862,7 @@ def set_serial_batch_entries(self, doc):
if self.get("serial_nos"):
serial_no_wise_batch = frappe._dict({})
if self.has_batch_no:
- serial_no_wise_batch = self.get_serial_nos_batch(self.serial_nos)
+ serial_no_wise_batch = get_serial_nos_batch(self.serial_nos)
qty = -1 if self.type_of_transaction == "Outward" else 1
for serial_no in self.serial_nos:
@@ -887,16 +887,6 @@ def set_serial_batch_entries(self, doc):
},
)
- def get_serial_nos_batch(self, serial_nos):
- return frappe._dict(
- frappe.get_all(
- "Serial No",
- fields=["name", "batch_no"],
- filters={"name": ("in", serial_nos)},
- as_list=1,
- )
- )
-
def create_batch(self):
from erpnext.stock.doctype.batch.batch import make_batch
@@ -974,3 +964,14 @@ def get_serial_or_batch_items(items):
serial_or_batch_items = [d.name for d in serial_or_batch_items]
return serial_or_batch_items
+
+
+def get_serial_nos_batch(serial_nos):
+ return frappe._dict(
+ frappe.get_all(
+ "Serial No",
+ fields=["name", "batch_no"],
+ filters={"name": ("in", serial_nos)},
+ as_list=1,
+ )
+ )
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 258a503dd5f6..db71fe280ac2 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1214,9 +1214,15 @@ def raise_exceptions(self):
if msg:
if self.reserved_stock:
allowed_qty = abs(exceptions[0]["actual_qty"]) - abs(exceptions[0]["diff"])
- msg = "{0} As {1} units are reserved, you are allowed to consume only {2} units.".format(
- msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty)
- )
+
+ if allowed_qty > 0:
+ msg = "{0} As {1} units are reserved for other sales orders, you are allowed to consume only {2} units.".format(
+ msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty)
+ )
+ else:
+ msg = "{0} As the full stock is reserved for other sales orders, you're not allowed to consume the stock.".format(
+ msg,
+ )
msg_list.append(msg)
diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/__init__.py b/erpnext/subcontracting/doctype/subcontracting_bom/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.js b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.js
new file mode 100644
index 000000000000..a7f0d7a20b4e
--- /dev/null
+++ b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.js
@@ -0,0 +1,40 @@
+// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Subcontracting BOM', {
+ setup: (frm) => {
+ frm.trigger('set_queries');
+ },
+
+ set_queries: (frm) => {
+ frm.set_query('finished_good', () => {
+ return {
+ filters: {
+ disabled: 0,
+ is_stock_item: 1,
+ default_bom: ['!=', ''],
+ is_sub_contracted_item: 1,
+ }
+ }
+ });
+
+ frm.set_query('finished_good_bom', () => {
+ return {
+ filters: {
+ docstatus: 1,
+ is_active: 1,
+ item: frm.doc.finished_good,
+ }
+ }
+ });
+
+ frm.set_query('service_item', () => {
+ return {
+ filters: {
+ disabled: 0,
+ is_stock_item: 0,
+ }
+ }
+ });
+ }
+});
diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json
new file mode 100644
index 000000000000..b258afc5b61e
--- /dev/null
+++ b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json
@@ -0,0 +1,155 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "format:SB-{####}",
+ "creation": "2023-08-29 12:43:20.417184",
+ "default_view": "List",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "is_active",
+ "section_break_dsjm",
+ "finished_good",
+ "finished_good_qty",
+ "column_break_quoy",
+ "finished_good_uom",
+ "finished_good_bom",
+ "section_break_qdw9",
+ "service_item",
+ "service_item_qty",
+ "column_break_uzmw",
+ "service_item_uom",
+ "conversion_factor"
+ ],
+ "fields": [
+ {
+ "default": "1",
+ "fieldname": "is_active",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "in_preview": 1,
+ "in_standard_filter": 1,
+ "label": "Is Active",
+ "print_hide": 1
+ },
+ {
+ "fieldname": "section_break_dsjm",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "finished_good",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_preview": 1,
+ "in_standard_filter": 1,
+ "label": "Finished Good",
+ "options": "Item",
+ "reqd": 1,
+ "search_index": 1,
+ "set_only_once": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "finished_good_qty",
+ "fieldtype": "Float",
+ "label": "Finished Good Qty",
+ "non_negative": 1,
+ "reqd": 1
+ },
+ {
+ "fetch_from": "finished_good.default_bom",
+ "fetch_if_empty": 1,
+ "fieldname": "finished_good_bom",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_preview": 1,
+ "in_standard_filter": 1,
+ "label": "Finished Good BOM",
+ "options": "BOM",
+ "reqd": 1,
+ "search_index": 1
+ },
+ {
+ "fieldname": "section_break_qdw9",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "service_item",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_preview": 1,
+ "in_standard_filter": 1,
+ "label": "Service Item",
+ "options": "Item",
+ "reqd": 1,
+ "search_index": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "service_item_qty",
+ "fieldtype": "Float",
+ "label": "Service Item Qty",
+ "non_negative": 1,
+ "reqd": 1
+ },
+ {
+ "fetch_from": "service_item.stock_uom",
+ "fetch_if_empty": 1,
+ "fieldname": "service_item_uom",
+ "fieldtype": "Link",
+ "label": "Service Item UOM",
+ "options": "UOM",
+ "reqd": 1
+ },
+ {
+ "description": "Service Item Qty / Finished Good Qty",
+ "fieldname": "conversion_factor",
+ "fieldtype": "Float",
+ "label": "Conversion Factor",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_quoy",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "finished_good.stock_uom",
+ "fieldname": "finished_good_uom",
+ "fieldtype": "Link",
+ "label": "Finished Good UOM",
+ "options": "UOM",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_uzmw",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2023-09-03 16:51:43.558295",
+ "modified_by": "Administrator",
+ "module": "Subcontracting",
+ "name": "Subcontracting BOM",
+ "naming_rule": "Expression",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.py b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.py
new file mode 100644
index 000000000000..49ba98653f50
--- /dev/null
+++ b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.py
@@ -0,0 +1,97 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import flt
+
+
+class SubcontractingBOM(Document):
+ def validate(self):
+ self.validate_finished_good()
+ self.validate_service_item()
+ self.validate_is_active()
+
+ def before_save(self):
+ self.set_conversion_factor()
+
+ def validate_finished_good(self):
+ disabled, is_stock_item, default_bom, is_sub_contracted_item = frappe.db.get_value(
+ "Item",
+ self.finished_good,
+ ["disabled", "is_stock_item", "default_bom", "is_sub_contracted_item"],
+ )
+
+ if disabled:
+ frappe.throw(_("Finished Good {0} is disabled.").format(frappe.bold(self.finished_good)))
+ if not is_stock_item:
+ frappe.throw(
+ _("Finished Good {0} must be a stock item.").format(frappe.bold(self.finished_good))
+ )
+ if not default_bom:
+ frappe.throw(
+ _("Finished Good {0} does not have a default BOM.").format(frappe.bold(self.finished_good))
+ )
+ if not is_sub_contracted_item:
+ frappe.throw(
+ _("Finished Good {0} must be a sub-contracted item.").format(frappe.bold(self.finished_good))
+ )
+
+ def validate_service_item(self):
+ disabled, is_stock_item = frappe.db.get_value(
+ "Item", self.service_item, ["disabled", "is_stock_item"]
+ )
+
+ if disabled:
+ frappe.throw(_("Service Item {0} is disabled.").format(frappe.bold(self.service_item)))
+ if is_stock_item:
+ frappe.throw(
+ _("Service Item {0} must be a non-stock item.").format(frappe.bold(self.service_item))
+ )
+
+ def validate_is_active(self):
+ if self.is_active:
+ if sb := frappe.db.exists(
+ "Subcontracting BOM",
+ {"finished_good": self.finished_good, "is_active": 1, "name": ["!=", self.name]},
+ ):
+ frappe.throw(
+ _("There is already an active Subcontracting BOM {0} for the Finished Good {1}.").format(
+ frappe.bold(sb), frappe.bold(self.finished_good)
+ )
+ )
+
+ def set_conversion_factor(self):
+ self.conversion_factor = flt(self.service_item_qty) / flt(self.finished_good_qty)
+
+
+@frappe.whitelist()
+def get_subcontracting_boms_for_finished_goods(fg_items: str | list) -> dict:
+ if fg_items:
+ filters = {"is_active": 1}
+
+ if isinstance(fg_items, list):
+ filters["finished_good"] = ["in", fg_items]
+ else:
+ filters["finished_good"] = fg_items
+
+ if subcontracting_boms := frappe.get_all("Subcontracting BOM", filters=filters, fields=["*"]):
+ if isinstance(fg_items, list):
+ return {d.finished_good: d for d in subcontracting_boms}
+ else:
+ return subcontracting_boms[0]
+
+ return {}
+
+
+@frappe.whitelist()
+def get_subcontracting_boms_for_service_item(service_item: str) -> dict:
+ if service_item:
+ filters = {"is_active": 1, "service_item": service_item}
+ Subcontracting_boms = frappe.db.get_all("Subcontracting BOM", filters=filters, fields=["*"])
+
+ if Subcontracting_boms:
+ return {d.finished_good: d for d in Subcontracting_boms}
+
+ return {}
diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/test_subcontracting_bom.py b/erpnext/subcontracting/doctype/subcontracting_bom/test_subcontracting_bom.py
new file mode 100644
index 000000000000..9335ac8cba0d
--- /dev/null
+++ b/erpnext/subcontracting/doctype/subcontracting_bom/test_subcontracting_bom.py
@@ -0,0 +1,26 @@
+# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+
+
+class TestSubcontractingBOM(FrappeTestCase):
+ pass
+
+
+def create_subcontracting_bom(**kwargs):
+ kwargs = frappe._dict(kwargs)
+
+ doc = frappe.new_doc("Subcontracting BOM")
+ doc.is_active = kwargs.is_active or 1
+ doc.finished_good = kwargs.finished_good
+ doc.finished_good_uom = kwargs.finished_good_uom
+ doc.finished_good_qty = kwargs.finished_good_qty or 1
+ doc.finished_good_bom = kwargs.finished_good_bom
+ doc.service_item = kwargs.service_item
+ doc.service_item_uom = kwargs.service_item_uom
+ doc.service_item_qty = kwargs.service_item_qty or 1
+ doc.save()
+
+ return doc
diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py
index b7b344584cfc..faf0cadb7550 100644
--- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py
+++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py
@@ -128,8 +128,12 @@ def populate_items_table(self):
for si in self.service_items:
if si.fg_item:
item = frappe.get_doc("Item", si.fg_item)
- bom = frappe.db.get_value("BOM", {"item": item.item_code, "is_active": 1, "is_default": 1})
-
+ bom = (
+ frappe.db.get_value(
+ "Subcontracting BOM", {"finished_good": item.item_code, "is_active": 1}, "finished_good_bom"
+ )
+ or item.default_bom
+ )
items.append(
{
"item_code": item.item_code,
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
index acf955305267..dd071e605103 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
@@ -239,12 +239,6 @@ frappe.ui.form.on('Subcontracting Receipt Item', {
set_missing_values(frm);
},
- recalculate_rate(frm) {
- if (frm.doc.recalculate_rate) {
- set_missing_values(frm);
- }
- },
-
items_remove: (frm) => {
set_missing_values(frm);
},
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
index 8a12e3bcd03d..6aecaf98a5dd 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
@@ -180,7 +180,6 @@ def get_scrap_items(self, recalculate_rate=False):
"item_name": scrap_item.item_name,
"qty": qty,
"stock_uom": scrap_item.stock_uom,
- "recalculate_rate": 0,
"rate": rate,
"rm_cost_per_qty": 0,
"service_cost_per_qty": 0,
@@ -277,13 +276,12 @@ def calculate_items_qty_and_amount(self):
else:
item.scrap_cost_per_qty = 0
- if item.recalculate_rate:
- item.rate = (
- flt(item.rm_cost_per_qty)
- + flt(item.service_cost_per_qty)
- + flt(item.additional_cost_per_qty)
- - flt(item.scrap_cost_per_qty)
- )
+ item.rate = (
+ flt(item.rm_cost_per_qty)
+ + flt(item.service_cost_per_qty)
+ + flt(item.additional_cost_per_qty)
+ - flt(item.scrap_cost_per_qty)
+ )
item.received_qty = flt(item.qty) + flt(item.rejected_qty)
item.amount = flt(item.qty) * flt(item.rate)
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json
index c036390ba373..38432beb4411 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json
@@ -28,7 +28,6 @@
"rate_and_amount",
"rate",
"amount",
- "recalculate_rate",
"column_break_19",
"rm_cost_per_qty",
"service_cost_per_qty",
@@ -202,7 +201,6 @@
"options": "currency",
"print_width": "100px",
"read_only": 1,
- "read_only_depends_on": "eval: doc.recalculate_rate",
"width": "100px"
},
{
@@ -475,14 +473,6 @@
"fieldtype": "Section Break",
"label": "Accounting Details"
},
- {
- "default": "1",
- "depends_on": "eval: !doc.is_scrap_item",
- "fieldname": "recalculate_rate",
- "fieldtype": "Check",
- "label": "Recalculate Rate",
- "read_only_depends_on": "eval: doc.is_scrap_item"
- },
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
@@ -531,7 +521,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2023-08-25 20:09:03.069417",
+ "modified": "2023-09-03 17:04:21.214316",
"modified_by": "Administrator",
"module": "Subcontracting",
"name": "Subcontracting Receipt Item",
diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json
index 1c6f24b23ce8..588203307584 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json
@@ -192,7 +192,7 @@
}
],
"links": [],
- "modified": "2023-04-21 17:16:56.192560",
+ "modified": "2023-08-28 22:17:54.740924",
"modified_by": "Administrator",
"module": "Support",
"name": "Service Level Agreement",
@@ -213,7 +213,7 @@
},
{
"read": 1,
- "role": "All"
+ "role": "Desk User"
}
],
"sort_field": "modified",