diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index f2a696d565a..a0c0eccff14 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -2648,6 +2648,7 @@ class TestSalesInvoice(unittest.TestCase): # reset einvoice_settings = frappe.get_doc("E Invoice Settings") einvoice_settings.enable = 0 + einvoice_settings.save() frappe.flags.country = country def test_einvoice_json(self): diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 78645e0d4ff..46013bb367f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2451,11 +2451,21 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row ) - def validate_quantity(child_item, d): - if parent_doctype == "Sales Order" and flt(d.get("qty")) < flt(child_item.delivered_qty): + def validate_quantity(child_item, new_data): + if not flt(new_data.get("qty")): + frappe.throw( + _("Row # {0}: Quantity for Item {1} cannot be zero").format( + new_data.get("idx"), frappe.bold(new_data.get("item_code")) + ), + title=_("Invalid Qty"), + ) + + if parent_doctype == "Sales Order" and flt(new_data.get("qty")) < flt(child_item.delivered_qty): frappe.throw(_("Cannot set quantity less than delivered quantity")) - if parent_doctype == "Purchase Order" and flt(d.get("qty")) < flt(child_item.received_qty): + if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt( + child_item.received_qty + ): frappe.throw(_("Cannot set quantity less than received quantity")) data = json.loads(trans_items) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 19b4d68e1cf..b590177562d 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -9,7 +9,7 @@ from frappe import _ from frappe.email.inbox import link_communication_to_document from frappe.model.mapper import get_mapped_doc from frappe.query_builder import DocType -from frappe.utils import cint, cstr, flt, get_fullname +from frappe.utils import cint, flt, get_fullname from erpnext.crm.utils import add_link_in_communication, copy_comments from erpnext.setup.utils import get_exchange_rate @@ -215,20 +215,20 @@ class Opportunity(TransactionBase): if self.party_name and self.opportunity_from == "Customer": if self.contact_person: - opts.description = "Contact " + cstr(self.contact_person) + opts.description = f"Contact {self.contact_person}" else: - opts.description = "Contact customer " + cstr(self.party_name) + opts.description = f"Contact customer {self.party_name}" elif self.party_name and self.opportunity_from == "Lead": if self.contact_display: - opts.description = "Contact " + cstr(self.contact_display) + opts.description = f"Contact {self.contact_display}" else: - opts.description = "Contact lead " + cstr(self.party_name) + opts.description = f"Contact lead {self.party_name}" opts.subject = opts.description - opts.description += ". By : " + cstr(self.contact_by) + opts.description += f". By : {self.contact_by}" if self.to_discuss: - opts.description += " To Discuss : " + cstr(self.to_discuss) + opts.description += f" To Discuss : {frappe.render_template(self.to_discuss, {'doc': self})}" super(Opportunity, self).add_calendar_event(opts, force) diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 481c7f1e262..4a18e940bcf 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import now_datetime, random_string, today +from frappe.utils import add_days, now_datetime, random_string, today from erpnext.crm.doctype.lead.lead import make_customer from erpnext.crm.doctype.lead.test_lead import make_lead @@ -97,6 +97,22 @@ class TestOpportunity(unittest.TestCase): self.assertEqual(quotation_comment_count, 4) self.assertEqual(quotation_communication_count, 4) + def test_render_template_for_to_discuss(self): + doc = make_opportunity(with_items=0, opportunity_from="Lead") + doc.contact_by = "test@example.com" + doc.contact_date = add_days(today(), days=2) + doc.to_discuss = "{{ doc.name }} test data" + doc.save() + + event = frappe.get_all( + "Event Participants", + fields=["parent"], + filters={"reference_doctype": doc.doctype, "reference_docname": doc.name}, + ) + + event_description = frappe.db.get_value("Event", event[0].parent, "description") + self.assertTrue(doc.name in event_description) + def make_opportunity_from_lead(): new_lead_email_id = "new{}@example.com".format(random_string(5)) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d5b15922ec3..1fef2404a4d 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -369,4 +369,5 @@ erpnext.patches.v13_0.copy_custom_field_filters_to_website_item erpnext.patches.v13_0.change_default_item_manufacturer_fieldtype erpnext.patches.v14_0.discount_accounting_separation erpnext.patches.v14_0.delete_employee_transfer_property_doctype -erpnext.patches.v13_0.create_accounting_dimensions_in_orders \ No newline at end of file +erpnext.patches.v13_0.create_accounting_dimensions_in_orders +erpnext.patches.v13_0.set_per_billed_in_return_delivery_note \ No newline at end of file diff --git a/erpnext/patches/v13_0/set_per_billed_in_return_delivery_note.py b/erpnext/patches/v13_0/set_per_billed_in_return_delivery_note.py new file mode 100644 index 00000000000..a4d70124492 --- /dev/null +++ b/erpnext/patches/v13_0/set_per_billed_in_return_delivery_note.py @@ -0,0 +1,29 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe + + +def execute(): + dn = frappe.qb.DocType("Delivery Note") + dn_item = frappe.qb.DocType("Delivery Note Item") + + dn_list = ( + frappe.qb.from_(dn) + .inner_join(dn_item) + .on(dn.name == dn_item.parent) + .select(dn.name) + .where(dn.docstatus == 1) + .where(dn.is_return == 1) + .where(dn.per_billed < 100) + .where(dn_item.returned_qty > 0) + .run(as_dict=True) + ) + + frappe.qb.update(dn_item).inner_join(dn).on(dn.name == dn_item.parent).set( + dn_item.returned_qty, 0 + ).where(dn.is_return == 1).where(dn_item.returned_qty > 0).run() + + for d in dn_list: + dn_doc = frappe.get_doc("Delivery Note", d.get("name")) + dn_doc.run_method("update_billing_status") diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js index c4b27a5d63b..ea56d07d6da 100644 --- a/erpnext/regional/india/e_invoice/einvoice.js +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -204,6 +204,29 @@ erpnext.setup_einvoice_actions = (doctype) => { }; add_custom_button(__("Cancel E-Way Bill"), action); } + + if (irn && !irn_cancelled) { + const action = () => { + const dialog = frappe.msgprint({ + title: __("Generate QRCode"), + message: __("Generate and attach QR Code using IRN?"), + primary_action: { + action: function() { + frappe.call({ + method: 'erpnext.regional.india.e_invoice.utils.generate_qrcode', + args: { doctype, docname: name }, + freeze: true, + callback: () => frm.reload_doc() || dialog.hide(), + error: () => dialog.hide() + }); + } + }, + primary_action_label: __('Yes') + }); + dialog.show(); + }; + add_custom_button(__("Generate QRCode"), action); + } } }); }; diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 53d3211d3b9..ed1002a129e 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -167,7 +167,12 @@ def get_doc_details(invoice): title=_("Not Allowed"), ) - invoice_type = "CRN" if invoice.is_return else "INV" + if invoice.is_return: + invoice_type = "CRN" + elif invoice.is_debit_note: + invoice_type = "DBN" + else: + invoice_type = "INV" invoice_name = invoice.name invoice_date = format_date(invoice.posting_date, "dd/mm/yyyy") @@ -794,6 +799,7 @@ class GSPConnector: self.gstin_details_url = self.base_url + "/enriched/ei/api/master/gstin" self.cancel_ewaybill_url = self.base_url + "/enriched/ei/api/ewayapi" self.generate_ewaybill_url = self.base_url + "/enriched/ei/api/ewaybill" + self.get_qrcode_url = self.base_url + "/enriched/ei/others/qr/image" def set_invoice(self): self.invoice = None @@ -857,8 +863,8 @@ class GSPConnector: return res def auto_refresh_token(self): - self.fetch_auth_token() self.token_auto_refreshed = True + self.fetch_auth_token() def log_request(self, url, headers, data, res): headers.update({"password": self.credentials.password}) @@ -998,6 +1004,37 @@ class GSPConnector: return failed + def fetch_and_attach_qrcode_from_irn(self): + qrcode = self.get_qrcode_from_irn(self.invoice.irn) + if qrcode: + qrcode_file = self.create_qr_code_file(qrcode) + frappe.db.set_value("Sales Invoice", self.invoice.name, "qrcode_image", qrcode_file.file_url) + frappe.msgprint(_("QR Code attached to the invoice"), alert=True) + else: + frappe.msgprint(_("QR Code not found for the IRN"), alert=True) + + def get_qrcode_from_irn(self, irn): + import requests + + headers = self.get_headers() + headers.update({"width": "215", "height": "215", "imgtype": "jpg", "irn": irn}) + + try: + # using requests.get instead of make_request to avoid parsing the response + res = requests.get(self.get_qrcode_url, headers=headers) + self.log_request(self.get_qrcode_url, headers, None, None) + if res.status_code == 200: + return res.content + else: + raise RequestFailed(str(res.content, "utf-8")) + + except RequestFailed as e: + self.raise_error(errors=str(e)) + + except Exception: + log_error() + self.raise_error() + def get_irn_details(self, irn): headers = self.get_headers() @@ -1198,8 +1235,6 @@ class GSPConnector: return errors def raise_error(self, raise_exception=False, errors=None): - if errors is None: - errors = [] title = _("E Invoice Request Failed") if errors: frappe.throw(errors, title=title, as_list=1) @@ -1240,13 +1275,18 @@ class GSPConnector: def attach_qrcode_image(self): qrcode = self.invoice.signed_qr_code - doctype = self.invoice.doctype - docname = self.invoice.name - filename = "QRCode_{}.png".format(docname).replace(os.path.sep, "__") qr_image = io.BytesIO() url = qrcreate(qrcode, error="L") url.png(qr_image, scale=2, quiet_zone=1) + qrcode_file = self.create_qr_code_file(qr_image.getvalue()) + self.invoice.qrcode_image = qrcode_file.file_url + + def create_qr_code_file(self, qr_image): + doctype = self.invoice.doctype + docname = self.invoice.name + filename = "QRCode_{}.png".format(docname).replace(os.path.sep, "__") + _file = frappe.get_doc( { "doctype": "File", @@ -1255,12 +1295,12 @@ class GSPConnector: "attached_to_name": docname, "attached_to_field": "qrcode_image", "is_private": 0, - "content": qr_image.getvalue(), + "content": qr_image, } ) _file.save() frappe.db.commit() - self.invoice.qrcode_image = _file.file_url + return _file def update_invoice(self): self.invoice.flags.ignore_validate_update_after_submit = True @@ -1305,6 +1345,12 @@ def cancel_irn(doctype, docname, irn, reason, remark): gsp_connector.cancel_irn(irn, reason, remark) +@frappe.whitelist() +def generate_qrcode(doctype, docname): + gsp_connector = GSPConnector(doctype, docname) + gsp_connector.fetch_and_attach_qrcode_from_irn() + + @frappe.whitelist() def generate_eway_bill(doctype, docname, **kwargs): gsp_connector = GSPConnector(doctype, docname) diff --git a/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py b/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py index 091c20c917f..e10df2acbb5 100644 --- a/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py +++ b/erpnext/selling/report/item_wise_sales_history/item_wise_sales_history.py @@ -238,4 +238,5 @@ def get_chart_data(data): "datasets": [{"name": _("Total Sales Amount"), "values": datapoints[:30]}], }, "type": "bar", + "fieldtype": "Currency", } diff --git a/erpnext/selling/report/quotation_trends/quotation_trends.py b/erpnext/selling/report/quotation_trends/quotation_trends.py index 4e0758d7cda..4d71ce77c4f 100644 --- a/erpnext/selling/report/quotation_trends/quotation_trends.py +++ b/erpnext/selling/report/quotation_trends/quotation_trends.py @@ -54,4 +54,5 @@ def get_chart_data(data, conditions, filters): }, "type": "line", "lineOptions": {"regionFill": 1}, + "fieldtype": "Currency", } diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index 1a2476a9da1..9d7d806c716 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -415,3 +415,8 @@ class Analytics(object): else: labels = [d.get("label") for d in self.columns[1 : length - 1]] self.chart = {"data": {"labels": labels, "datasets": []}, "type": "line"} + + if self.filters["value_quantity"] == "Value": + self.chart["fieldtype"] = "Currency" + else: + self.chart["fieldtype"] = "Float" diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.py b/erpnext/selling/report/sales_order_trends/sales_order_trends.py index 719f1c52745..18f448c7cd2 100644 --- a/erpnext/selling/report/sales_order_trends/sales_order_trends.py +++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.py @@ -51,4 +51,5 @@ def get_chart_data(data, conditions, filters): }, "type": "line", "lineOptions": {"regionFill": 1}, + "fieldtype": "Currency", } diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index f97e7ca9c68..0738bfbd8fc 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -962,6 +962,44 @@ class TestDeliveryNote(FrappeTestCase): automatically_fetch_payment_terms(enable=0) + def test_returned_qty_in_return_dn(self): + # SO ---> SI ---> DN + # | + # |---> DN(Partial Sales Return) ---> SI(Credit Note) + # | + # |---> DN(Partial Sales Return) ---> SI(Credit Note) + + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + + so = make_sales_order(qty=10) + si = make_sales_invoice(so.name) + si.insert() + si.submit() + dn = make_delivery_note(si.name) + dn.insert() + dn.submit() + self.assertEqual(dn.items[0].returned_qty, 0) + self.assertEqual(dn.per_billed, 100) + + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice + + dn1 = create_delivery_note(is_return=1, return_against=dn.name, qty=-3) + si1 = make_sales_invoice(dn1.name) + si1.insert() + si1.submit() + dn1.reload() + self.assertEqual(dn1.items[0].returned_qty, 0) + self.assertEqual(dn1.per_billed, 100) + + dn2 = create_delivery_note(is_return=1, return_against=dn.name, qty=-4) + si2 = make_sales_invoice(dn2.name) + si2.insert() + si2.submit() + dn2.reload() + self.assertEqual(dn2.items[0].returned_qty, 0) + self.assertEqual(dn2.per_billed, 100) + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") diff --git a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json index e2eb2a4bbb2..2d7abc8a0d6 100644 --- a/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json +++ b/erpnext/stock/doctype/delivery_note_item/delivery_note_item.json @@ -737,7 +737,9 @@ "depends_on": "returned_qty", "fieldname": "returned_qty", "fieldtype": "Float", - "label": "Returned Qty in Stock UOM" + "label": "Returned Qty in Stock UOM", + "no_copy": 1, + "read_only": 1 }, { "fieldname": "incoming_rate", @@ -778,7 +780,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-03-31 18:36:24.671913", + "modified": "2022-05-02 12:09:39.610075", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note Item", diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 5850ec7be66..4e2fc83a9d2 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1183,6 +1183,42 @@ class TestStockLedgerEntry(FrappeTestCase): backdated.cancel() self.assertEqual([1], ordered_qty_after_transaction()) + def test_timestamp_clash(self): + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + reciept = make_stock_entry( + item_code=item, + to_warehouse=warehouse, + qty=100, + rate=10, + posting_date="2021-01-01", + posting_time="01:00:00", + ) + + consumption = make_stock_entry( + item_code=item, + from_warehouse=warehouse, + qty=50, + posting_date="2021-01-01", + posting_time="02:00:00.1234", # ms are possible when submitted without editing posting time + ) + + backdated_receipt = make_stock_entry( + item_code=item, + to_warehouse=warehouse, + qty=100, + posting_date="2021-01-01", + rate=10, + posting_time="02:00:00", # same posting time as consumption but ms part stripped + ) + + try: + backdated_receipt.cancel() + except Exception as e: + self.fail("Double processing of qty for clashing timestamp.") + def create_repack_entry(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index e7b89b18a83..9088eb802b5 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -31,6 +31,7 @@ class TestStockReconciliation(FrappeTestCase): def tearDown(self): frappe.local.future_sle = {} + frappe.flags.pop("dont_execute_stock_reposts", None) def test_reco_for_fifo(self): self._test_reco_sle_gle("FIFO") @@ -384,6 +385,7 @@ class TestStockReconciliation(FrappeTestCase): ------------------------------------------- Var | Doc | Qty | Balance ------------------------------------------- + PR5 | PR | 10 | 10 (posting date: today-4) [backdated] SR5 | Reco | 0 | 8 (posting date: today-4) [backdated] PR1 | PR | 10 | 18 (posting date: today-3) PR2 | PR | 1 | 19 (posting date: today-2) @@ -393,6 +395,14 @@ class TestStockReconciliation(FrappeTestCase): item_code = make_item().name warehouse = "_Test Warehouse - _TC" + frappe.flags.dont_execute_stock_reposts = True + + def assertBalance(doc, qty_after_transaction): + sle_balance = frappe.db.get_value( + "Stock Ledger Entry", {"voucher_no": doc.name, "is_cancelled": 0}, "qty_after_transaction" + ) + self.assertEqual(sle_balance, qty_after_transaction) + pr1 = make_purchase_receipt( item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -3) ) @@ -402,62 +412,37 @@ class TestStockReconciliation(FrappeTestCase): pr3 = make_purchase_receipt( item_code=item_code, warehouse=warehouse, qty=1, rate=100, posting_date=nowdate() ) - - pr1_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, "qty_after_transaction" - ) - pr3_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0}, "qty_after_transaction" - ) - self.assertEqual(pr1_balance, 10) - self.assertEqual(pr3_balance, 12) + assertBalance(pr1, 10) + assertBalance(pr3, 12) # post backdated stock reco in between sr4 = create_stock_reconciliation( item_code=item_code, warehouse=warehouse, qty=6, rate=100, posting_date=add_days(nowdate(), -1) ) - pr3_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0}, "qty_after_transaction" - ) - self.assertEqual(pr3_balance, 7) + assertBalance(pr3, 7) # post backdated stock reco at the start sr5 = create_stock_reconciliation( item_code=item_code, warehouse=warehouse, qty=8, rate=100, posting_date=add_days(nowdate(), -4) ) - pr1_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, "qty_after_transaction" + assertBalance(pr1, 18) + assertBalance(pr2, 19) + assertBalance(sr4, 6) # check if future stock reco is unaffected + + # Make a backdated receipt and check only entries till first SR are affected + pr5 = make_purchase_receipt( + item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -5) ) - pr2_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0}, "qty_after_transaction" - ) - sr4_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0}, "qty_after_transaction" - ) - self.assertEqual(pr1_balance, 18) - self.assertEqual(pr2_balance, 19) - self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected + assertBalance(pr5, 10) + # check if future stock reco is unaffected + assertBalance(sr4, 6) + assertBalance(sr5, 8) # cancel backdated stock reco and check future impact sr5.cancel() - pr1_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, "qty_after_transaction" - ) - pr2_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0}, "qty_after_transaction" - ) - sr4_balance = frappe.db.get_value( - "Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0}, "qty_after_transaction" - ) - self.assertEqual(pr1_balance, 10) - self.assertEqual(pr2_balance, 11) - self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected - - # teardown - sr4.cancel() - pr3.cancel() - pr2.cancel() - pr1.cancel() + assertBalance(pr1, 10) + assertBalance(pr2, 11) + assertBalance(sr4, 6) # check if future stock reco is unaffected @change_settings("Stock Settings", {"allow_negative_stock": 0}) def test_backdated_stock_reco_future_negative_stock(self): @@ -563,7 +548,6 @@ class TestStockReconciliation(FrappeTestCase): # repost will make this test useless, qty should update in realtime without reposts frappe.flags.dont_execute_stock_reposts = True - self.addCleanup(frappe.flags.pop, "dont_execute_stock_reposts") item_code = make_item().name warehouse = "_Test Warehouse - _TC" diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/__init__.py b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.js b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.js new file mode 100644 index 00000000000..0b8f49653dd --- /dev/null +++ b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.js @@ -0,0 +1,53 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +const DIFFERNCE_FIELD_NAMES = [ + "fifo_qty_diff", + "fifo_value_diff", +]; + +frappe.query_reports["FIFO Queue vs Qty After Transaction Comparison"] = { + "filters": [ + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": "Item", + "options": "Item", + get_query: function() { + return { + filters: {is_stock_item: 1, has_serial_no: 0} + } + } + }, + { + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group", + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "label": "Warehouse", + "options": "Warehouse", + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Posting Date", + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "label": "From Posting Date", + } + ], + formatter (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + if (DIFFERNCE_FIELD_NAMES.includes(column.fieldname) && Math.abs(data[column.fieldname]) > 0.001) { + value = "" + value + ""; + } + return value; + }, +}; diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.json b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.json new file mode 100644 index 00000000000..5e958aa1b03 --- /dev/null +++ b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.json @@ -0,0 +1,27 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2022-05-11 04:09:13.460652", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": "abc", + "modified": "2022-05-11 04:09:20.232177", + "modified_by": "Administrator", + "module": "Stock", + "name": "FIFO Queue vs Qty After Transaction Comparison", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "FIFO Queue vs Qty After Transaction Comparison", + "report_type": "Script Report", + "roles": [ + { + "role": "Administrator" + } + ] +} \ No newline at end of file diff --git a/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py new file mode 100644 index 00000000000..9e140336c9a --- /dev/null +++ b/erpnext/stock/report/fifo_queue_vs_qty_after_transaction_comparison/fifo_queue_vs_qty_after_transaction_comparison.py @@ -0,0 +1,212 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.utils import flt +from frappe.utils.nestedset import get_descendants_of + +SLE_FIELDS = ( + "name", + "item_code", + "warehouse", + "posting_date", + "posting_time", + "creation", + "voucher_type", + "voucher_no", + "actual_qty", + "qty_after_transaction", + "stock_queue", + "batch_no", + "stock_value", + "valuation_rate", +) + + +def execute(filters=None): + columns = get_columns() + data = get_data(filters) + return columns, data + + +def get_data(filters): + if not any([filters.warehouse, filters.item_code, filters.item_group]): + frappe.throw(_("Any one of following filters required: warehouse, Item Code, Item Group")) + sles = get_stock_ledger_entries(filters) + return find_first_bad_queue(sles) + + +def get_stock_ledger_entries(filters): + + sle_filters = {"is_cancelled": 0} + + if filters.warehouse: + children = get_descendants_of("Warehouse", filters.warehouse) + sle_filters["warehouse"] = ("in", children + [filters.warehouse]) + + if filters.item_code: + sle_filters["item_code"] = filters.item_code + elif filters.get("item_group"): + item_group = filters.get("item_group") + children = get_descendants_of("Item Group", item_group) + item_group_filter = {"item_group": ("in", children + [item_group])} + sle_filters["item_code"] = ( + "in", + frappe.get_all("Item", filters=item_group_filter, pluck="name", order_by=None), + ) + + if filters.from_date: + sle_filters["posting_date"] = (">=", filters.from_date) + if filters.to_date: + sle_filters["posting_date"] = ("<=", filters.to_date) + + return frappe.get_all( + "Stock Ledger Entry", + fields=SLE_FIELDS, + filters=sle_filters, + order_by="timestamp(posting_date, posting_time), creation", + ) + + +def find_first_bad_queue(sles): + item_warehouse_sles = {} + for sle in sles: + item_warehouse_sles.setdefault((sle.item_code, sle.warehouse), []).append(sle) + + data = [] + + for _item_wh, sles in item_warehouse_sles.items(): + for idx, sle in enumerate(sles): + queue = json.loads(sle.stock_queue or "[]") + + sle.fifo_queue_qty = 0.0 + sle.fifo_stock_value = 0.0 + for qty, rate in queue: + sle.fifo_queue_qty += flt(qty) + sle.fifo_stock_value += flt(qty) * flt(rate) + + sle.fifo_qty_diff = sle.qty_after_transaction - sle.fifo_queue_qty + sle.fifo_value_diff = sle.stock_value - sle.fifo_stock_value + + if sle.batch_no: + sle.use_batchwise_valuation = frappe.db.get_value( + "Batch", sle.batch_no, "use_batchwise_valuation", cache=True + ) + + if abs(sle.fifo_qty_diff) > 0.001 or abs(sle.fifo_value_diff) > 0.1: + if idx: + data.append(sles[idx - 1]) + data.append(sle) + data.append({}) + break + + return data + + +def get_columns(): + return [ + { + "fieldname": "name", + "fieldtype": "Link", + "label": _("Stock Ledger Entry"), + "options": "Stock Ledger Entry", + }, + { + "fieldname": "item_code", + "fieldtype": "Link", + "label": _("Item Code"), + "options": "Item", + }, + { + "fieldname": "warehouse", + "fieldtype": "Link", + "label": _("Warehouse"), + "options": "Warehouse", + }, + { + "fieldname": "posting_date", + "fieldtype": "Data", + "label": _("Posting Date"), + }, + { + "fieldname": "posting_time", + "fieldtype": "Data", + "label": _("Posting Time"), + }, + { + "fieldname": "creation", + "fieldtype": "Data", + "label": _("Creation"), + }, + { + "fieldname": "voucher_type", + "fieldtype": "Link", + "label": _("Voucher Type"), + "options": "DocType", + }, + { + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "label": _("Voucher No"), + "options": "voucher_type", + }, + { + "fieldname": "batch_no", + "fieldtype": "Link", + "label": _("Batch"), + "options": "Batch", + }, + { + "fieldname": "use_batchwise_valuation", + "fieldtype": "Check", + "label": _("Batchwise Valuation"), + }, + { + "fieldname": "actual_qty", + "fieldtype": "Float", + "label": _("Qty Change"), + }, + { + "fieldname": "qty_after_transaction", + "fieldtype": "Float", + "label": _("(A) Qty After Transaction"), + }, + { + "fieldname": "stock_queue", + "fieldtype": "Data", + "label": _("FIFO/LIFO Queue"), + }, + { + "fieldname": "fifo_queue_qty", + "fieldtype": "Float", + "label": _("(C) Total qty in queue"), + }, + { + "fieldname": "fifo_qty_diff", + "fieldtype": "Float", + "label": _("A - C"), + }, + { + "fieldname": "stock_value", + "fieldtype": "Float", + "label": _("(D) Balance Stock Value"), + }, + { + "fieldname": "fifo_stock_value", + "fieldtype": "Float", + "label": _("(E) Balance Stock Value in Queue"), + }, + { + "fieldname": "fifo_value_diff", + "fieldtype": "Float", + "label": _("D - E"), + }, + { + "fieldname": "valuation_rate", + "fieldtype": "Float", + "label": _("(H) Valuation Rate"), + }, + ] 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 837c4a6d15c..ed0e2fc31bd 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 @@ -111,17 +111,17 @@ def get_columns(): }, { "fieldname": "posting_date", - "fieldtype": "Date", + "fieldtype": "Data", "label": _("Posting Date"), }, { "fieldname": "posting_time", - "fieldtype": "Time", + "fieldtype": "Data", "label": _("Posting Time"), }, { "fieldname": "creation", - "fieldtype": "Datetime", + "fieldtype": "Data", "label": _("Creation"), }, { diff --git a/erpnext/stock/report/test_reports.py b/erpnext/stock/report/test_reports.py index 55b910432a6..d118d8e5694 100644 --- a/erpnext/stock/report/test_reports.py +++ b/erpnext/stock/report/test_reports.py @@ -65,6 +65,8 @@ REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ ("Delayed Item Report", {"based_on": "Delivery Note"}), ("Stock Ageing", {"range1": 30, "range2": 60, "range3": 90, "_optional": True}), ("Stock Ledger Invariant Check", {"warehouse": "_Test Warehouse - _TC", "item": "_Test Item"}), + ("FIFO Queue vs Qty After Transaction Comparison", {"warehouse": "_Test Warehouse - _TC"}), + ("FIFO Queue vs Qty After Transaction Comparison", {"item_group": "All Item Groups"}), ] OPTIONAL_FILTERS = { diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 7e5c231d9c4..4789b52d50e 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1303,6 +1303,8 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): datetime_limit_condition = "" qty_shift = args.actual_qty + args["time_format"] = "%H:%i:%s" + # find difference/shift in qty caused by stock reconciliation if args.voucher_type == "Stock Reconciliation": qty_shift = get_stock_reco_qty_shift(args) @@ -1315,7 +1317,7 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): datetime_limit_condition = get_datetime_limit_condition(detail) frappe.db.sql( - """ + f""" update `tabStock Ledger Entry` set qty_after_transaction = qty_after_transaction + {qty_shift} where @@ -1323,16 +1325,10 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): and warehouse = %(warehouse)s and voucher_no != %(voucher_no)s and is_cancelled = 0 - and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s) - or ( - timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s) - and creation > %(creation)s - ) - ) + and timestamp(posting_date, time_format(posting_time, %(time_format)s)) + > timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) {datetime_limit_condition} - """.format( - qty_shift=qty_shift, datetime_limit_condition=datetime_limit_condition - ), + """, args, ) @@ -1383,6 +1379,7 @@ def get_next_stock_reco(args): and creation > %(creation)s ) ) + order by timestamp(posting_date, posting_time) asc, creation asc limit 1 """, args, diff --git a/erpnext/translations/ru.csv b/erpnext/translations/ru.csv index 6ca33447671..fb56ff68204 100644 --- a/erpnext/translations/ru.csv +++ b/erpnext/translations/ru.csv @@ -1357,7 +1357,7 @@ Item Price added for {0} in Price List {1},Цена продукта {0} доб "Item Price appears multiple times based on Price List, Supplier/Customer, Currency, Item, UOM, Qty and Dates.","Цена товара отображается несколько раз на основе Прайс-листа, Поставщика / Клиента, Валюты, Предмет, UOM, Кол-во и Даты.", Item Price updated for {0} in Price List {1},Цена продукта {0} обновлена в прайс-листе {1}, Item Row {0}: {1} {2} does not exist in above '{1}' table,Элемент Row {0}: {1} {2} не существует в таблице «{1}», -Item Tax Row {0} must have account of type Tax or Income or Expense or Chargeable,"Строка налога {0} должен иметь счет типа Налога, Доход, Расходов или Облагаемый налогом," +Item Tax Row {0} must have account of type Tax or Income or Expense or Chargeable,"Строка налога {0} должен иметь счет типа Налога, Доход, Расходов или Облагаемый налогом", Item Template,Шаблон продукта, Item Variant Settings,Параметры модификации продкута, Item Variant {0} already exists with same attributes,Модификация продукта {0} с этими атрибутами уже существует,