From d5fd8e0ba6f2b6297339dd12bb7cbf1dc0c3155e Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 21 Mar 2022 11:36:30 +0530 Subject: [PATCH 01/10] fix: prevent multiple save on applying coupon code --- .../selling/page/point_of_sale/pos_payment.js | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 1e9f6d7d920..326ee59d11a 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -170,20 +170,24 @@ erpnext.PointOfSale.Payment = class { }); frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => { - if (!frm.doc.ignore_pricing_rule && frm.doc.coupon_code) { - frappe.run_serially([ - () => frm.doc.ignore_pricing_rule=1, - () => frm.trigger('ignore_pricing_rule'), - () => frm.doc.ignore_pricing_rule=0, - () => frm.trigger('apply_pricing_rule'), - () => frm.save(), - () => this.update_totals_section(frm.doc) - ]); - } else if (frm.doc.ignore_pricing_rule && frm.doc.coupon_code) { - frappe.show_alert({ - message: __("Ignore Pricing Rule is enabled. Cannot apply coupon code."), - indicator: "orange" - }); + if (frm.doc.coupon_code && !frm.applying_pos_coupon_code) { + if (!frm.doc.ignore_pricing_rule) { + frm.applying_pos_coupon_code = true + frappe.run_serially([ + () => frm.doc.ignore_pricing_rule=1, + () => frm.trigger('ignore_pricing_rule'), + () => frm.doc.ignore_pricing_rule=0, + () => frm.trigger('apply_pricing_rule'), + () => frm.save(), + () => this.update_totals_section(frm.doc), + () => (frm.applying_pos_coupon_code = false) + ]); + } else if (frm.doc.ignore_pricing_rule) { + frappe.show_alert({ + message: __("Ignore Pricing Rule is enabled. Cannot apply coupon code."), + indicator: "orange" + }); + } } }); From 2f82e237ef650ab49d08dfd3eeaf56f1f299c84a Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 21 Mar 2022 12:47:35 +0530 Subject: [PATCH 02/10] fix(pos): customer group filter in customer selector --- .../selling/page/point_of_sale/point_of_sale.py | 15 ++++++++++++++- .../selling/page/point_of_sale/pos_controller.js | 13 +++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index 993c61d5639..67948d779f5 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -8,7 +8,7 @@ import frappe from frappe.utils.nestedset import get_root_of from erpnext.accounts.doctype.pos_invoice.pos_invoice import get_stock_availability -from erpnext.accounts.doctype.pos_profile.pos_profile import get_item_groups +from erpnext.accounts.doctype.pos_profile.pos_profile import get_child_nodes, get_item_groups def search_by_term(search_term, warehouse, price_list): @@ -275,3 +275,16 @@ def set_customer_info(fieldname, customer, value=""): contact_doc.set('phone_nos', [{ 'phone': value, 'is_primary_mobile_no': 1}]) frappe.db.set_value('Customer', customer, 'mobile_no', value) contact_doc.save() + +@frappe.whitelist() +def get_pos_profile_data(pos_profile): + pos_profile = frappe.get_doc('POS Profile', pos_profile) + pos_profile = pos_profile.as_dict() + + _customer_groups_with_children = [] + for row in pos_profile.customer_groups: + children = get_child_nodes('Customer Group', row.customer_group) + _customer_groups_with_children.extend(children) + + pos_profile.customer_groups = _customer_groups_with_children + return pos_profile \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index ea8459f970b..d66c6e46860 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -119,10 +119,15 @@ erpnext.PointOfSale.Controller = class { this.allow_negative_stock = flt(message.allow_negative_stock) || false; }); - frappe.db.get_doc("POS Profile", this.pos_profile).then((profile) => { - Object.assign(this.settings, profile); - this.settings.customer_groups = profile.customer_groups.map(group => group.customer_group); - this.make_app(); + frappe.call({ + method: "erpnext.selling.page.point_of_sale.point_of_sale.get_pos_profile_data", + args: { "pos_profile": this.pos_profile }, + callback: (res) => { + const profile = res.message; + Object.assign(this.settings, profile); + this.settings.customer_groups = profile.customer_groups.map(group => group.name); + this.make_app(); + } }); } From 4afb47e869b70c66a0fa51934a3c65de54f98b4e Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 21 Mar 2022 13:24:11 +0530 Subject: [PATCH 03/10] fix(pos): specific case when serialized item not removed --- .../selling/page/point_of_sale/pos_controller.js | 4 ++-- .../selling/page/point_of_sale/pos_item_details.js | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index d66c6e46860..49e85ecc7a0 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -560,7 +560,7 @@ erpnext.PointOfSale.Controller = class { if (this.item_details.$component.is(':visible')) this.edit_item_details_of(item_row); - if (this.check_serial_batch_selection_needed(item_row)) + if (this.check_serial_batch_selection_needed(item_row) && !this.item_details.$component.is(':visible')) this.edit_item_details_of(item_row); } @@ -709,7 +709,7 @@ erpnext.PointOfSale.Controller = class { frappe.dom.freeze(); const { doctype, name, current_item } = this.item_details; - frappe.model.set_value(doctype, name, 'qty', 0) + return frappe.model.set_value(doctype, name, 'qty', 0) .then(() => { frappe.model.clear_doc(doctype, name); this.update_cart_html(current_item, true); diff --git a/erpnext/selling/page/point_of_sale/pos_item_details.js b/erpnext/selling/page/point_of_sale/pos_item_details.js index fb69b63f82a..b75ffb235ed 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_details.js +++ b/erpnext/selling/page/point_of_sale/pos_item_details.js @@ -60,12 +60,18 @@ erpnext.PointOfSale.ItemDetails = class { return item && item.name == this.current_item.name; } - toggle_item_details_section(item) { + async toggle_item_details_section(item) { const current_item_changed = !this.compare_with_current_item(item); // if item is null or highlighted cart item is clicked twice const hide_item_details = !Boolean(item) || !current_item_changed; + if ((!hide_item_details && current_item_changed) || hide_item_details) { + // if item details is being closed OR if item details is opened but item is changed + // in both cases, if the current item is a serialized item, then validate and remove the item + await this.validate_serial_batch_item(); + } + this.events.toggle_item_selector(!hide_item_details); this.toggle_component(!hide_item_details); @@ -83,7 +89,6 @@ erpnext.PointOfSale.ItemDetails = class { this.render_form(item); this.events.highlight_cart_item(item); } else { - this.validate_serial_batch_item(); this.current_item = {}; } } @@ -103,11 +108,11 @@ erpnext.PointOfSale.ItemDetails = class { (serialized && batched && (no_batch_selected || no_serial_selected))) { frappe.show_alert({ - message: __("Item will be removed since no serial / batch no selected."), + message: __("Item is removed since no serial / batch no selected."), indicator: 'orange' }); frappe.utils.play_sound("cancel"); - this.events.remove_item_from_cart(); + return this.events.remove_item_from_cart(); } } From aff74087755cfb6c2e9a582886c26267b6fc5667 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 21 Mar 2022 13:53:58 +0530 Subject: [PATCH 04/10] fix(pos): allow validating stock on save --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 8 ++++++-- .../accounts/doctype/pos_profile/pos_profile.json | 13 +++++++++++-- .../selling/page/point_of_sale/pos_controller.js | 11 +++++++++-- erpnext/selling/page/point_of_sale/pos_payment.js | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 91c07ade7fb..9e6b7ce8ec5 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -172,10 +172,14 @@ class POSInvoice(SalesInvoice): frappe.throw(error_msg, title=_("Invalid Item"), as_list=True) def validate_stock_availablility(self): + if self.is_return: + return + + if self.docstatus.is_draft() and not frappe.db.get_value('POS Profile', self.pos_profile, 'validate_stock_on_save'): + return + from erpnext.stock.stock_ledger import is_negative_stock_allowed - if self.is_return or self.docstatus != 1: - return for d in self.get('items'): is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item')) if is_service_item: diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 9c9f37bba27..11646a6517d 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -22,6 +22,7 @@ "hide_images", "hide_unavailable_items", "auto_add_item_to_cart", + "validate_stock_on_save", "column_break_16", "update_stock", "ignore_pricing_rule", @@ -351,6 +352,12 @@ { "fieldname": "column_break_25", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "validate_stock_on_save", + "fieldtype": "Check", + "label": "Validate Stock on Save" } ], "icon": "icon-cog", @@ -378,10 +385,11 @@ "link_fieldname": "pos_profile" } ], - "modified": "2021-10-14 14:17:00.469298", + "modified": "2022-03-21 13:29:28.480533", "modified_by": "Administrator", "module": "Accounts", "name": "POS Profile", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -404,5 +412,6 @@ } ], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 49e85ecc7a0..6974bed4f1f 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -720,7 +720,14 @@ erpnext.PointOfSale.Controller = class { } async save_and_checkout() { - this.frm.is_dirty() && await this.frm.save(); - this.payment.checkout(); + if (this.frm.is_dirty()) { + // only move to payment section if save is successful + frappe.route_hooks.after_save = () => this.payment.checkout(); + return this.frm.save( + null, null, null, () => this.cart.toggle_checkout_btn(true) // show checkout button on error + ); + } else { + this.payment.checkout(); + } } }; diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 326ee59d11a..b4ece46e6e1 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -172,7 +172,7 @@ erpnext.PointOfSale.Payment = class { frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => { if (frm.doc.coupon_code && !frm.applying_pos_coupon_code) { if (!frm.doc.ignore_pricing_rule) { - frm.applying_pos_coupon_code = true + frm.applying_pos_coupon_code = true; frappe.run_serially([ () => frm.doc.ignore_pricing_rule=1, () => frm.trigger('ignore_pricing_rule'), From f2ae63cbfdc0262f45ccae5991927e49e5c38c4c Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Tue, 22 Mar 2022 17:41:49 +0530 Subject: [PATCH 05/10] fix(pos): remove returned sr. nos. from pos reserved sr. nos. list --- .../doctype/pos_invoice/pos_invoice.json | 4 +- .../doctype/pos_invoice/pos_invoice.py | 13 ++-- .../doctype/pos_invoice/test_pos_invoice.py | 72 +++++++++++++++++++ erpnext/stock/doctype/serial_no/serial_no.py | 62 +++++++++++++--- 4 files changed, 131 insertions(+), 20 deletions(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json index 0c6e7edeb02..b8500270d1a 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.json +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.json @@ -264,7 +264,6 @@ "print_hide": 1 }, { - "allow_on_submit": 1, "default": "0", "fieldname": "is_return", "fieldtype": "Check", @@ -1573,7 +1572,7 @@ "icon": "fa fa-file-text", "is_submittable": 1, "links": [], - "modified": "2021-10-05 12:11:53.871828", + "modified": "2022-03-22 13:00:24.166684", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice", @@ -1623,6 +1622,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "customer", "title_field": "title", "track_changes": 1, diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 9e6b7ce8ec5..7239deeda21 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -16,7 +16,11 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( ) from erpnext.accounts.party import get_due_date, get_party_account from erpnext.stock.doctype.batch.batch import get_batch_qty, get_pos_reserved_batch_qty -from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos, get_serial_nos +from erpnext.stock.doctype.serial_no.serial_no import ( + get_delivered_serial_nos, + get_pos_reserved_serial_nos, + get_serial_nos, +) class POSInvoice(SalesInvoice): @@ -145,12 +149,7 @@ class POSInvoice(SalesInvoice): .format(item.idx, bold_invalid_batch_no, bold_item_name, bold_extra_batch_qty_needed), title=_("Item Unavailable")) def validate_delivered_serial_nos(self, item): - serial_nos = get_serial_nos(item.serial_no) - delivered_serial_nos = frappe.db.get_list('Serial No', { - 'item_code': item.item_code, - 'name': ['in', serial_nos], - 'sales_invoice': ['is', 'set'] - }, pluck='name') + delivered_serial_nos = get_delivered_serial_nos(item.serial_no) if delivered_serial_nos: bold_delivered_serial_nos = frappe.bold(', '.join(delivered_serial_nos)) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index cf8affdd010..a76ae124a50 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -610,6 +610,78 @@ class TestPOSInvoice(unittest.TestCase): pos_inv.delete() pr.delete() + def test_delivered_serial_no_case(self): + from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import ( + init_user_and_profile, + ) + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.serial_no.test_serial_no import get_serial_nos + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item + + frappe.db.savepoint('before_test_delivered_serial_no_case') + try: + se = make_serialized_item() + serial_no = get_serial_nos(se.get("items")[0].serial_no)[0] + + dn = create_delivery_note( + item_code="_Test Serialized Item With Series", serial_no=serial_no + ) + + delivery_document_no = frappe.db.get_value("Serial No", serial_no, "delivery_document_no") + self.assertEquals(delivery_document_no, dn.name) + + init_user_and_profile() + + pos_inv = create_pos_invoice( + item_code="_Test Serialized Item With Series", + serial_no=serial_no, + qty=1, + rate=100, + do_not_submit=True + ) + + self.assertRaises(frappe.ValidationError, pos_inv.submit) + + finally: + frappe.db.rollback(save_point='before_test_delivered_serial_no_case') + frappe.set_user("Administrator") + + def test_returned_serial_no_case(self): + from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import ( + init_user_and_profile, + ) + from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos + from erpnext.stock.doctype.serial_no.test_serial_no import get_serial_nos + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item + + frappe.db.savepoint('before_test_returned_serial_no_case') + try: + se = make_serialized_item() + serial_no = get_serial_nos(se.get("items")[0].serial_no)[0] + + init_user_and_profile() + + pos_inv = create_pos_invoice( + item_code="_Test Serialized Item With Series", + serial_no=serial_no, + qty=1, + rate=100, + ) + + pos_return = make_sales_return(pos_inv.name) + pos_return.flags.ignore_validate = True + pos_return.insert() + pos_return.submit() + + pos_reserved_serial_nos = get_pos_reserved_serial_nos({ + 'item_code': '_Test Serialized Item With Series', + 'warehouse': '_Test Warehouse - _TC' + }) + self.assertTrue(serial_no not in pos_reserved_serial_nos) + + finally: + frappe.db.rollback(save_point='before_test_returned_serial_no_case') + frappe.set_user("Administrator") def create_pos_invoice(**args): args = frappe._dict(args) diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index bf62f50c971..df872afdb41 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -585,25 +585,65 @@ def auto_fetch_serial_number(qty, item_code, warehouse, posting_date=None, batch return [d.get('name') for d in serial_numbers] +def get_delivered_serial_nos(serial_nos): + ''' + Returns serial numbers that delivered from the list of serial numbers + ''' + from frappe.query_builder.functions import Coalesce + + SerialNo = frappe.qb.DocType("Serial No") + serial_nos = get_serial_nos(serial_nos) + query = ( + frappe.qb + .from_(SerialNo) + .select(SerialNo.name) + .where( + (SerialNo.name.isin(serial_nos)) + & (Coalesce(SerialNo.delivery_document_type, "") != "") + ) + ) + + result = query.run() + if result and len(result) > 0: + delivered_serial_nos = [row[0] for row in result] + return delivered_serial_nos + @frappe.whitelist() def get_pos_reserved_serial_nos(filters): if isinstance(filters, str): filters = json.loads(filters) - pos_transacted_sr_nos = frappe.db.sql("""select item.serial_no as serial_no - from `tabPOS Invoice` p, `tabPOS Invoice Item` item - where p.name = item.parent - and p.consolidated_invoice is NULL - and p.docstatus = 1 - and item.docstatus = 1 - and item.item_code = %(item_code)s - and item.warehouse = %(warehouse)s - and item.serial_no is NOT NULL and item.serial_no != '' - """, filters, as_dict=1) + POSInvoice = frappe.qb.DocType("POS Invoice") + POSInvoiceItem = frappe.qb.DocType("POS Invoice Item") + query = frappe.qb.from_( + POSInvoice + ).from_( + POSInvoiceItem + ).select( + POSInvoice.is_return, + POSInvoiceItem.serial_no + ).where( + (POSInvoice.name == POSInvoiceItem.parent) + & (POSInvoice.docstatus == 1) + & (POSInvoiceItem.docstatus == 1) + & (POSInvoiceItem.item_code == filters.get('item_code')) + & (POSInvoiceItem.warehouse == filters.get('warehouse')) + & (POSInvoiceItem.serial_no.isnotnull()) + & (POSInvoiceItem.serial_no != '') + ) + + pos_transacted_sr_nos = query.run(as_dict=True) reserved_sr_nos = [] + returned_sr_nos = [] for d in pos_transacted_sr_nos: - reserved_sr_nos += get_serial_nos(d.serial_no) + if d.is_return == 0: + reserved_sr_nos += get_serial_nos(d.serial_no) + elif d.is_return == 1: + returned_sr_nos += get_serial_nos(d.serial_no) + + for sr_no in returned_sr_nos: + reserved_sr_nos.remove(sr_no) return reserved_sr_nos From cf51a0a1b8ec45bf653c9478bd57cee676b384d9 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 24 Mar 2022 16:20:37 +0530 Subject: [PATCH 06/10] fix(pos): cannot close the pos if sr. no. is sold & returned --- .../pos_invoice_merge_log.py | 62 ++++++++++++++++--- .../test_pos_invoice_merge_log.py | 62 +++++++++++++++++++ .../pos_invoice_reference.json | 23 ++++++- 3 files changed, 135 insertions(+), 12 deletions(-) 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 d4513c6a686..d8f893c6448 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 @@ -53,7 +53,7 @@ class POSInvoiceMergeLog(Document): frappe.throw(msg) def on_submit(self): - pos_invoice_docs = [frappe.get_doc("POS Invoice", d.pos_invoice) for d in self.pos_invoices] + pos_invoice_docs = [frappe.get_cached_doc("POS Invoice", d.pos_invoice) for d in self.pos_invoices] returns = [d for d in pos_invoice_docs if d.get('is_return') == 1] sales = [d for d in pos_invoice_docs if d.get('is_return') == 0] @@ -70,7 +70,7 @@ class POSInvoiceMergeLog(Document): self.update_pos_invoices(pos_invoice_docs, sales_invoice, credit_note) def on_cancel(self): - pos_invoice_docs = [frappe.get_doc("POS Invoice", d.pos_invoice) for d in self.pos_invoices] + pos_invoice_docs = [frappe.get_cached_doc("POS Invoice", d.pos_invoice) for d in self.pos_invoices] self.update_pos_invoices(pos_invoice_docs) self.cancel_linked_invoices() @@ -254,7 +254,7 @@ def get_all_unconsolidated_invoices(): 'docstatus': 1 } pos_invoices = frappe.db.get_all('POS Invoice', filters=filters, - fields=["name as pos_invoice", 'posting_date', 'grand_total', 'customer']) + fields=["name as pos_invoice", 'posting_date', 'grand_total', 'customer', 'is_return', 'return_against']) return pos_invoices @@ -294,17 +294,59 @@ def unconsolidate_pos_invoices(closing_entry): else: cancel_merge_logs(merge_logs, closing_entry) +def split_invoices(invoices): + ''' + Splits invoices into multiple groups + Use-case: + If a serial no is sold and later it is returned + then split the invoices such that the selling entry is merged first and then the return entry + ''' + # Input + # invoices = [ + # {'pos_invoice': 'Invoice with SR#1 & SR#2', 'is_return': 0}, + # {'pos_invoice': 'Invoice with SR#1', 'is_return': 1}, + # {'pos_invoice': 'Invoice with SR#2', 'is_return': 0} + # ] + # Output + # _invoices = [ + # [{'pos_invoice': 'Invoice with SR#1 & SR#2', 'is_return': 0}], + # [{'pos_invoice': 'Invoice with SR#1', 'is_return': 1}, {'pos_invoice': 'Invoice with SR#2', 'is_return': 0}], + # ] + + _invoices = [] + special_invoices = [] + pos_return_docs = [frappe.get_cached_doc("POS Invoice", d.pos_invoice) for d in invoices if d.is_return and d.return_against] + for pos_invoice in pos_return_docs: + for item in pos_invoice.items: + if not item.serial_no: continue + + return_against_is_added = any(d for d in _invoices if d.pos_invoice == pos_invoice.return_against) + if return_against_is_added: break + + return_against_is_consolidated = frappe.db.get_value('POS Invoice', pos_invoice.return_against, 'status', cache=True) == 'Consolidated' + if return_against_is_consolidated: break + + pos_invoice_row = [d for d in invoices if d.pos_invoice == pos_invoice.return_against] + _invoices.append(pos_invoice_row) + special_invoices.append(pos_invoice.return_against) + break + + _invoices.append([d for d in invoices if d.pos_invoice not in special_invoices]) + + return _invoices + def create_merge_logs(invoice_by_customer, closing_entry=None): try: for customer, invoices in invoice_by_customer.items(): - merge_log = frappe.new_doc('POS Invoice Merge Log') - merge_log.posting_date = getdate(closing_entry.get('posting_date')) if closing_entry else nowdate() - merge_log.customer = customer - merge_log.pos_closing_entry = closing_entry.get('name') if closing_entry else None + for _invoices in split_invoices(invoices): + merge_log = frappe.new_doc('POS Invoice Merge Log') + merge_log.posting_date = getdate(closing_entry.get('posting_date')) if closing_entry else nowdate() + merge_log.customer = customer + merge_log.pos_closing_entry = closing_entry.get('name') if closing_entry else None - merge_log.set('pos_invoices', invoices) - merge_log.save(ignore_permissions=True) - merge_log.submit() + merge_log.set('pos_invoices', _invoices) + merge_log.save(ignore_permissions=True) + merge_log.submit() if closing_entry: closing_entry.set_status(update=True, status='Submitted') 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 8909da96fcf..655d726f75c 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 @@ -382,6 +382,68 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): consolidated_invoice = frappe.get_doc('Sales Invoice', inv.consolidated_invoice) self.assertEqual(consolidated_invoice.rounding_adjustment, 1) + finally: + frappe.set_user("Administrator") + frappe.db.sql("delete from `tabPOS Profile`") + frappe.db.sql("delete from `tabPOS Invoice`") + + def test_serial_no_case_1(self): + ''' + Create a POS Invoice with serial no + Create a Return Invoice with serial no + Create a POS Invoice with serial no again + Consolidate the invoices + + The first POS Invoice should be consolidated with a separate single Merge Log + The second and third POS Invoice should be consolidated with a single Merge Log + ''' + + from erpnext.stock.doctype.serial_no.test_serial_no import get_serial_nos + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item + + frappe.db.sql("delete from `tabPOS Invoice`") + + try: + se = make_serialized_item() + serial_no = get_serial_nos(se.get("items")[0].serial_no)[0] + + init_user_and_profile() + + pos_inv = create_pos_invoice( + item_code="_Test Serialized Item With Series", + serial_no=serial_no, + qty=1, + rate=100, + do_not_submit=1 + ) + pos_inv.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 100 + }) + pos_inv.submit() + + pos_inv_cn = make_sales_return(pos_inv.name) + pos_inv_cn.paid_amount = -100 + pos_inv_cn.submit() + + pos_inv2 = create_pos_invoice( + item_code="_Test Serialized Item With Series", + serial_no=serial_no, + qty=1, + rate=100, + do_not_submit=1 + ) + pos_inv2.append('payments', { + 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 100 + }) + pos_inv.submit() + + consolidate_pos_invoices() + + pos_inv.load_from_db() + pos_inv2.load_from_db() + + self.assertNotEqual(pos_inv.consolidated_invoice, pos_inv2.consolidated_invoice) + finally: frappe.set_user("Administrator") frappe.db.sql("delete from `tabPOS Profile`") diff --git a/erpnext/accounts/doctype/pos_invoice_reference/pos_invoice_reference.json b/erpnext/accounts/doctype/pos_invoice_reference/pos_invoice_reference.json index 205c4ede901..387c4b0f360 100644 --- a/erpnext/accounts/doctype/pos_invoice_reference/pos_invoice_reference.json +++ b/erpnext/accounts/doctype/pos_invoice_reference/pos_invoice_reference.json @@ -9,7 +9,9 @@ "posting_date", "column_break_3", "customer", - "grand_total" + "grand_total", + "is_return", + "return_against" ], "fields": [ { @@ -48,11 +50,27 @@ "in_list_view": 1, "label": "Amount", "reqd": 1 + }, + { + "default": "0", + "fetch_from": "pos_invoice.is_return", + "fieldname": "is_return", + "fieldtype": "Check", + "label": "Is Return", + "read_only": 1 + }, + { + "fetch_from": "pos_invoice.return_against", + "fieldname": "return_against", + "fieldtype": "Link", + "label": "Return Against", + "options": "POS Invoice", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2020-05-29 15:08:42.194979", + "modified": "2022-03-24 13:32:02.366257", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Reference", @@ -61,5 +79,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file From cb4873c019f7694f64b94b5845e2fa73a602103a Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 24 Mar 2022 17:56:27 +0530 Subject: [PATCH 07/10] fix: sider issues --- .../pos_invoice_merge_log/pos_invoice_merge_log.py | 9 ++++++--- erpnext/stock/doctype/serial_no/serial_no.py | 11 +++-------- 2 files changed, 9 insertions(+), 11 deletions(-) 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 d8f893c6448..b3d9c15f086 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 @@ -318,13 +318,16 @@ def split_invoices(invoices): pos_return_docs = [frappe.get_cached_doc("POS Invoice", d.pos_invoice) for d in invoices if d.is_return and d.return_against] for pos_invoice in pos_return_docs: for item in pos_invoice.items: - if not item.serial_no: continue + if not item.serial_no: + continue return_against_is_added = any(d for d in _invoices if d.pos_invoice == pos_invoice.return_against) - if return_against_is_added: break + if return_against_is_added: + break return_against_is_consolidated = frappe.db.get_value('POS Invoice', pos_invoice.return_against, 'status', cache=True) == 'Consolidated' - if return_against_is_consolidated: break + if return_against_is_consolidated: + break pos_invoice_row = [d for d in invoices if d.pos_invoice == pos_invoice.return_against] _invoices.append(pos_invoice_row) diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index 66c77072926..2808c219ea8 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -619,14 +619,9 @@ def get_delivered_serial_nos(serial_nos): SerialNo = frappe.qb.DocType("Serial No") serial_nos = get_serial_nos(serial_nos) - query = ( - frappe.qb - .from_(SerialNo) - .select(SerialNo.name) - .where( - (SerialNo.name.isin(serial_nos)) - & (Coalesce(SerialNo.delivery_document_type, "") != "") - ) + query = frappe.qb.select(SerialNo.name).from_(SerialNo).where( + (SerialNo.name.isin(serial_nos)) + & (Coalesce(SerialNo.delivery_document_type, "") != "") ) result = query.run() From 1b556d1c5353c9fca94f9d00496776e1c2a69839 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 24 Mar 2022 17:59:34 +0530 Subject: [PATCH 08/10] fix: test cases --- erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py | 1 + .../doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index a76ae124a50..e06f7aa9a7d 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -340,6 +340,7 @@ class TestPOSInvoice(unittest.TestCase): item=se.get("items")[0].item_code, rate=1000, do_not_save=1) si.get("items")[0].serial_no = serial_nos[0] + si.update_stock = 1 si.insert() si.submit() 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 655d726f75c..fe57ce2f0f0 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 @@ -435,7 +435,7 @@ class TestPOSInvoiceMergeLog(unittest.TestCase): pos_inv2.append('payments', { 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': 100 }) - pos_inv.submit() + pos_inv2.submit() consolidate_pos_invoices() From 16253a2f7207d8c4182e92a45247144fe24489db Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 25 Mar 2022 10:51:30 +0530 Subject: [PATCH 09/10] fix: set is_return & return_against in POS Invoice Reference table --- erpnext/patches.txt | 1 + ...eturn_against_in_pos_invoice_references.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 erpnext/patches/v13_0/set_return_against_in_pos_invoice_references.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 31999126214..a135484dc81 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -361,3 +361,4 @@ erpnext.patches.v14_0.delete_non_profit_doctypes erpnext.patches.v14_0.update_employee_advance_status erpnext.patches.v13_0.add_cost_center_in_loans erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items +erpnext.patches.v13_0.set_return_against_in_pos_invoice_references \ No newline at end of file diff --git a/erpnext/patches/v13_0/set_return_against_in_pos_invoice_references.py b/erpnext/patches/v13_0/set_return_against_in_pos_invoice_references.py new file mode 100644 index 00000000000..6c24f520274 --- /dev/null +++ b/erpnext/patches/v13_0/set_return_against_in_pos_invoice_references.py @@ -0,0 +1,38 @@ +import frappe + + +def execute(): + ''' + Fetch and Set is_return & return_against from POS Invoice in POS Invoice References table. + ''' + + POSClosingEntry = frappe.qb.DocType("POS Closing Entry") + open_pos_closing_entries = ( + frappe.qb + .from_(POSClosingEntry) + .select(POSClosingEntry.name) + .where(POSClosingEntry.docstatus == 0) + .run(pluck=True) + ) + + if not open_pos_closing_entries: + return + + POSInvoiceReference = frappe.qb.DocType("POS Invoice Reference") + POSInvoice = frappe.qb.DocType("POS Invoice") + pos_invoice_references = ( + frappe.qb + .from_(POSInvoiceReference) + .join(POSInvoice) + .on(POSInvoiceReference.pos_invoice == POSInvoice.name) + .select(POSInvoiceReference.name, POSInvoice.is_return, POSInvoice.return_against) + .where(POSInvoiceReference.parent.isin(open_pos_closing_entries)) + .run(as_dict=True) + ) + + for row in pos_invoice_references: + frappe.db.set_value("POS Invoice Reference", row.name, "is_return", row.is_return) + if row.is_return: + frappe.db.set_value("POS Invoice Reference", row.name, "return_against", row.return_against) + else: + frappe.db.set_value("POS Invoice Reference", row.name, "return_against", None) From e0c36d87e0198392c5a7369c31690f5922ea5d64 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 25 Mar 2022 14:26:12 +0530 Subject: [PATCH 10/10] chore: ignore rules for QB formatting --- .flake8 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.flake8 b/.flake8 index 5735456ae7d..4ff88403244 100644 --- a/.flake8 +++ b/.flake8 @@ -29,6 +29,8 @@ ignore = B950, W191, E124, # closing bracket, irritating while writing QB code + E131, # continuation line unaligned for hanging indent + E123, # closing bracket does not match indentation of opening bracket's line max-line-length = 200 exclude=.github/helper/semgrep_rules