Merge pull request #29736 from rohitwaghchaure/performance-issue-while-making-wo
fix: time out error while making work orders from production plan
diff --git a/cypress/integration/test_bulk_transaction_processing.js b/cypress/integration/test_bulk_transaction_processing.js
new file mode 100644
index 0000000..428ec51
--- /dev/null
+++ b/cypress/integration/test_bulk_transaction_processing.js
@@ -0,0 +1,44 @@
+describe("Bulk Transaction Processing", () => {
+ before(() => {
+ cy.login();
+ cy.visit("/app/website");
+ });
+
+ it("Creates To Sales Order", () => {
+ cy.visit("/app/sales-order");
+ cy.url().should("include", "/sales-order");
+ cy.window()
+ .its("frappe.csrf_token")
+ .then((csrf_token) => {
+ return cy
+ .request({
+ url: "/api/method/erpnext.tests.ui_test_bulk_transaction_processing.create_records",
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ "X-Frappe-CSRF-Token": csrf_token,
+ },
+ timeout: 60000,
+ })
+ .then((res) => {
+ expect(res.status).eq(200);
+ });
+ });
+ cy.wait(5000);
+ cy.get(
+ ".list-row-head > .list-header-subject > .list-row-col > .list-check-all"
+ ).check({ force: true });
+ cy.wait(3000);
+ cy.get(".actions-btn-group > .btn-primary").click({ force: true });
+ cy.wait(3000);
+ cy.get(".dropdown-menu-right > .user-action > .dropdown-item")
+ .contains("Sales Invoice")
+ .click({ force: true });
+ cy.wait(3000);
+ cy.get(".modal-content > .modal-footer > .standard-actions")
+ .contains("Yes")
+ .click({ force: true });
+ cy.contains("Creation of Sales Invoice successful");
+ });
+});
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
index 335f850..dbf3622 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
@@ -14,6 +14,10 @@
});
},
+ onload: function (frm) {
+ frm.trigger('bank_account');
+ },
+
refresh: function (frm) {
frappe.require("bank-reconciliation-tool.bundle.js", () =>
frm.trigger("make_reconciliation_tool")
@@ -51,7 +55,7 @@
bank_account: function (frm) {
frappe.db.get_value(
"Bank Account",
- frm.bank_account,
+ frm.doc.bank_account,
"account",
(r) => {
frappe.db.get_value(
diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
index ba751c0..cf8affd 100644
--- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
@@ -586,23 +586,29 @@
item_price.insert()
pr = make_pricing_rule(selling=1, priority=5, discount_percentage=10)
pr.save()
- pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
- pos_inv.items[0].rate = 300
- pos_inv.save()
- self.assertEquals(pos_inv.items[0].discount_percentage, 10)
- # rate shouldn't change
- self.assertEquals(pos_inv.items[0].rate, 405)
- pos_inv.ignore_pricing_rule = 1
- pos_inv.items[0].rate = 300
- pos_inv.save()
- self.assertEquals(pos_inv.ignore_pricing_rule, 1)
- # rate should change since pricing rules are ignored
- self.assertEquals(pos_inv.items[0].rate, 300)
+ try:
+ pos_inv = create_pos_invoice(qty=1, do_not_submit=1)
+ pos_inv.items[0].rate = 300
+ pos_inv.save()
+ self.assertEquals(pos_inv.items[0].discount_percentage, 10)
+ # rate shouldn't change
+ self.assertEquals(pos_inv.items[0].rate, 405)
- item_price.delete()
- pos_inv.delete()
- pr.delete()
+ pos_inv.ignore_pricing_rule = 1
+ pos_inv.save()
+ self.assertEquals(pos_inv.ignore_pricing_rule, 1)
+ # rate should reset since pricing rules are ignored
+ self.assertEquals(pos_inv.items[0].rate, 450)
+
+ pos_inv.items[0].rate = 300
+ pos_inv.save()
+ self.assertEquals(pos_inv.items[0].rate, 300)
+
+ finally:
+ item_price.delete()
+ pos_inv.delete()
+ pr.delete()
def create_pos_invoice(**args):
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
index 0720d9b..ddca68a 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/pos_invoice_merge_log.py
@@ -84,12 +84,20 @@
sales_invoice.set_posting_time = 1
sales_invoice.posting_date = getdate(self.posting_date)
sales_invoice.save()
+ self.write_off_fractional_amount(sales_invoice, data)
sales_invoice.submit()
self.consolidated_invoice = sales_invoice.name
return sales_invoice.name
+ def write_off_fractional_amount(self, invoice, data):
+ pos_invoice_grand_total = sum(d.grand_total for d in data)
+
+ if abs(pos_invoice_grand_total - invoice.grand_total) < 1:
+ invoice.write_off_amount += -1 * (pos_invoice_grand_total - invoice.grand_total)
+ invoice.save()
+
def process_merging_into_credit_note(self, data):
credit_note = self.get_new_sales_invoice()
credit_note.is_return = 1
@@ -102,6 +110,7 @@
# TODO: return could be against multiple sales invoice which could also have been consolidated?
# credit_note.return_against = self.consolidated_invoice
credit_note.save()
+ self.write_off_fractional_amount(credit_note, data)
credit_note.submit()
self.consolidated_credit_note = credit_note.name
@@ -135,9 +144,15 @@
i.uom == item.uom and i.net_rate == item.net_rate and i.warehouse == item.warehouse):
found = True
i.qty = i.qty + item.qty
+ i.amount = i.amount + item.net_amount
+ i.net_amount = i.amount
+ i.base_amount = i.base_amount + item.base_net_amount
+ i.base_net_amount = i.base_amount
if not found:
item.rate = item.net_rate
+ item.amount = item.net_amount
+ item.base_amount = item.base_net_amount
item.price_list_rate = 0
si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"})
items.append(si_item)
@@ -169,6 +184,7 @@
found = True
if not found:
payments.append(payment)
+
rounding_adjustment += doc.rounding_adjustment
rounded_total += doc.rounded_total
base_rounding_adjustment += doc.base_rounding_adjustment
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
index 3555da8..5930aa0 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
@@ -12,6 +12,7 @@
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
consolidate_pos_invoices,
)
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
class TestPOSInvoiceMergeLog(unittest.TestCase):
@@ -150,3 +151,132 @@
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")
+
+
+ def test_consolidation_round_off_error_1(self):
+ '''
+ Test round off error in consolidated invoice creation if POS Invoice has inclusive tax
+ '''
+
+ frappe.db.sql("delete from `tabPOS Invoice`")
+
+ try:
+ make_stock_entry(
+ to_warehouse="_Test Warehouse - _TC",
+ item_code="_Test Item",
+ rate=8000,
+ qty=10,
+ )
+
+ init_user_and_profile()
+
+ inv = create_pos_invoice(qty=3, rate=10000, do_not_save=True)
+ inv.append("taxes", {
+ "account_head": "_Test Account VAT - _TC",
+ "charge_type": "On Net Total",
+ "cost_center": "_Test Cost Center - _TC",
+ "description": "VAT",
+ "doctype": "Sales Taxes and Charges",
+ "rate": 7.5,
+ "included_in_print_rate": 1
+ })
+ inv.append('payments', {
+ 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000
+ })
+ inv.insert()
+ inv.submit()
+
+ inv2 = create_pos_invoice(qty=3, rate=10000, do_not_save=True)
+ inv2.append("taxes", {
+ "account_head": "_Test Account VAT - _TC",
+ "charge_type": "On Net Total",
+ "cost_center": "_Test Cost Center - _TC",
+ "description": "VAT",
+ "doctype": "Sales Taxes and Charges",
+ "rate": 7.5,
+ "included_in_print_rate": 1
+ })
+ inv2.append('payments', {
+ 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 30000
+ })
+ inv2.insert()
+ inv2.submit()
+
+ consolidate_pos_invoices()
+
+ inv.load_from_db()
+ consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice)
+ self.assertEqual(consolidated_invoice.outstanding_amount, 0)
+ self.assertEqual(consolidated_invoice.status, 'Paid')
+
+ finally:
+ frappe.set_user("Administrator")
+ frappe.db.sql("delete from `tabPOS Profile`")
+ frappe.db.sql("delete from `tabPOS Invoice`")
+
+ def test_consolidation_round_off_error_2(self):
+ '''
+ Test the same case as above but with an Unpaid POS Invoice
+ '''
+ frappe.db.sql("delete from `tabPOS Invoice`")
+
+ try:
+ make_stock_entry(
+ to_warehouse="_Test Warehouse - _TC",
+ item_code="_Test Item",
+ rate=8000,
+ qty=10,
+ )
+
+ init_user_and_profile()
+
+ inv = create_pos_invoice(qty=6, rate=10000, do_not_save=True)
+ inv.append("taxes", {
+ "account_head": "_Test Account VAT - _TC",
+ "charge_type": "On Net Total",
+ "cost_center": "_Test Cost Center - _TC",
+ "description": "VAT",
+ "doctype": "Sales Taxes and Charges",
+ "rate": 7.5,
+ "included_in_print_rate": 1
+ })
+ inv.append('payments', {
+ 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000
+ })
+ inv.insert()
+ inv.submit()
+
+ inv2 = create_pos_invoice(qty=6, rate=10000, do_not_save=True)
+ inv2.append("taxes", {
+ "account_head": "_Test Account VAT - _TC",
+ "charge_type": "On Net Total",
+ "cost_center": "_Test Cost Center - _TC",
+ "description": "VAT",
+ "doctype": "Sales Taxes and Charges",
+ "rate": 7.5,
+ "included_in_print_rate": 1
+ })
+ inv2.append('payments', {
+ 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 60000
+ })
+ inv2.insert()
+ inv2.submit()
+
+ inv3 = create_pos_invoice(qty=3, rate=600, do_not_save=True)
+ inv3.append('payments', {
+ 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 1000
+ })
+ inv3.insert()
+ inv3.submit()
+
+ consolidate_pos_invoices()
+
+ inv.load_from_db()
+ consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice)
+ self.assertEqual(consolidated_invoice.outstanding_amount, 800)
+ self.assertNotEqual(consolidated_invoice.status, 'Paid')
+
+ finally:
+ frappe.set_user("Administrator")
+ frappe.db.sql("delete from `tabPOS Profile`")
+ frappe.db.sql("delete from `tabPOS Invoice`")
diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
index ac96b04..933fda8 100644
--- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py
@@ -249,13 +249,17 @@
"free_item_data": [],
"parent": args.parent,
"parenttype": args.parenttype,
- "child_docname": args.get('child_docname')
+ "child_docname": args.get('child_docname'),
})
if args.ignore_pricing_rule or not args.item_code:
if frappe.db.exists(args.doctype, args.name) and args.get("pricing_rules"):
- item_details = remove_pricing_rule_for_item(args.get("pricing_rules"),
- item_details, args.get('item_code'))
+ item_details = remove_pricing_rule_for_item(
+ args.get("pricing_rules"),
+ item_details,
+ item_code=args.get("item_code"),
+ rate=args.get("price_list_rate"),
+ )
return item_details
update_args_for_pricing_rule(args)
@@ -308,8 +312,12 @@
if not doc: return item_details
elif args.get("pricing_rules"):
- item_details = remove_pricing_rule_for_item(args.get("pricing_rules"),
- item_details, args.get('item_code'))
+ item_details = remove_pricing_rule_for_item(
+ args.get("pricing_rules"),
+ item_details,
+ item_code=args.get("item_code"),
+ rate=args.get("price_list_rate"),
+ )
return item_details
@@ -390,7 +398,7 @@
item_details[field] += (pricing_rule.get(field, 0)
if pricing_rule else args.get(field, 0))
-def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None):
+def remove_pricing_rule_for_item(pricing_rules, item_details, item_code=None, rate=None):
from erpnext.accounts.doctype.pricing_rule.utils import (
get_applied_pricing_rules,
get_pricing_rule_items,
@@ -403,6 +411,7 @@
if pricing_rule.rate_or_discount == 'Discount Percentage':
item_details.discount_percentage = 0.0
item_details.discount_amount = 0.0
+ item_details.rate = rate or 0.0
if pricing_rule.rate_or_discount == 'Discount Amount':
item_details.discount_amount = 0.0
@@ -421,6 +430,7 @@
item_details.applied_on_items = ','.join(items)
item_details.pricing_rules = ''
+ item_details.pricing_rule_removed = True
return item_details
@@ -432,9 +442,12 @@
out = []
for item in item_list:
item = frappe._dict(item)
- if item.get('pricing_rules'):
- out.append(remove_pricing_rule_for_item(item.get("pricing_rules"),
- item, item.item_code))
+ if item.get("pricing_rules"):
+ out.append(
+ remove_pricing_rule_for_item(
+ item.get("pricing_rules"), item, item.item_code, item.get("price_list_rate")
+ )
+ )
return out
diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
index 968137e..8338a5b0 100644
--- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
@@ -628,6 +628,46 @@
for doc in [si, si1]:
doc.delete()
+ def test_remove_pricing_rule(self):
+ item = make_item("Water Flask")
+ make_item_price("Water Flask", "_Test Price List", 100)
+
+ pricing_rule_record = {
+ "doctype": "Pricing Rule",
+ "title": "_Test Water Flask Rule",
+ "apply_on": "Item Code",
+ "price_or_product_discount": "Price",
+ "items": [{
+ "item_code": "Water Flask",
+ }],
+ "selling": 1,
+ "currency": "INR",
+ "rate_or_discount": "Discount Percentage",
+ "discount_percentage": 20,
+ "company": "_Test Company"
+ }
+ rule = frappe.get_doc(pricing_rule_record)
+ rule.insert()
+
+ si = create_sales_invoice(do_not_save=True, item_code="Water Flask")
+ si.selling_price_list = "_Test Price List"
+ si.save()
+
+ self.assertEqual(si.items[0].price_list_rate, 100)
+ self.assertEqual(si.items[0].discount_percentage, 20)
+ self.assertEqual(si.items[0].rate, 80)
+
+ si.ignore_pricing_rule = 1
+ si.save()
+
+ self.assertEqual(si.items[0].discount_percentage, 0)
+ self.assertEqual(si.items[0].rate, 100)
+
+ si.delete()
+ rule.delete()
+ frappe.get_doc("Item Price", {"item_code": "Water Flask"}).delete()
+ item.delete()
+
def test_multiple_pricing_rules_with_min_qty(self):
make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4,
apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1")
@@ -648,6 +688,7 @@
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1")
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2")
+
test_dependencies = ["Campaign"]
def make_pricing_rule(**args):
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index 76d9cc7..2c31561 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -178,8 +178,8 @@
if self.supplier and account.account_type != "Payable":
frappe.throw(
- _("Please ensure {} account is a Payable account. Change the account type to Payable or select a different account.")
- .format(frappe.bold("Credit To")), title=_("Invalid Account")
+ _("Please ensure {} account {} is a Payable account. Change the account type to Payable or select a different account.")
+ .format(frappe.bold("Credit To"), frappe.bold(self.credit_to)), title=_("Invalid Account")
)
self.party_account_currency = account.account_currency
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
index f6ff83a..82d0030 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice_list.js
@@ -56,4 +56,14 @@
];
}
},
+
+ onload: function(listview) {
+ listview.page.add_action_item(__("Purchase Receipt"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Purchase Receipt");
+ });
+
+ listview.page.add_action_item(__("Payment"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Purchase Invoice", "Payment");
+ });
+ }
};
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index bc44358..b894f90 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -285,7 +285,7 @@
filters={ invoice_or_credit_note: self.name },
pluck="pos_closing_entry"
)
- if pos_closing_entry:
+ if pos_closing_entry and pos_closing_entry[0]:
msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
frappe.bold("Consolidated Sales Invoice"),
get_link_to_form("POS Closing Entry", pos_closing_entry[0])
@@ -572,7 +572,10 @@
frappe.throw(msg, title=_("Invalid Account"))
if self.customer and account.account_type != "Receivable":
- msg = _("Please ensure {} account is a Receivable account.").format(frappe.bold("Debit To")) + " "
+ msg = _("Please ensure {} account {} is a Receivable account.").format(
+ frappe.bold("Debit To"),
+ frappe.bold(self.debit_to)
+ ) + " "
msg += _("Change the account type to Receivable or select a different account.")
frappe.throw(msg, title=_("Invalid Account"))
@@ -1249,14 +1252,14 @@
def update_billing_status_in_dn(self, update_modified=True):
updated_delivery_notes = []
for d in self.get("items"):
- if d.dn_detail:
+ if d.so_detail:
+ updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified)
+ elif d.dn_detail:
billed_amt = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item`
where dn_detail=%s and docstatus=1""", d.dn_detail)
billed_amt = billed_amt and billed_amt[0][0] or 0
frappe.db.set_value("Delivery Note Item", d.dn_detail, "billed_amt", billed_amt, update_modified=update_modified)
updated_delivery_notes.append(d.delivery_note)
- elif d.so_detail:
- updated_delivery_notes += update_billed_amount_based_on_so(d.so_detail, update_modified)
for dn in set(updated_delivery_notes):
frappe.get_doc("Delivery Note", dn).update_billing_percentage(update_modified=update_modified)
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js
index 06e6f51..1130284 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice_list.js
@@ -21,5 +21,15 @@
};
return [__(doc.status), status_colors[doc.status], "status,=,"+doc.status];
},
- right_column: "grand_total"
+ right_column: "grand_total",
+
+ onload: function(listview) {
+ listview.page.add_action_item(__("Delivery Note"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Delivery Note");
+ });
+
+ listview.page.add_action_item(__("Payment"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Sales Invoice", "Payment");
+ });
+ }
};
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js
index 685f2d6..2ba649d 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.js
+++ b/erpnext/accounts/report/gross_profit/gross_profit.js
@@ -42,6 +42,11 @@
"parent_field": "parent_invoice",
"initial_depth": 3,
"formatter": function(value, row, column, data, default_formatter) {
+ if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) {
+ column._options = "Sales Invoice";
+ } else {
+ column._options = "Item";
+ }
value = default_formatter(value, row, column, data);
if (data && (data.indent == 0.0 || row[1].content == "Total")) {
diff --git a/erpnext/bulk_transaction/__init__.py b/erpnext/bulk_transaction/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/bulk_transaction/__init__.py
diff --git a/erpnext/bulk_transaction/doctype/__init__.py b/erpnext/bulk_transaction/doctype/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/__init__.py
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/__init__.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/__init__.py
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js
new file mode 100644
index 0000000..a739cc3
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.js
@@ -0,0 +1,34 @@
+// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Bulk Transaction Log', {
+
+ before_load: function(frm) {
+ query(frm);
+ },
+
+ refresh: function(frm) {
+ frm.disable_save();
+ frm.add_custom_button(__('Retry Failed Transactions'), ()=>{
+ frappe.confirm(__("Retry Failing Transactions ?"), ()=>{
+ query(frm);
+ }
+ );
+ });
+ }
+});
+
+function query(frm) {
+ frappe.call({
+ method: "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction",
+ args: {
+ log_date: frm.doc.log_date
+ }
+ }).then((r) => {
+ if (r.message) {
+ frm.remove_custom_button("Retry Failed Transactions");
+ } else {
+ frappe.show_alert(__("Retrying Failed Transactions"), 5);
+ }
+ });
+}
\ No newline at end of file
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json
new file mode 100644
index 0000000..da42cf1
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.json
@@ -0,0 +1,51 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2021-11-30 13:41:16.343827",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "log_date",
+ "logger_data"
+ ],
+ "fields": [
+ {
+ "fieldname": "log_date",
+ "fieldtype": "Date",
+ "label": "Log Date",
+ "read_only": 1
+ },
+ {
+ "fieldname": "logger_data",
+ "fieldtype": "Table",
+ "label": "Logger Data",
+ "options": "Bulk Transaction Log Detail"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2022-02-03 17:23:02.935325",
+ "modified_by": "Administrator",
+ "module": "Bulk Transaction",
+ "name": "Bulk Transaction Log",
+ "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/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py
new file mode 100644
index 0000000..de7cde5
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py
@@ -0,0 +1,66 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from datetime import date
+
+import frappe
+from frappe.model.document import Document
+
+from erpnext.utilities.bulk_transaction import task, update_logger
+
+
+class BulkTransactionLog(Document):
+ pass
+
+
+@frappe.whitelist()
+def retry_failing_transaction(log_date=None):
+ btp = frappe.qb.DocType("Bulk Transaction Log Detail")
+ data = (
+ frappe.qb.from_(btp)
+ .select(btp.transaction_name, btp.from_doctype, btp.to_doctype)
+ .distinct()
+ .where(btp.retried != 1)
+ .where(btp.transaction_status == "Failed")
+ .where(btp.date == log_date)
+ ).run(as_dict=True)
+
+ if data:
+ if not log_date:
+ log_date = str(date.today())
+ if len(data) > 10:
+ frappe.enqueue(job, queue="long", job_name="bulk_retry", data=data, log_date=log_date)
+ else:
+ job(data, log_date)
+ else:
+ return "No Failed Records"
+
+def job(data, log_date):
+ for d in data:
+ failed = []
+ try:
+ frappe.db.savepoint("before_creation_of_record")
+ task(d.transaction_name, d.from_doctype, d.to_doctype)
+ except Exception as e:
+ frappe.db.rollback(save_point="before_creation_of_record")
+ failed.append(e)
+ update_logger(
+ d.transaction_name,
+ e,
+ d.from_doctype,
+ d.to_doctype,
+ status="Failed",
+ log_date=log_date,
+ restarted=1
+ )
+
+ if not failed:
+ update_logger(
+ d.transaction_name,
+ None,
+ d.from_doctype,
+ d.to_doctype,
+ status="Success",
+ log_date=log_date,
+ restarted=1,
+ )
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py
new file mode 100644
index 0000000..a78e697
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/test_bulk_transaction_log.py
@@ -0,0 +1,81 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import unittest
+from datetime import date
+
+import frappe
+
+from erpnext.utilities.bulk_transaction import transaction_processing
+
+
+class TestBulkTransactionLog(unittest.TestCase):
+
+ def setUp(self):
+ create_company()
+ create_customer()
+ create_item()
+
+ def test_for_single_record(self):
+ so_name = create_so()
+ transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice")
+ data = frappe.db.get_list("Sales Invoice", filters = {"posting_date": date.today(), "customer": "Bulk Customer"}, fields=["*"])
+ if not data:
+ self.fail("No Sales Invoice Created !")
+
+ def test_entry_in_log(self):
+ so_name = create_so()
+ transaction_processing([{"name": so_name}], "Sales Order", "Sales Invoice")
+ doc = frappe.get_doc("Bulk Transaction Log", str(date.today()))
+ for d in doc.get("logger_data"):
+ if d.transaction_name == so_name:
+ self.assertEqual(d.transaction_name, so_name)
+ self.assertEqual(d.transaction_status, "Success")
+ self.assertEqual(d.from_doctype, "Sales Order")
+ self.assertEqual(d.to_doctype, "Sales Invoice")
+ self.assertEqual(d.retried, 0)
+
+
+
+def create_company():
+ if not frappe.db.exists('Company', '_Test Company'):
+ frappe.get_doc({
+ 'doctype': 'Company',
+ 'company_name': '_Test Company',
+ 'country': 'India',
+ 'default_currency': 'INR'
+ }).insert()
+
+def create_customer():
+ if not frappe.db.exists('Customer', 'Bulk Customer'):
+ frappe.get_doc({
+ 'doctype': 'Customer',
+ 'customer_name': 'Bulk Customer'
+ }).insert()
+
+def create_item():
+ if not frappe.db.exists("Item", "MK"):
+ frappe.get_doc({
+ "doctype": "Item",
+ "item_code": "MK",
+ "item_name": "Milk",
+ "description": "Milk",
+ "item_group": "Products"
+ }).insert()
+
+def create_so(intent=None):
+ so = frappe.new_doc("Sales Order")
+ so.customer = "Bulk Customer"
+ so.company = "_Test Company"
+ so.transaction_date = date.today()
+
+ so.set_warehouse = "Finished Goods - _TC"
+ so.append("items", {
+ "item_code": "MK",
+ "delivery_date": date.today(),
+ "qty": 10,
+ "rate": 80,
+ })
+ so.insert()
+ so.submit()
+ return so.name
\ No newline at end of file
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/__init__.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/__init__.py
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json
new file mode 100644
index 0000000..8262caa
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.json
@@ -0,0 +1,86 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2021-11-30 13:38:30.926047",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "transaction_name",
+ "date",
+ "time",
+ "transaction_status",
+ "error_description",
+ "from_doctype",
+ "to_doctype",
+ "retried"
+ ],
+ "fields": [
+ {
+ "fieldname": "transaction_name",
+ "fieldtype": "Dynamic Link",
+ "in_list_view": 1,
+ "label": "Name",
+ "options": "from_doctype"
+ },
+ {
+ "fieldname": "transaction_status",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Status",
+ "read_only": 1
+ },
+ {
+ "fieldname": "error_description",
+ "fieldtype": "Long Text",
+ "label": "Error Description",
+ "read_only": 1
+ },
+ {
+ "fieldname": "from_doctype",
+ "fieldtype": "Link",
+ "label": "From Doctype",
+ "options": "DocType",
+ "read_only": 1
+ },
+ {
+ "fieldname": "to_doctype",
+ "fieldtype": "Link",
+ "label": "To Doctype",
+ "options": "DocType",
+ "read_only": 1
+ },
+ {
+ "fieldname": "date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Date ",
+ "read_only": 1
+ },
+ {
+ "fieldname": "time",
+ "fieldtype": "Time",
+ "label": "Time",
+ "read_only": 1
+ },
+ {
+ "fieldname": "retried",
+ "fieldtype": "Int",
+ "label": "Retried",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2022-02-03 19:57:31.650359",
+ "modified_by": "Administrator",
+ "module": "Bulk Transaction",
+ "name": "Bulk Transaction Log Detail",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py
new file mode 100644
index 0000000..67795b9
--- /dev/null
+++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log_detail/bulk_transaction_log_detail.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class BulkTransactionLogDetail(Document):
+ pass
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order_list.js b/erpnext/buying/doctype/purchase_order/purchase_order_list.js
index 8413eb6..d7907e4 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order_list.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order_list.js
@@ -29,8 +29,22 @@
listview.call_for_selected_items(method, { "status": "Closed" });
});
- listview.page.add_menu_item(__("Re-open"), function () {
+ listview.page.add_menu_item(__("Reopen"), function () {
listview.call_for_selected_items(method, { "status": "Submitted" });
});
+
+
+ listview.page.add_action_item(__("Purchase Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Invoice");
+ });
+
+ listview.page.add_action_item(__("Purchase Receipt"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Purchase Receipt");
+ });
+
+ listview.page.add_action_item(__("Advance Payment"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Purchase Order", "Advance Payment");
+ });
+
}
};
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index 9a63afc..645e97e 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -682,17 +682,18 @@
bin1 = frappe.db.get_value("Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
- fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
+ fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
# Submit PO
po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
bin2 = frappe.db.get_value("Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
- fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
+ fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
self.assertEqual(bin2.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract + 10)
self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10)
+ self.assertNotEqual(bin1.modified, bin2.modified)
# Create stock transfer
rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item",
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
index d65ab94..171de78 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
@@ -143,6 +143,26 @@
return doclist
@frappe.whitelist()
+def make_purchase_invoice(source_name, target_doc=None):
+ doc = get_mapped_doc("Supplier Quotation", source_name, {
+ "Supplier Quotation": {
+ "doctype": "Purchase Invoice",
+ "validation": {
+ "docstatus": ["=", 1],
+ }
+ },
+ "Supplier Quotation Item": {
+ "doctype": "Purchase Invoice Item"
+ },
+ "Purchase Taxes and Charges": {
+ "doctype": "Purchase Taxes and Charges"
+ }
+ }, target_doc)
+
+ return doc
+
+
+@frappe.whitelist()
def make_quotation(source_name, target_doc=None):
doclist = get_mapped_doc("Supplier Quotation", source_name, {
"Supplier Quotation": {
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js
index 5ab6c98..73685ca 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation_list.js
@@ -8,5 +8,15 @@
} else if(doc.status==="Expired") {
return [__("Expired"), "gray", "status,=,Expired"];
}
+ },
+
+ onload: function(listview) {
+ listview.page.add_action_item(__("Purchase Order"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Order");
+ });
+
+ listview.page.add_action_item(__("Purchase Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Supplier Quotation", "Purchase Invoice");
+ });
}
};
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index bab16a4..994b903 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -407,6 +407,22 @@
if item_qty != len(get_serial_nos(item.get('serial_no'))):
item.set(fieldname, value)
+ elif (
+ ret.get("pricing_rule_removed")
+ and value is not None
+ and fieldname
+ in [
+ "discount_percentage",
+ "discount_amount",
+ "rate",
+ "margin_rate_or_amount",
+ "margin_type",
+ "remove_free_item",
+ ]
+ ):
+ # reset pricing rule fields if pricing_rule_removed
+ item.set(fieldname, value)
+
if self.doctype in ["Purchase Invoice", "Sales Invoice"] and item.meta.get_field('is_fixed_asset'):
item.set('is_fixed_asset', ret.get('is_fixed_asset', 0))
@@ -1318,6 +1334,9 @@
payment_schedule['discount_type'] = schedule.discount_type
payment_schedule['discount'] = schedule.discount
+ if not schedule.invoice_portion:
+ payment_schedule['payment_amount'] = schedule.payment_amount
+
self.append("payment_schedule", payment_schedule)
def set_due_date(self):
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 8d17683..c8e5edd 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -3,6 +3,7 @@
import json
from collections import defaultdict
+from typing import List, Tuple
import frappe
from frappe import _
@@ -181,33 +182,28 @@
return details
- def get_items_and_warehouses(self):
- items, warehouses = [], []
+ def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]:
+ """Get list of items and warehouses affected by a transaction"""
- if hasattr(self, "items"):
- item_doclist = self.get("items")
- elif self.doctype == "Stock Reconciliation":
- item_doclist = []
- data = json.loads(self.reconciliation_json)
- for row in data[data.index(self.head_row)+1:]:
- d = frappe._dict(zip(["item_code", "warehouse", "qty", "valuation_rate"], row))
- item_doclist.append(d)
+ if not (hasattr(self, "items") or hasattr(self, "packed_items")):
+ return [], []
- if item_doclist:
- for d in item_doclist:
- if d.item_code and d.item_code not in items:
- items.append(d.item_code)
+ item_rows = (self.get("items") or []) + (self.get("packed_items") or [])
- if d.get("warehouse") and d.warehouse not in warehouses:
- warehouses.append(d.warehouse)
+ items = {d.item_code for d in item_rows if d.item_code}
- if self.doctype == "Stock Entry":
- if d.get("s_warehouse") and d.s_warehouse not in warehouses:
- warehouses.append(d.s_warehouse)
- if d.get("t_warehouse") and d.t_warehouse not in warehouses:
- warehouses.append(d.t_warehouse)
+ warehouses = set()
+ for d in item_rows:
+ if d.get("warehouse"):
+ warehouses.add(d.warehouse)
- return items, warehouses
+ if self.doctype == "Stock Entry":
+ if d.get("s_warehouse"):
+ warehouses.add(d.s_warehouse)
+ if d.get("t_warehouse"):
+ warehouses.add(d.t_warehouse)
+
+ return list(items), list(warehouses)
def get_stock_ledger_details(self):
stock_ledger = {}
@@ -219,7 +215,7 @@
from
`tabStock Ledger Entry`
where
- voucher_type=%s and voucher_no=%s
+ voucher_type=%s and voucher_no=%s and is_cancelled = 0
""", (self.doctype, self.name), as_dict=True)
for sle in stock_ledger_entries:
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index 075e3e3..2776628 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -106,6 +106,9 @@
self.doc.conversion_rate = flt(self.doc.conversion_rate)
def calculate_item_values(self):
+ if self.doc.get('is_consolidated'):
+ return
+
if not self.discount_amount_applied:
for item in self.doc.get("items"):
self.doc.round_floats_in(item)
@@ -647,12 +650,12 @@
def calculate_change_amount(self):
self.doc.change_amount = 0.0
self.doc.base_change_amount = 0.0
+ grand_total = self.doc.rounded_total or self.doc.grand_total
+ base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total
if self.doc.doctype == "Sales Invoice" \
- and self.doc.paid_amount > self.doc.grand_total and not self.doc.is_return \
+ and self.doc.paid_amount > grand_total and not self.doc.is_return \
and any(d.type == "Cash" for d in self.doc.payments):
- grand_total = self.doc.rounded_total or self.doc.grand_total
- base_grand_total = self.doc.base_rounded_total or self.doc.base_grand_total
self.doc.change_amount = flt(self.doc.paid_amount - grand_total +
self.doc.write_off_amount, self.doc.precision("change_amount"))
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 0e29038..d99f23e 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -341,7 +341,8 @@
"erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts"
],
"hourly_long": [
- "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries"
+ "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
+ "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction"
],
"daily": [
"erpnext.stock.reorder_item.reorder_item",
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index 6b85927..6d27f4a 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -546,7 +546,7 @@
from erpnext.hr.utils import allocate_earned_leaves
i = 0
while(i<14):
- allocate_earned_leaves()
+ allocate_earned_leaves(ignore_duplicates=True)
i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6)
@@ -554,7 +554,7 @@
frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0)
i = 0
while(i<6):
- allocate_earned_leaves()
+ allocate_earned_leaves(ignore_duplicates=True)
i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)
diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
index 355370f..41a9558 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
@@ -8,7 +8,7 @@
import frappe
from frappe import _, bold
from frappe.model.document import Document
-from frappe.utils import date_diff, flt, formatdate, get_datetime, getdate
+from frappe.utils import date_diff, flt, formatdate, get_datetime, get_last_day, getdate
class LeavePolicyAssignment(Document):
@@ -108,8 +108,8 @@
def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining):
from erpnext.hr.utils import get_monthly_earned_leave
- current_month = get_datetime().month
- current_year = get_datetime().year
+ current_month = get_datetime(frappe.flags.current_date).month or get_datetime().month
+ current_year = get_datetime(frappe.flags.current_date).year or get_datetime().year
from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date")
if getdate(date_of_joining) > getdate(from_date):
@@ -119,10 +119,14 @@
from_date_year = get_datetime(from_date).year
months_passed = 0
+
if current_year == from_date_year and current_month > from_date_month:
months_passed = current_month - from_date_month
+ months_passed = add_current_month_if_applicable(months_passed)
+
elif current_year > from_date_year:
months_passed = (12 - from_date_month) + current_month
+ months_passed = add_current_month_if_applicable(months_passed)
if months_passed > 0:
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
@@ -134,6 +138,17 @@
return new_leaves_allocated
+def add_current_month_if_applicable(months_passed):
+ date = getdate(frappe.flags.current_date) or getdate()
+ last_day_of_month = get_last_day(date)
+
+ # if its the last day of the month, then that month should also be considered
+ if last_day_of_month == date:
+ months_passed += 1
+
+ return months_passed
+
+
@frappe.whitelist()
def create_assignment_for_multiple_employees(employees, data):
diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
index 3b7f8ec..8c76ca1 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
@@ -4,7 +4,7 @@
import unittest
import frappe
-from frappe.utils import add_months, get_first_day, getdate
+from frappe.utils import add_months, get_first_day, get_last_day, getdate
from erpnext.hr.doctype.leave_application.test_leave_application import (
get_employee,
@@ -125,6 +125,121 @@
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 0)
+ def test_earned_leave_allocation_for_passed_months(self):
+ employee = get_employee()
+ leave_type = create_earned_leave_type("Test Earned Leave")
+ leave_period = create_leave_period("Test Earned Leave Period",
+ start_date=get_first_day(add_months(getdate(), -1)))
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).insert()
+
+ # Case 1: assignment created one month after the leave period, should allocate 1 leave
+ frappe.flags.current_date = get_first_day(getdate())
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 1)
+
+ def test_earned_leave_allocation_for_passed_months_on_month_end(self):
+ employee = get_employee()
+ leave_type = create_earned_leave_type("Test Earned Leave")
+ leave_period = create_leave_period("Test Earned Leave Period",
+ start_date=get_first_day(add_months(getdate(), -2)))
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).insert()
+
+ # Case 2: assignment created on the last day of the leave period's latter month
+ # should allocate 1 leave for current month even though the month has not ended
+ # since the daily job might have already executed
+ frappe.flags.current_date = get_last_day(getdate())
+
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ # if the daily job is not completed yet, there is another check present
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import allocate_earned_leaves
+ allocate_earned_leaves()
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ def test_earned_leave_allocation_for_passed_months_with_carry_forwarded_leaves(self):
+ from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
+
+ employee = get_employee()
+ leave_type = create_earned_leave_type("Test Earned Leave")
+ leave_period = create_leave_period("Test Earned Leave Period",
+ start_date=get_first_day(add_months(getdate(), -2)))
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).insert()
+
+ # initial leave allocation = 5
+ leave_allocation = create_leave_allocation(
+ employee=employee.name,
+ employee_name=employee.employee_name,
+ leave_type=leave_type.name,
+ from_date=add_months(getdate(), -12),
+ to_date=add_months(getdate(), -3),
+ new_leaves_allocated=5,
+ carry_forward=0)
+ leave_allocation.submit()
+
+ # Case 3: assignment created on the last day of the leave period's latter month with carry forwarding
+ frappe.flags.current_date = get_last_day(add_months(getdate(), -1))
+
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name,
+ "carry_forward": 1
+ }
+ # carry forwarded leaves = 5, 3 leaves allocated for passed months
+ leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+
+ details = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True)
+ self.assertEqual(details.new_leaves_allocated, 2)
+ self.assertEqual(details.unused_leaves, 5)
+ self.assertEqual(details.total_leaves_allocated, 7)
+
+ # if the daily job is not completed yet, there is another check present
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import is_earned_leave_already_allocated
+ frappe.flags.current_date = get_last_day(getdate())
+
+ allocation = frappe.get_doc('Leave Allocation', details.name)
+ # 1 leave is still pending to be allocated, irrespective of carry forwarded leaves
+ self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation))
+
def tearDown(self):
frappe.db.rollback()
@@ -138,13 +253,14 @@
is_earned_leave=1,
earned_leave_frequency="Monthly",
rounding=0.5,
- max_leaves_allowed=6
+ is_carry_forward=1
)).insert()
-def create_leave_period(name):
+def create_leave_period(name, start_date=None):
frappe.delete_doc_if_exists("Leave Period", name, force=1)
- start_date = get_first_day(getdate())
+ if not start_date:
+ start_date = get_first_day(getdate())
return frappe.get_doc(dict(
name=name,
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index 0febce1..7fd3a98 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -237,7 +237,7 @@
create_leave_encashment(leave_allocation=leave_allocation)
-def allocate_earned_leaves():
+def allocate_earned_leaves(ignore_duplicates=False):
'''Allocate earned leaves to Employees'''
e_leave_types = get_earned_leaves()
today = getdate()
@@ -265,9 +265,9 @@
from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date):
- update_previous_leave_allocation(allocation, annual_allocation, e_leave_type)
+ update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates)
-def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type):
+def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False):
earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding)
allocation = frappe.get_doc('Leave Allocation', allocation.name)
@@ -277,9 +277,12 @@
new_allocation = e_leave_type.max_leaves_allowed
if new_allocation != allocation.total_leaves_allocated:
- allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
today_date = today()
- create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
+
+ if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation):
+ allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
+ create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
+
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
earned_leaves = 0.0
@@ -297,6 +300,28 @@
return earned_leaves
+def is_earned_leave_already_allocated(allocation, annual_allocation):
+ from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
+ get_leave_type_details,
+ )
+
+ leave_type_details = get_leave_type_details()
+ date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
+
+ assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment)
+ leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type,
+ annual_allocation, leave_type_details, date_of_joining)
+
+ # exclude carry-forwarded leaves while checking for leave allocation for passed months
+ num_allocations = allocation.total_leaves_allocated
+ if allocation.unused_leaves:
+ num_allocations -= allocation.unused_leaves
+
+ if num_allocations >= leaves_for_passed_months:
+ return True
+ return False
+
+
def get_leave_allocations(date, leave_type):
return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy
from `tabLeave Allocation`
diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py
index 6415689..5ebb2e1 100644
--- a/erpnext/loan_management/doctype/loan/test_loan.py
+++ b/erpnext/loan_management/doctype/loan/test_loan.py
@@ -680,6 +680,29 @@
loan.load_from_db()
self.assertEqual(loan.status, "Loan Closure Requested")
+ def test_loan_repayment_against_partially_disbursed_loan(self):
+ pledge = [{
+ "loan_security": "Test Security 1",
+ "qty": 4000.00
+ }]
+
+ loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge)
+ create_pledge(loan_application)
+
+ loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01')
+ loan.submit()
+
+ first_date = '2019-10-01'
+ last_date = '2019-10-30'
+
+ make_loan_disbursement_entry(loan.name, loan.loan_amount/2, disbursement_date=first_date)
+
+ loan.load_from_db()
+
+ self.assertEqual(loan.status, "Partially Disbursed")
+ create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5),
+ flt(loan.loan_amount/3))
+
def test_loan_amount_write_off(self):
pledge = [{
"loan_security": "Test Security 1",
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index 7e997e8..acf3a65 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -125,7 +125,7 @@
def update_paid_amount(self):
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
- 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
+ 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable',
'written_off_amount'], as_dict=1)
loan.update({
@@ -153,7 +153,7 @@
def mark_as_unpaid(self):
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
- 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
+ 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable',
'written_off_amount'], as_dict=1)
no_of_repayments = len(self.repayment_details)
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index a399edd..67c47ef 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -201,6 +201,21 @@
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
cint(bin1_on_start_production.reserved_qty_for_production))
+ def test_reserved_qty_for_production_closed(self):
+
+ wo1 = make_wo_order_test_record(item="_Test FG Item", qty=2,
+ source_warehouse=self.warehouse)
+ item = wo1.required_items[0].item_code
+ bin_before = get_bin(item, self.warehouse)
+ bin_before.update_reserved_qty_for_production()
+
+ make_wo_order_test_record(item="_Test FG Item", qty=2,
+ source_warehouse=self.warehouse)
+ close_work_order(wo1.name, "Closed")
+
+ bin_after = get_bin(item, self.warehouse)
+ self.assertEqual(bin_before.reserved_qty_for_production, bin_after.reserved_qty_for_production)
+
def test_backflush_qty_for_overpduction_manufacture(self):
cancel_stock_entry = []
allow_overproduction("overproduction_percentage_for_work_order", 30)
@@ -703,7 +718,8 @@
wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse,
company=company)
- self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture')
+ stock_entry = frappe.get_doc(make_stock_entry(wo.name, 'Material Transfer for Manufacture'))
+ self.assertRaises(frappe.ValidationError, stock_entry.save)
def test_wo_completion_with_pl_bom(self):
from erpnext.manufacturing.doctype.bom.test_bom import (
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 7471587..9dd3fa7 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -8,6 +8,8 @@
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
+from frappe.query_builder import Case
+from frappe.query_builder.functions import Sum
from frappe.utils import (
cint,
date_diff,
@@ -1174,3 +1176,27 @@
doc.set_item_locations()
return doc
+
+def get_reserved_qty_for_production(item_code: str, warehouse: str) -> float:
+ """Get total reserved quantity for any item in specified warehouse"""
+ wo = frappe.qb.DocType("Work Order")
+ wo_item = frappe.qb.DocType("Work Order Item")
+
+ return (
+ frappe.qb
+ .from_(wo)
+ .from_(wo_item)
+ .select(Sum(Case()
+ .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
+ .else_(wo_item.required_qty - wo_item.consumed_qty))
+ )
+ .where(
+ (wo_item.item_code == item_code)
+ & (wo_item.parent == wo.name)
+ & (wo.docstatus == 1)
+ & (wo_item.source_warehouse == warehouse)
+ & (wo.status.notin(["Stopped", "Completed", "Closed"]))
+ & ((wo_item.required_qty > wo_item.transferred_qty)
+ | (wo_item.required_qty > wo_item.consumed_qty))
+ )
+ ).run()[0][0] or 0.0
diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
index 090a3e7..2693352 100644
--- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
+++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
@@ -89,10 +89,10 @@
GROUP BY bom_item.item_code""".format(qty_field=qty_field, table=table, conditions=conditions, bom=bom), as_dict=1)
def get_manufacturer_records():
- details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "parent"])
+ details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "item_code"])
manufacture_details = frappe._dict()
for detail in details:
- dic = manufacture_details.setdefault(detail.get('parent'), {})
+ dic = manufacture_details.setdefault(detail.get('item_code'), {})
dic.setdefault('manufacturer', []).append(detail.get('manufacturer'))
dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no'))
diff --git a/erpnext/modules.txt b/erpnext/modules.txt
index c5705c1..8c79ee5 100644
--- a/erpnext/modules.txt
+++ b/erpnext/modules.txt
@@ -21,4 +21,5 @@
Loan Management
Payroll
Telephony
+Bulk Transaction
E-commerce
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index feafecb..d300340 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -350,3 +350,4 @@
erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template
erpnext.patches.v13_0.shopping_cart_to_ecommerce
erpnext.patches.v13_0.update_disbursement_account
+erpnext.patches.v13_0.update_reserved_qty_closed_wo
diff --git a/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py b/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py
new file mode 100644
index 0000000..00926b0
--- /dev/null
+++ b/erpnext/patches/v13_0/update_reserved_qty_closed_wo.py
@@ -0,0 +1,28 @@
+import frappe
+
+from erpnext.stock.utils import get_bin
+
+
+def execute():
+
+ wo = frappe.qb.DocType("Work Order")
+ wo_item = frappe.qb.DocType("Work Order Item")
+
+ incorrect_item_wh = (
+ frappe.qb
+ .from_(wo)
+ .join(wo_item).on(wo.name == wo_item.parent)
+ .select(wo_item.item_code, wo.source_warehouse).distinct()
+ .where(
+ (wo.status == "Closed")
+ & (wo.docstatus == 1)
+ & (wo.source_warehouse.notnull())
+ )
+ ).run()
+
+ for item_code, warehouse in incorrect_item_wh:
+ if not (item_code and warehouse):
+ continue
+
+ bin = get_bin(item_code, warehouse)
+ bin.update_reserved_qty_for_production()
diff --git a/erpnext/public/build.json b/erpnext/public/build.json
index 569910d..91a752c 100644
--- a/erpnext/public/build.json
+++ b/erpnext/public/build.json
@@ -39,7 +39,8 @@
"public/js/utils/dimension_tree_filter.js",
"public/js/telephony.js",
"public/js/templates/call_link.html",
- "public/js/templates/node_card.html"
+ "public/js/templates/node_card.html",
+ "public/js/bulk_transaction_processing.js"
],
"js/item-dashboard.min.js": [
"stock/dashboard/item_dashboard.html",
diff --git a/erpnext/public/js/bulk_transaction_processing.js b/erpnext/public/js/bulk_transaction_processing.js
new file mode 100644
index 0000000..101f50c
--- /dev/null
+++ b/erpnext/public/js/bulk_transaction_processing.js
@@ -0,0 +1,30 @@
+frappe.provide("erpnext.bulk_transaction_processing");
+
+$.extend(erpnext.bulk_transaction_processing, {
+ create: function(listview, from_doctype, to_doctype) {
+ let checked_items = listview.get_checked_items();
+ const doc_name = [];
+ checked_items.forEach((Item)=> {
+ if (Item.docstatus == 0) {
+ doc_name.push(Item.name);
+ }
+ });
+
+ let count_of_rows = checked_items.length;
+ frappe.confirm(__("Create {0} {1} ?", [count_of_rows, to_doctype]), ()=>{
+ if (doc_name.length == 0) {
+ frappe.call({
+ method: "erpnext.utilities.bulk_transaction.transaction_processing",
+ args: {data: checked_items, from_doctype: from_doctype, to_doctype: to_doctype}
+ }).then(()=> {
+
+ });
+ if (count_of_rows > 10) {
+ frappe.show_alert("Starting a background job to create {0} {1}", [count_of_rows, to_doctype]);
+ }
+ } else {
+ frappe.msgprint(__("Selected document must be in submitted state"));
+ }
+ });
+ }
+});
\ No newline at end of file
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 3791741..aa3e2f3 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -1463,7 +1463,8 @@
"item_code": d.item_code,
"pricing_rules": d.pricing_rules,
"parenttype": d.parenttype,
- "parent": d.parent
+ "parent": d.parent,
+ "price_list_rate": d.price_list_rate
})
}
});
@@ -2288,7 +2289,8 @@
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule(),
() => this.frm.doc.ignore_pricing_rule=0,
- () => me.apply_pricing_rule()
+ () => me.apply_pricing_rule(),
+ () => this.frm.save()
]);
} else {
frappe.run_serially([
diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js
index 5259bdc..b3a68b3 100644
--- a/erpnext/public/js/erpnext.bundle.js
+++ b/erpnext/public/js/erpnext.bundle.js
@@ -22,5 +22,6 @@
import "./utils/dimension_tree_filter";
import "./telephony";
import "./templates/call_link.html";
+import "./bulk_transaction_processing";
// import { sum } from 'frappe/public/utils/util.js'
diff --git a/erpnext/regional/report/datev/datev.js b/erpnext/regional/report/datev/datev.js
index 4124e3d..03c729e 100644
--- a/erpnext/regional/report/datev/datev.js
+++ b/erpnext/regional/report/datev/datev.js
@@ -40,7 +40,11 @@
});
query_report.page.add_menu_item(__("Download DATEV File"), () => {
- const filters = JSON.stringify(query_report.get_values());
+ const filters = encodeURIComponent(
+ JSON.stringify(
+ query_report.get_values()
+ )
+ );
window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`);
});
diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js
index b631685..4c8f9c4 100644
--- a/erpnext/selling/doctype/quotation/quotation_list.js
+++ b/erpnext/selling/doctype/quotation/quotation_list.js
@@ -12,6 +12,14 @@
};
};
}
+
+ listview.page.add_action_item(__("Sales Order"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Order");
+ });
+
+ listview.page.add_action_item(__("Sales Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Invoice");
+ });
},
get_indicator: function(doc) {
diff --git a/erpnext/selling/doctype/sales_order/sales_order_list.js b/erpnext/selling/doctype/sales_order/sales_order_list.js
index 26d96d5..4691190 100644
--- a/erpnext/selling/doctype/sales_order/sales_order_list.js
+++ b/erpnext/selling/doctype/sales_order/sales_order_list.js
@@ -16,7 +16,7 @@
return [__("Overdue"), "red",
"per_delivered,<,100|delivery_date,<,Today|status,!=,Closed"];
} else if (flt(doc.grand_total) === 0) {
- // not delivered (zero-amount order)
+ // not delivered (zeroount order)
return [__("To Deliver"), "orange",
"per_delivered,<,100|grand_total,=,0|status,!=,Closed"];
} else if (flt(doc.per_billed, 6) < 100) {
@@ -48,5 +48,17 @@
listview.call_for_selected_items(method, {"status": "Submitted"});
});
+ listview.page.add_action_item(__("Sales Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Sales Invoice");
+ });
+
+ listview.page.add_action_item(__("Delivery Note"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Delivery Note");
+ });
+
+ listview.page.add_action_item(__("Advance Payment"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Advance Payment");
+ });
+
}
};
diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py
index c34e9d0..3bc15a8 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -20,43 +20,12 @@
+ flt(self.indented_qty) + flt(self.planned_qty) - flt(self.reserved_qty)
- flt(self.reserved_qty_for_production) - flt(self.reserved_qty_for_sub_contract))
- def get_first_sle(self):
- sle = frappe.qb.DocType("Stock Ledger Entry")
- first_sle = (
- frappe.qb.from_(sle)
- .select("*")
- .where((sle.item_code == self.item_code) & (sle.warehouse == self.warehouse))
- .orderby(sle.posting_date, sle.posting_time, sle.creation)
- .limit(1)
- ).run(as_dict=True)
-
- return first_sle and first_sle[0] or None
-
def update_reserved_qty_for_production(self):
'''Update qty reserved for production from Production Item tables
in open work orders'''
+ from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
- wo = frappe.qb.DocType("Work Order")
- wo_item = frappe.qb.DocType("Work Order Item")
-
- self.reserved_qty_for_production = (
- frappe.qb
- .from_(wo)
- .from_(wo_item)
- .select(Sum(Case()
- .when(wo.skip_transfer == 0, wo_item.required_qty - wo_item.transferred_qty)
- .else_(wo_item.required_qty - wo_item.consumed_qty))
- )
- .where(
- (wo_item.item_code == self.item_code)
- & (wo_item.parent == wo.name)
- & (wo.docstatus == 1)
- & (wo_item.source_warehouse == self.warehouse)
- & (wo.status.notin(["Stopped", "Completed"]))
- & ((wo_item.required_qty > wo_item.transferred_qty)
- | (wo_item.required_qty > wo_item.consumed_qty))
- )
- ).run()[0][0] or 0.0
+ self.reserved_qty_for_production = get_reserved_qty_for_production(self.item_code, self.warehouse)
self.set_projected_qty()
@@ -126,13 +95,6 @@
frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse")
-def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False):
- """WARNING: This function is deprecated. Inline this function instead of using it."""
- from erpnext.stock.stock_ledger import repost_current_voucher
-
- repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
- update_qty(bin_name, args)
-
def get_bin_details(bin_name):
return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty',
'reserved_qty', 'indented_qty', 'planned_qty', 'reserved_qty_for_production',
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index d1e2244..2a4d639 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -339,17 +339,35 @@
frappe.throw(_("Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again"))
def update_billed_amount_based_on_so(so_detail, update_modified=True):
+ from frappe.query_builder.functions import Sum
+
# Billed against Sales Order directly
- billed_against_so = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item`
- where so_detail=%s and (dn_detail is null or dn_detail = '') and docstatus=1""", so_detail)
+ si = frappe.qb.DocType("Sales Invoice").as_("si")
+ si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item")
+ sum_amount = Sum(si_item.amount).as_("amount")
+
+ billed_against_so = frappe.qb.from_(si).from_(si_item).select(sum_amount).where(
+ (si_item.parent == si.name) &
+ (si_item.so_detail == so_detail) &
+ ((si_item.dn_detail.isnull()) | (si_item.dn_detail == '')) &
+ (si_item.docstatus == 1) &
+ (si.update_stock == 0)
+ ).run()
billed_against_so = billed_against_so and billed_against_so[0][0] or 0
# Get all Delivery Note Item rows against the Sales Order Item row
- dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent
- from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn
- where dn.name=dn_item.parent and dn_item.so_detail=%s
- and dn.docstatus=1 and dn.is_return = 0
- order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1)
+
+ dn = frappe.qb.DocType("Delivery Note").as_("dn")
+ dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item")
+
+ dn_details = frappe.qb.from_(dn).from_(dn_item).select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent, dn_item.stock_qty, dn_item.returned_qty).where(
+ (dn.name == dn_item.parent) &
+ (dn_item.so_detail == so_detail) &
+ (dn.docstatus == 1) &
+ (dn.is_return == 0)
+ ).orderby(
+ dn.posting_date, dn.posting_time, dn.name
+ ).run(as_dict=True)
updated_dn = []
for dnd in dn_details:
@@ -367,7 +385,11 @@
# Distribute billed amount directly against SO between DNs based on FIFO
if billed_against_so and billed_amt_agianst_dn < dnd.amount:
- pending_to_bill = flt(dnd.amount) - billed_amt_agianst_dn
+ if dnd.returned_qty:
+ pending_to_bill = flt(dnd.amount) * (dnd.stock_qty - dnd.returned_qty) / dnd.stock_qty
+ else:
+ pending_to_bill = flt(dnd.amount)
+ pending_to_bill -= billed_amt_agianst_dn
if pending_to_bill <= billed_against_so:
billed_amt_agianst_dn += pending_to_bill
billed_against_so -= pending_to_bill
@@ -586,7 +608,18 @@
"validation": {
"docstatus": ["=", 0]
}
+ },
+
+ "Delivery Note Item": {
+ "doctype": "Packing Slip Item",
+ "field_map": {
+ "item_code": "item_code",
+ "item_name": "item_name",
+ "description": "description",
+ "qty": "qty",
+ }
}
+
}, target_doc)
return doclist
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js
index 0402898..9e6f3bc 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js
+++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js
@@ -14,7 +14,7 @@
return [__("Completed"), "green", "per_billed,=,100"];
}
},
- onload: function (doclist) {
+ onload: function (listview) {
const action = () => {
const selected_docs = doclist.get_checked_items();
const docnames = doclist.get_checked_items(true);
@@ -54,6 +54,16 @@
};
};
- doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false);
+ // doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false);
+
+ listview.page.add_action_item(__('Create Delivery Trip'), action);
+
+ listview.page.add_action_item(__("Sales Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Sales Invoice");
+ });
+
+ listview.page.add_action_item(__("Packaging Slip From Delivery Note"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Packing Slip");
+ });
}
};
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index e71cdb3..b05f58a 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -346,7 +346,7 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Valuation Method",
- "options": "\nFIFO\nMoving Average"
+ "options": "\nFIFO\nMoving Average\nLIFO"
},
{
"depends_on": "is_stock_item",
@@ -987,4 +987,4 @@
"states": [],
"title_field": "item_name",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py
index 5cbaa1e..2521ac9 100644
--- a/erpnext/stock/doctype/packed_item/test_packed_item.py
+++ b/erpnext/stock/doctype/packed_item/test_packed_item.py
@@ -1,10 +1,14 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
+from frappe.utils import add_to_date, nowdate
+
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.selling.doctype.sales_order.sales_order import 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.purchase_receipt.test_purchase_receipt import get_gl_entries
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestCase, change_settings
@@ -12,31 +16,30 @@
"Test impact on Packed Items table in various scenarios."
@classmethod
def setUpClass(cls) -> None:
- make_item("_Test Product Bundle X", {"is_stock_item": 0})
- make_item("_Test Bundle Item 1", {"is_stock_item": 1})
- make_item("_Test Bundle Item 2", {"is_stock_item": 1})
+ super().setUpClass()
+ cls.bundle = "_Test Product Bundle X"
+ cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
+ make_item(cls.bundle, {"is_stock_item": 0})
+ for item in cls.bundle_items:
+ make_item(item, {"is_stock_item": 1})
+
make_item("_Test Normal Stock Item", {"is_stock_item": 1})
- make_product_bundle(
- "_Test Product Bundle X",
- ["_Test Bundle Item 1", "_Test Bundle Item 2"],
- qty=2
- )
+ make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
def test_adding_bundle_item(self):
"Test impact on packed items if bundle item row is added."
- so = make_sales_order(item_code = "_Test Product Bundle X", qty=1,
+ so = make_sales_order(item_code = self.bundle, qty=1,
do_not_submit=True)
self.assertEqual(so.items[0].qty, 1)
self.assertEqual(len(so.packed_items), 2)
- self.assertEqual(so.packed_items[0].item_code, "_Test Bundle Item 1")
+ self.assertEqual(so.packed_items[0].item_code, self.bundle_items[0])
self.assertEqual(so.packed_items[0].qty, 2)
def test_updating_bundle_item(self):
"Test impact on packed items if bundle item row is updated."
- so = make_sales_order(item_code = "_Test Product Bundle X", qty=1,
- do_not_submit=True)
+ so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True)
so.items[0].qty = 2 # change qty
so.save()
@@ -55,7 +58,7 @@
so_items = []
for qty in [2, 4, 6, 8]:
so_items.append({
- "item_code": "_Test Product Bundle X",
+ "item_code": self.bundle,
"qty": qty,
"rate": 400,
"warehouse": "_Test Warehouse - _TC"
@@ -66,7 +69,7 @@
# check alternate rows for qty
self.assertEqual(len(so.packed_items), 8)
- self.assertEqual(so.packed_items[1].item_code, "_Test Bundle Item 2")
+ self.assertEqual(so.packed_items[1].item_code, self.bundle_items[1])
self.assertEqual(so.packed_items[1].qty, 4)
self.assertEqual(so.packed_items[3].qty, 8)
self.assertEqual(so.packed_items[5].qty, 12)
@@ -94,8 +97,7 @@
@change_settings("Selling Settings", {"editable_bundle_item_rates": 1})
def test_bundle_item_cumulative_price(self):
"Test if Bundle Item rate is cumulative from packed items."
- so = make_sales_order(item_code = "_Test Product Bundle X", qty=2,
- do_not_submit=True)
+ so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True)
so.packed_items[0].rate = 150
so.packed_items[1].rate = 200
@@ -109,7 +111,7 @@
so_items = []
for qty in [2, 4]:
so_items.append({
- "item_code": "_Test Product Bundle X",
+ "item_code": self.bundle,
"qty": qty,
"rate": 400,
"warehouse": "_Test Warehouse - _TC"
@@ -124,4 +126,33 @@
self.assertEqual(len(dn.packed_items), 4)
self.assertEqual(dn.packed_items[2].qty, 6)
- self.assertEqual(dn.packed_items[3].qty, 6)
\ No newline at end of file
+ self.assertEqual(dn.packed_items[3].qty, 6)
+
+ def test_reposting_packed_items(self):
+ warehouse = "Stores - TCP1"
+ company = "_Test Company with perpetual inventory"
+
+ today = nowdate()
+ yesterday = add_to_date(today, days=-1, as_string=True)
+
+ for item in self.bundle_items:
+ make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=100, posting_date=today)
+
+ so = make_sales_order(item_code = self.bundle, qty=1, company=company, warehouse=warehouse)
+
+ dn = make_delivery_note(so.name)
+ dn.save()
+ dn.submit()
+
+ gles = get_gl_entries(dn.doctype, dn.name)
+ credit_before_repost = sum(gle.credit for gle in gles)
+
+ # backdated stock entry
+ for item in self.bundle_items:
+ make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=200, posting_date=yesterday)
+
+ # assert correct reposting
+ gles = get_gl_entries(dn.doctype, dn.name)
+ credit_after_reposting = sum(gle.credit for gle in gles)
+ self.assertNotEqual(credit_before_repost, credit_after_reposting)
+ self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost)
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index ffdf8c4..33e40c8 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -288,9 +288,6 @@
{"voucher_type": "Purchase Receipt", "voucher_no": self.name,
"voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference")
- if not stock_value_diff:
- continue
-
warehouse_account_name = warehouse_account[d.warehouse]["account"]
warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"]
supplier_warehouse_account = warehouse_account.get(self.supplier_warehouse, {}).get("account")
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
index 77711de..4029f0c 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
@@ -13,5 +13,13 @@
} else if (flt(doc.grand_total) === 0 || flt(doc.per_billed, 2) === 100) {
return [__("Completed"), "green", "per_billed,=,100"];
}
+ },
+
+ onload: function(listview) {
+
+ listview.page.add_action_item(__("Purchase Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Purchase Receipt", "Purchase Invoice");
+ });
}
+
};
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index b87d920..5ab7929 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -4,6 +4,7 @@
import json
import unittest
+from collections import defaultdict
import frappe
from frappe.utils import add_days, cint, cstr, flt, today
@@ -16,7 +17,7 @@
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
-from erpnext.tests.utils import ERPNextTestCase
+from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestPurchaseReceipt(ERPNextTestCase):
@@ -1387,6 +1388,36 @@
automatically_fetch_payment_terms(enable=0)
+ @change_settings("Stock Settings", {"allow_negative_stock": 1})
+ def test_neg_to_positive(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ item_code = "_TestNegToPosItem"
+ warehouse = "Stores - TCP1"
+ company = "_Test Company with perpetual inventory"
+ account = "Stock Received But Not Billed - TCP1"
+
+ make_item(item_code)
+ se = make_stock_entry(item_code=item_code, from_warehouse=warehouse, qty=50, do_not_save=True, rate=0)
+ se.items[0].allow_zero_valuation_rate = 1
+ se.save()
+ se.submit()
+
+ pr = make_purchase_receipt(
+ qty=50,
+ rate=1,
+ item_code=item_code,
+ warehouse=warehouse,
+ get_taxes_and_charges=True,
+ company=company,
+ )
+ gles = get_gl_entries(pr.doctype, pr.name)
+
+ for gle in gles:
+ if gle.account == account:
+ self.assertEqual(gle.credit, 50)
+
+
def get_sl_entries(voucher_type, voucher_no):
return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference
from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index 01c5e3e..977d470 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -13,7 +13,7 @@
check_if_stock_and_account_balance_synced,
update_gl_entries_after,
)
-from erpnext.stock.stock_ledger import repost_future_sle
+from erpnext.stock.stock_ledger import get_items_to_be_repost, repost_future_sle
class RepostItemValuation(Document):
@@ -138,13 +138,20 @@
if doc.based_on == 'Transaction':
ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no)
- items, warehouses = ref_doc.get_items_and_warehouses()
+ doc_items, doc_warehouses = ref_doc.get_items_and_warehouses()
+
+ sles = get_items_to_be_repost(doc.voucher_type, doc.voucher_no)
+ sle_items = [sle.item_code for sle in sles]
+ sle_warehouse = [sle.warehouse for sle in sles]
+
+ items = list(set(doc_items).union(set(sle_items)))
+ warehouses = list(set(doc_warehouses).union(set(sle_warehouse)))
else:
items = [doc.item_code]
warehouses = [doc.warehouse]
update_gl_entries_after(doc.posting_date, doc.posting_time,
- warehouses, items, company=doc.company)
+ for_warehouses=warehouses, for_items=items, company=doc.company)
def notify_error_to_stock_managers(doc, traceback):
recipients = get_users_with_role("Stock Manager")
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index a2ef7b4..782fcf0 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -1115,7 +1115,7 @@
self.set_actual_qty()
self.update_items_for_process_loss()
self.validate_customer_provided_item()
- self.calculate_rate_and_amount()
+ self.calculate_rate_and_amount(raise_error_if_no_rate=False)
def set_scrap_items(self):
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]:
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
index 3402972..a882a61 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
@@ -18,7 +18,6 @@
"items",
"section_break_9",
"expense_account",
- "reconciliation_json",
"column_break_13",
"difference_amount",
"amended_from",
@@ -112,15 +111,6 @@
"options": "Cost Center"
},
{
- "fieldname": "reconciliation_json",
- "fieldtype": "Long Text",
- "hidden": 1,
- "label": "Reconciliation JSON",
- "no_copy": 1,
- "print_hide": 1,
- "read_only": 1
- },
- {
"fieldname": "column_break_13",
"fieldtype": "Column Break"
},
@@ -155,7 +145,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-11-30 01:33:51.437194",
+ "modified": "2022-02-06 14:28:19.043905",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation",
@@ -178,5 +168,6 @@
"search_fields": "posting_date",
"show_name_in_global_search": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
index 428370c..86af0a0 100644
--- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
@@ -25,8 +25,8 @@
class TestStockReconciliation(ERPNextTestCase):
@classmethod
def setUpClass(cls):
- super().setUpClass()
create_batch_or_serial_no_items()
+ super().setUpClass()
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
def tearDown(self):
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index 438ec16..ec7fb0f 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -99,7 +99,7 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Default Valuation Method",
- "options": "FIFO\nMoving Average"
+ "options": "FIFO\nMoving Average\nLIFO"
},
{
"description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.",
@@ -346,7 +346,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-02-04 15:33:43.692736",
+ "modified": "2022-02-05 15:33:43.692736",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
index 48753b0..cb35bf7 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
@@ -167,7 +167,7 @@
{
"fieldname": "stock_queue",
"fieldtype": "Data",
- "label": "FIFO Queue",
+ "label": "FIFO/LIFO Queue",
},
{
diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py
index 6663458..62017e4 100644
--- a/erpnext/stock/stock_balance.py
+++ b/erpnext/stock/stock_balance.py
@@ -3,10 +3,9 @@
import frappe
-from frappe.utils import cstr, flt, nowdate, nowtime
+from frappe.utils import cstr, flt, now, nowdate, nowtime
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry
-from erpnext.stock.utils import update_bin
def repost(only_actual=False, allow_negative_stock=False, allow_zero_rate=False, only_bin=False):
@@ -175,6 +174,7 @@
bin.set(field, flt(value))
mismatch = True
+ bin.modified = now()
if mismatch:
bin.set_projected_qty()
bin.db_update()
@@ -227,8 +227,6 @@
"sle_id": sle_doc.name
})
- update_bin(args)
-
create_repost_item_valuation_entry({
"item_code": d[0],
"warehouse": d[1],
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 0a7ab40..41c4002 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -16,7 +16,7 @@
get_or_make_bin,
get_valuation_method,
)
-from erpnext.stock.valuation import FIFOValuation
+from erpnext.stock.valuation import FIFOValuation, LIFOValuation
class NegativeStockError(frappe.ValidationError): pass
@@ -461,7 +461,7 @@
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
else:
- self.update_fifo_values(sle)
+ self.update_queue_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
# rounding as per precision
@@ -701,14 +701,18 @@
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company)
- def update_fifo_values(self, sle):
+ def update_queue_values(self, sle):
incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty)
outgoing_rate = flt(sle.outgoing_rate)
- fifo_queue = FIFOValuation(self.wh_data.stock_queue)
+ if self.valuation_method == "LIFO":
+ stock_queue = LIFOValuation(self.wh_data.stock_queue)
+ else:
+ stock_queue = FIFOValuation(self.wh_data.stock_queue)
+
if actual_qty > 0:
- fifo_queue.add_stock(qty=actual_qty, rate=incoming_rate)
+ stock_queue.add_stock(qty=actual_qty, rate=incoming_rate)
else:
def rate_generator() -> float:
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
@@ -719,11 +723,11 @@
else:
return 0.0
- fifo_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator)
+ stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator)
- stock_qty, stock_value = fifo_queue.get_total_stock_and_value()
+ stock_qty, stock_value = stock_queue.get_total_stock_and_value()
- self.wh_data.stock_queue = fifo_queue.get_state()
+ self.wh_data.stock_queue = stock_queue.state
self.wh_data.stock_value = stock_value
if stock_qty:
self.wh_data.valuation_rate = stock_value / stock_qty
diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py
index 85788ba..648d440 100644
--- a/erpnext/stock/tests/test_valuation.py
+++ b/erpnext/stock/tests/test_valuation.py
@@ -1,16 +1,21 @@
+import json
import unittest
+import frappe
from hypothesis import given
from hypothesis import strategies as st
-from erpnext.stock.valuation import FIFOValuation, _round_off_if_near_zero
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero
+from erpnext.tests.utils import ERPNextTestCase
qty_gen = st.floats(min_value=-1e6, max_value=1e6)
value_gen = st.floats(min_value=1, max_value=1e6)
stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10)
-class TestFifoValuation(unittest.TestCase):
+class TestFIFOValuation(unittest.TestCase):
def setUp(self):
self.queue = FIFOValuation([])
@@ -164,3 +169,184 @@
total_value -= sum(q * r for q, r in consumed)
self.assertTotalQty(total_qty)
self.assertTotalValue(total_value)
+
+
+class TestLIFOValuation(unittest.TestCase):
+
+ def setUp(self):
+ self.stack = LIFOValuation([])
+
+ def tearDown(self):
+ qty, value = self.stack.get_total_stock_and_value()
+ self.assertTotalQty(qty)
+ self.assertTotalValue(value)
+
+ def assertTotalQty(self, qty):
+ self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
+
+ def assertTotalValue(self, value):
+ self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2)
+
+ def test_simple_addition(self):
+ self.stack.add_stock(1, 10)
+ self.assertTotalQty(1)
+
+ def test_merge_new_stock(self):
+ self.stack.add_stock(1, 10)
+ self.stack.add_stock(1, 10)
+ self.assertEqual(self.stack, [[2, 10]])
+
+ def test_simple_removal(self):
+ self.stack.add_stock(1, 10)
+ self.stack.remove_stock(1)
+ self.assertTotalQty(0)
+
+ def test_adding_negative_stock_keeps_rate(self):
+ self.stack = LIFOValuation([[-5.0, 100]])
+ self.stack.add_stock(1, 10)
+ self.assertEqual(self.stack, [[-4, 100]])
+
+ def test_adding_negative_stock_updates_rate(self):
+ self.stack = LIFOValuation([[-5.0, 100]])
+ self.stack.add_stock(6, 10)
+ self.assertEqual(self.stack, [[1, 10]])
+
+ def test_rounding_off(self):
+ self.stack.add_stock(1.0, 1.0)
+ self.stack.remove_stock(1.0 - 1e-9)
+ self.assertTotalQty(0)
+
+ def test_lifo_consumption(self):
+ self.stack.add_stock(10, 10)
+ self.stack.add_stock(10, 20)
+ consumed = self.stack.remove_stock(15)
+ self.assertEqual(consumed, [[10, 20], [5, 10]])
+ self.assertTotalQty(5)
+
+ def test_lifo_consumption_going_negative(self):
+ self.stack.add_stock(10, 10)
+ self.stack.add_stock(10, 20)
+ consumed = self.stack.remove_stock(25)
+ self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
+ self.assertTotalQty(-5)
+
+ def test_lifo_consumption_multiple(self):
+ self.stack.add_stock(1, 1)
+ self.stack.add_stock(2, 2)
+ consumed = self.stack.remove_stock(1)
+ self.assertEqual(consumed, [[1, 2]])
+
+ self.stack.add_stock(3, 3)
+ consumed = self.stack.remove_stock(4)
+ self.assertEqual(consumed, [[3, 3], [1, 2]])
+
+ self.stack.add_stock(4, 4)
+ consumed = self.stack.remove_stock(5)
+ self.assertEqual(consumed, [[4, 4], [1, 1]])
+
+ self.stack.add_stock(5, 5)
+ consumed = self.stack.remove_stock(5)
+ self.assertEqual(consumed, [[5, 5]])
+
+
+ @given(stock_queue_generator)
+ def test_lifo_qty_hypothesis(self, stock_stack):
+ self.stack = LIFOValuation([])
+ total_qty = 0
+
+ for qty, rate in stock_stack:
+ if qty == 0:
+ continue
+ if qty > 0:
+ self.stack.add_stock(qty, rate)
+ total_qty += qty
+ else:
+ qty = abs(qty)
+ consumed = self.stack.remove_stock(qty)
+ self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
+ total_qty -= qty
+ self.assertTotalQty(total_qty)
+
+ @given(stock_queue_generator)
+ def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
+ self.stack = LIFOValuation([])
+ total_qty = 0.0
+ total_value = 0.0
+
+ for qty, rate in stock_stack:
+ # don't allow negative stock
+ if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
+ continue
+ if qty > 0:
+ self.stack.add_stock(qty, rate)
+ total_qty += qty
+ total_value += qty * rate
+ else:
+ qty = abs(qty)
+ consumed = self.stack.remove_stock(qty)
+ self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
+ total_qty -= qty
+ total_value -= sum(q * r for q, r in consumed)
+ self.assertTotalQty(total_qty)
+ self.assertTotalValue(total_value)
+
+class TestLIFOValuationSLE(ERPNextTestCase):
+ ITEM_CODE = "_Test LIFO item"
+ WAREHOUSE = "_Test Warehouse - _TC"
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ super().setUpClass()
+ make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"})
+
+ def _make_stock_entry(self, qty, rate=None):
+ kwargs = {
+ "item_code": self.ITEM_CODE,
+ "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE,
+ "rate": rate,
+ "qty": abs(qty),
+ }
+ return make_stock_entry(**kwargs)
+
+ def assertStockQueue(self, se, expected_queue):
+ sle_name = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"})
+ sle = frappe.get_doc("Stock Ledger Entry", sle_name)
+
+ stock_queue = json.loads(sle.stock_queue)
+
+ total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value()
+ self.assertEqual(sle.qty_after_transaction, total_qty)
+ self.assertEqual(sle.stock_value, total_value)
+
+ if total_qty > 0:
+ self.assertEqual(stock_queue, expected_queue)
+
+
+ def test_lifo_values(self):
+
+ in1 = self._make_stock_entry(1, 1)
+ self.assertStockQueue(in1, [[1, 1]])
+
+ in2 = self._make_stock_entry(2, 2)
+ self.assertStockQueue(in2, [[1, 1], [2, 2]])
+
+ out1 = self._make_stock_entry(-1)
+ self.assertStockQueue(out1, [[1, 1], [1, 2]])
+
+ in3 = self._make_stock_entry(3, 3)
+ self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]])
+
+ out2 = self._make_stock_entry(-4)
+ self.assertStockQueue(out2, [[1, 1]])
+
+ in4 = self._make_stock_entry(4, 4)
+ self.assertStockQueue(in4, [[1, 1], [4,4]])
+
+ out3 = self._make_stock_entry(-5)
+ self.assertStockQueue(out3, [])
+
+ in5 = self._make_stock_entry(5, 5)
+ self.assertStockQueue(in5, [[5, 5]])
+
+ out5 = self._make_stock_entry(-5)
+ self.assertStockQueue(out5, [])
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index 7c63c17..7263e39 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -9,6 +9,7 @@
from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime
import erpnext
+from erpnext.stock.valuation import FIFOValuation, LIFOValuation
class InvalidWarehouseCompany(frappe.ValidationError): pass
@@ -205,16 +206,6 @@
return bin_obj
-def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False):
- """WARNING: This function is deprecated. Inline this function instead of using it."""
- from erpnext.stock.doctype.bin.bin import update_stock
- is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item')
- if is_stock_item:
- bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse"))
- update_stock(bin_name, args, allow_negative_stock, via_landed_cost_voucher)
- else:
- frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code")))
-
@frappe.whitelist()
def get_incoming_rate(args, raise_error_if_no_rate=True):
"""Get Incoming Rate based on valuation method"""
@@ -228,10 +219,10 @@
else:
valuation_method = get_valuation_method(args.get("item_code"))
previous_sle = get_previous_sle(args)
- if valuation_method == 'FIFO':
+ if valuation_method in ('FIFO', 'LIFO'):
if previous_sle:
previous_stock_queue = json.loads(previous_sle.get('stock_queue', '[]') or '[]')
- in_rate = get_fifo_rate(previous_stock_queue, args.get("qty") or 0) if previous_stock_queue else 0
+ in_rate = _get_fifo_lifo_rate(previous_stock_queue, args.get("qty") or 0, valuation_method) if previous_stock_queue else 0
elif valuation_method == 'Moving Average':
in_rate = previous_sle.get('valuation_rate') or 0
@@ -261,29 +252,25 @@
def get_fifo_rate(previous_stock_queue, qty):
"""get FIFO (average) Rate from Queue"""
- if flt(qty) >= 0:
- total = sum(f[0] for f in previous_stock_queue)
- return sum(flt(f[0]) * flt(f[1]) for f in previous_stock_queue) / flt(total) if total else 0.0
- else:
- available_qty_for_outgoing, outgoing_cost = 0, 0
- qty_to_pop = abs(flt(qty))
- while qty_to_pop and previous_stock_queue:
- batch = previous_stock_queue[0]
- if 0 < batch[0] <= qty_to_pop:
- # if batch qty > 0
- # not enough or exactly same qty in current batch, clear batch
- available_qty_for_outgoing += flt(batch[0])
- outgoing_cost += flt(batch[0]) * flt(batch[1])
- qty_to_pop -= batch[0]
- previous_stock_queue.pop(0)
- else:
- # all from current batch
- available_qty_for_outgoing += flt(qty_to_pop)
- outgoing_cost += flt(qty_to_pop) * flt(batch[1])
- batch[0] -= qty_to_pop
- qty_to_pop = 0
+ return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO")
- return outgoing_cost / available_qty_for_outgoing
+def get_lifo_rate(previous_stock_queue, qty):
+ """get LIFO (average) Rate from Queue"""
+ return _get_fifo_lifo_rate(previous_stock_queue, qty, "LIFO")
+
+
+def _get_fifo_lifo_rate(previous_stock_queue, qty, method):
+ ValuationKlass = LIFOValuation if method == "LIFO" else FIFOValuation
+
+ stock_queue = ValuationKlass(previous_stock_queue)
+ if flt(qty) >= 0:
+ total_qty, total_value = stock_queue.get_total_stock_and_value()
+ return total_value / total_qty if total_qty else 0.0
+ else:
+ popped_bins = stock_queue.remove_stock(abs(flt(qty)))
+
+ total_qty, total_value = ValuationKlass(popped_bins).get_total_stock_and_value()
+ return total_value / total_qty if total_qty else 0.0
def get_valid_serial_nos(sr_nos, qty=0, item_code=''):
"""split serial nos, validate and return list of valid serial nos"""
diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py
index 45c5083..ee9477e 100644
--- a/erpnext/stock/valuation.py
+++ b/erpnext/stock/valuation.py
@@ -1,15 +1,54 @@
+from abc import ABC, abstractmethod, abstractproperty
from typing import Callable, List, NewType, Optional, Tuple
from frappe.utils import flt
-FifoBin = NewType("FifoBin", List[float])
+StockBin = NewType("StockBin", List[float]) # [[qty, rate], ...]
# Indexes of values inside FIFO bin 2-tuple
QTY = 0
RATE = 1
-class FIFOValuation:
+class BinWiseValuation(ABC):
+
+ @abstractmethod
+ def add_stock(self, qty: float, rate: float) -> None:
+ pass
+
+ @abstractmethod
+ def remove_stock(
+ self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
+ ) -> List[StockBin]:
+ pass
+
+ @abstractproperty
+ def state(self) -> List[StockBin]:
+ pass
+
+ def get_total_stock_and_value(self) -> Tuple[float, float]:
+ total_qty = 0.0
+ total_value = 0.0
+
+ for qty, rate in self.state:
+ total_qty += flt(qty)
+ total_value += flt(qty) * flt(rate)
+
+ return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
+
+ def __repr__(self):
+ return str(self.state)
+
+ def __iter__(self):
+ return iter(self.state)
+
+ def __eq__(self, other):
+ if isinstance(other, list):
+ return self.state == other
+ return type(self) == type(other) and self.state == other.state
+
+
+class FIFOValuation(BinWiseValuation):
"""Valuation method where a queue of all the incoming stock is maintained.
New stock is added at end of the queue.
@@ -24,34 +63,14 @@
# ref: https://docs.python.org/3/reference/datamodel.html#slots
__slots__ = ["queue",]
- def __init__(self, state: Optional[List[FifoBin]]):
- self.queue: List[FifoBin] = state if state is not None else []
+ def __init__(self, state: Optional[List[StockBin]]):
+ self.queue: List[StockBin] = state if state is not None else []
- def __repr__(self):
- return str(self.queue)
-
- def __iter__(self):
- return iter(self.queue)
-
- def __eq__(self, other):
- if isinstance(other, list):
- return self.queue == other
- return self.queue == other.queue
-
- def get_state(self) -> List[FifoBin]:
+ @property
+ def state(self) -> List[StockBin]:
"""Get current state of queue."""
return self.queue
- def get_total_stock_and_value(self) -> Tuple[float, float]:
- total_qty = 0.0
- total_value = 0.0
-
- for qty, rate in self.queue:
- total_qty += flt(qty)
- total_value += flt(qty) * flt(rate)
-
- return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
-
def add_stock(self, qty: float, rate: float) -> None:
"""Update fifo queue with new stock.
@@ -78,7 +97,7 @@
def remove_stock(
self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
- ) -> List[FifoBin]:
+ ) -> List[StockBin]:
"""Remove stock from the queue and return popped bins.
args:
@@ -136,6 +155,101 @@
return consumed_bins
+class LIFOValuation(BinWiseValuation):
+ """Valuation method where a *stack* of all the incoming stock is maintained.
+
+ New stock is added at top of the stack.
+ Qty consumption happens on Last In First Out basis.
+
+ Stack is implemented using "bins" of [qty, rate].
+
+ ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
+ Implementation detail: appends and pops both at end of list.
+ """
+
+ # specifying the attributes to save resources
+ # ref: https://docs.python.org/3/reference/datamodel.html#slots
+ __slots__ = ["stack",]
+
+ def __init__(self, state: Optional[List[StockBin]]):
+ self.stack: List[StockBin] = state if state is not None else []
+
+ @property
+ def state(self) -> List[StockBin]:
+ """Get current state of stack."""
+ return self.stack
+
+ def add_stock(self, qty: float, rate: float) -> None:
+ """Update lifo stack with new stock.
+
+ args:
+ qty: new quantity to add
+ rate: incoming rate of new quantity.
+
+ Behaviour of this is same as FIFO valuation.
+ """
+ if not len(self.stack):
+ self.stack.append([0, 0])
+
+ # last row has the same rate, merge new bin.
+ if self.stack[-1][RATE] == rate:
+ self.stack[-1][QTY] += qty
+ else:
+ # Item has a positive balance qty, add new entry
+ if self.stack[-1][QTY] > 0:
+ self.stack.append([qty, rate])
+ else: # negative balance qty
+ qty = self.stack[-1][QTY] + qty
+ if qty > 0: # new balance qty is positive
+ self.stack[-1] = [qty, rate]
+ else: # new balance qty is still negative, maintain same rate
+ self.stack[-1][QTY] = qty
+
+
+ def remove_stock(
+ self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
+ ) -> List[StockBin]:
+ """Remove stock from the stack and return popped bins.
+
+ args:
+ qty: quantity to remove
+ rate: outgoing rate - ignored. Kept for backwards compatibility.
+ rate_generator: function to be called if stack is not found and rate is required.
+ """
+ if not rate_generator:
+ rate_generator = lambda : 0.0 # noqa
+
+ consumed_bins = []
+ while qty:
+ if not len(self.stack):
+ # rely on rate generator.
+ self.stack.append([0, rate_generator()])
+
+ # start at the end.
+ index = -1
+
+ stock_bin = self.stack[index]
+ if qty >= stock_bin[QTY]:
+ # consume current bin
+ qty = _round_off_if_near_zero(qty - stock_bin[QTY])
+ to_consume = self.stack.pop(index)
+ consumed_bins.append(list(to_consume))
+
+ if not self.stack and qty:
+ # stock finished, qty still remains to be withdrawn
+ # negative stock, keep in as a negative bin
+ self.stack.append([-qty, outgoing_rate or stock_bin[RATE]])
+ consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]])
+ break
+ else:
+ # qty found in current bin consume it and exit
+ stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty)
+ consumed_bins.append([qty, stock_bin[RATE]])
+ qty = 0
+
+ return consumed_bins
+
+
def _round_off_if_near_zero(number: float, precision: int = 7) -> float:
"""Rounds off the number to zero only if number is close to zero for decimal
specified in precision. Precision defaults to 7.
diff --git a/erpnext/tests/test_point_of_sale.py b/erpnext/tests/test_point_of_sale.py
index df2dc8b..3299c88 100644
--- a/erpnext/tests/test_point_of_sale.py
+++ b/erpnext/tests/test_point_of_sale.py
@@ -1,15 +1,25 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
+import unittest
+
+import frappe
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.selling.page.point_of_sale.point_of_sale import get_items
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
-from erpnext.tests.utils import ERPNextTestCase
-class TestPointOfSale(ERPNextTestCase):
+class TestPointOfSale(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ frappe.db.savepoint('before_test_point_of_sale')
+
+ @classmethod
+ def tearDownClass(cls) -> None:
+ frappe.db.rollback(save_point='before_test_point_of_sale')
+
def test_item_search(self):
"""
Test Stock and Service Item Search.
diff --git a/erpnext/tests/ui_test_bulk_transaction_processing.py b/erpnext/tests/ui_test_bulk_transaction_processing.py
new file mode 100644
index 0000000..d78689e
--- /dev/null
+++ b/erpnext/tests/ui_test_bulk_transaction_processing.py
@@ -0,0 +1,21 @@
+import frappe
+
+from erpnext.bulk_transaction.doctype.bulk_transaction_logger.test_bulk_transaction_logger import (
+ create_company,
+ create_customer,
+ create_item,
+ create_so,
+)
+
+
+@frappe.whitelist()
+def create_records():
+ create_company()
+ create_customer()
+ create_item()
+
+ gd = frappe.get_doc("Global Defaults")
+ gd.set("default_company", "Test Bulk")
+ gd.save()
+ frappe.clear_cache()
+ create_so()
\ No newline at end of file
diff --git a/erpnext/utilities/bulk_transaction.py b/erpnext/utilities/bulk_transaction.py
new file mode 100644
index 0000000..64e2ff4
--- /dev/null
+++ b/erpnext/utilities/bulk_transaction.py
@@ -0,0 +1,201 @@
+import json
+from datetime import date, datetime
+
+import frappe
+from frappe import _
+
+
+@frappe.whitelist()
+def transaction_processing(data, from_doctype, to_doctype):
+ if isinstance(data, str):
+ deserialized_data = json.loads(data)
+
+ else:
+ deserialized_data = data
+
+ length_of_data = len(deserialized_data)
+
+ if length_of_data > 10:
+ frappe.msgprint(
+ _("Started a background job to create {1} {0}").format(to_doctype, length_of_data)
+ )
+ frappe.enqueue(
+ job,
+ deserialized_data=deserialized_data,
+ from_doctype=from_doctype,
+ to_doctype=to_doctype,
+ )
+ else:
+ job(deserialized_data, from_doctype, to_doctype)
+
+
+def job(deserialized_data, from_doctype, to_doctype):
+ failed_history = []
+ i = 0
+ for d in deserialized_data:
+ failed = []
+
+ try:
+ i += 1
+ doc_name = d.get("name")
+ frappe.db.savepoint("before_creation_state")
+ task(doc_name, from_doctype, to_doctype)
+
+ except Exception as e:
+ frappe.db.rollback(save_point="before_creation_state")
+ failed_history.append(e)
+ failed.append(e)
+ update_logger(doc_name, e, from_doctype, to_doctype, status="Failed", log_date=str(date.today()))
+ if not failed:
+ update_logger(doc_name, None, from_doctype, to_doctype, status="Success", log_date=str(date.today()))
+
+ show_job_status(failed_history, deserialized_data, to_doctype)
+
+
+def task(doc_name, from_doctype, to_doctype):
+ from erpnext.accounts.doctype.payment_entry import payment_entry
+ from erpnext.accounts.doctype.purchase_invoice import purchase_invoice
+ from erpnext.accounts.doctype.sales_invoice import sales_invoice
+ from erpnext.buying.doctype.purchase_order import purchase_order
+ from erpnext.buying.doctype.supplier_quotation import supplier_quotation
+ from erpnext.selling.doctype.quotation import quotation
+ from erpnext.selling.doctype.sales_order import sales_order
+ from erpnext.stock.doctype.delivery_note import delivery_note
+ from erpnext.stock.doctype.purchase_receipt import purchase_receipt
+
+ mapper = {
+ "Sales Order": {
+ "Sales Invoice": sales_order.make_sales_invoice,
+ "Delivery Note": sales_order.make_delivery_note,
+ "Advance Payment": payment_entry.get_payment_entry,
+ },
+ "Sales Invoice": {
+ "Delivery Note": sales_invoice.make_delivery_note,
+ "Payment": payment_entry.get_payment_entry,
+ },
+ "Delivery Note": {
+ "Sales Invoice": delivery_note.make_sales_invoice,
+ "Packing Slip": delivery_note.make_packing_slip,
+ },
+ "Quotation": {
+ "Sales Order": quotation.make_sales_order,
+ "Sales Invoice": quotation.make_sales_invoice,
+ },
+ "Supplier Quotation": {
+ "Purchase Order": supplier_quotation.make_purchase_order,
+ "Purchase Invoice": supplier_quotation.make_purchase_invoice,
+ "Advance Payment": payment_entry.get_payment_entry,
+ },
+ "Purchase Order": {
+ "Purchase Invoice": purchase_order.make_purchase_invoice,
+ "Purchase Receipt": purchase_order.make_purchase_receipt,
+ },
+ "Purhcase Invoice": {
+ "Purchase Receipt": purchase_invoice.make_purchase_receipt,
+ "Payment": payment_entry.get_payment_entry,
+ },
+ "Purchase Receipt": {"Purchase Invoice": purchase_receipt.make_purchase_invoice},
+ }
+ if to_doctype in ['Advance Payment', 'Payment']:
+ obj = mapper[from_doctype][to_doctype](from_doctype, doc_name)
+ else:
+ obj = mapper[from_doctype][to_doctype](doc_name)
+
+ obj.flags.ignore_validate = True
+ obj.insert(ignore_mandatory=True)
+
+
+def check_logger_doc_exists(log_date):
+ return frappe.db.exists("Bulk Transaction Log", log_date)
+
+
+def get_logger_doc(log_date):
+ return frappe.get_doc("Bulk Transaction Log", log_date)
+
+
+def create_logger_doc():
+ log_doc = frappe.new_doc("Bulk Transaction Log")
+ log_doc.set_new_name(set_name=str(date.today()))
+ log_doc.log_date = date.today()
+
+ return log_doc
+
+
+def append_data_to_logger(log_doc, doc_name, error, from_doctype, to_doctype, status, restarted):
+ row = log_doc.append("logger_data", {})
+ row.transaction_name = doc_name
+ row.date = date.today()
+ now = datetime.now()
+ row.time = now.strftime("%H:%M:%S")
+ row.transaction_status = status
+ row.error_description = str(error)
+ row.from_doctype = from_doctype
+ row.to_doctype = to_doctype
+ row.retried = restarted
+
+
+def update_logger(doc_name, e, from_doctype, to_doctype, status, log_date=None, restarted=0):
+ if not check_logger_doc_exists(log_date):
+ log_doc = create_logger_doc()
+ append_data_to_logger(log_doc, doc_name, e, from_doctype, to_doctype, status, restarted)
+ log_doc.insert()
+ else:
+ log_doc = get_logger_doc(log_date)
+ if record_exists(log_doc, doc_name, status):
+ append_data_to_logger(
+ log_doc, doc_name, e, from_doctype, to_doctype, status, restarted
+ )
+ log_doc.save()
+
+
+def show_job_status(failed_history, deserialized_data, to_doctype):
+ if not failed_history:
+ frappe.msgprint(
+ _("Creation of {0} successful").format(to_doctype),
+ title="Successful",
+ indicator="green",
+ )
+
+ if len(failed_history) != 0 and len(failed_history) < len(deserialized_data):
+ frappe.msgprint(
+ _("""Creation of {0} partially successful.
+ Check <b><a href="/app/bulk-transaction-log">Bulk Transaction Log</a></b>""").format(
+ to_doctype
+ ),
+ title="Partially successful",
+ indicator="orange",
+ )
+
+ if len(failed_history) == len(deserialized_data):
+ frappe.msgprint(
+ _("""Creation of {0} failed.
+ Check <b><a href="/app/bulk-transaction-log">Bulk Transaction Log</a></b>""").format(
+ to_doctype
+ ),
+ title="Failed",
+ indicator="red",
+ )
+
+
+def record_exists(log_doc, doc_name, status):
+
+ record = mark_retrired_transaction(log_doc, doc_name)
+
+ if record and status == "Failed":
+ return False
+ elif record and status == "Success":
+ return True
+ else:
+ return True
+
+
+def mark_retrired_transaction(log_doc, doc_name):
+ record = 0
+ for d in log_doc.get("logger_data"):
+ if d.transaction_name == doc_name and d.transaction_status == "Failed":
+ d.retried = 1
+ record = record + 1
+
+ log_doc.save()
+
+ return record
\ No newline at end of file
diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.js b/erpnext/utilities/doctype/rename_tool/rename_tool.js
index 7823055..5553e44 100644
--- a/erpnext/utilities/doctype/rename_tool/rename_tool.js
+++ b/erpnext/utilities/doctype/rename_tool/rename_tool.js
@@ -13,6 +13,12 @@
},
refresh: function(frm) {
frm.disable_save();
+
+ frm.get_field("file_to_rename").df.options = {
+ restrictions: {
+ allowed_file_types: [".csv"],
+ },
+ };
if (!frm.doc.file_to_rename) {
frm.get_field("rename_log").$wrapper.html("");
}