diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 5bd31a91e44..285a14b40a6 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -58,6 +58,8 @@ "pos_tab", "pos_setting_section", "post_change_gl_entries", + "column_break_xrnd", + "use_sales_invoice_in_pos", "assets_tab", "asset_settings_section", "calculate_depr_using_total_days", @@ -532,14 +534,26 @@ "fieldtype": "Select", "label": "Posting Date Inheritance for Exchange Gain / Loss", "options": "Invoice\nPayment\nReconciliation Date" + }, + { + "fieldname": "column_break_xrnd", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "If enabled, Sales Invoice will be generated instead of POS Invoice in POS Transactions for real-time update of G/L and Stock Ledger.", + "fieldname": "use_sales_invoice_in_pos", + "fieldtype": "Check", + "label": "Use Sales Invoice" } ], + "grid_page_length": 50, "icon": "icon-cog", "idx": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-01-23 13:15:44.077853", + "modified": "2025-03-30 20:47:17.954736", "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", @@ -564,8 +578,9 @@ } ], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "ASC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 31249e22455..997ba49ed26 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -66,6 +66,7 @@ class AccountsSettings(Document): submit_journal_entries: DF.Check unlink_advance_payment_on_cancelation_of_order: DF.Check unlink_payment_on_cancellation_of_invoice: DF.Check + use_sales_invoice_in_pos: DF.Check # end: auto-generated types def validate(self): @@ -92,6 +93,9 @@ class AccountsSettings(Document): if old_doc.acc_frozen_upto != self.acc_frozen_upto: self.validate_pending_reposts() + if old_doc.use_sales_invoice_in_pos != self.use_sales_invoice_in_pos: + self.validate_invoice_mode_switch_in_pos() + if clear_cache: frappe.clear_cache() @@ -135,3 +139,15 @@ class AccountsSettings(Document): if self.has_value_changed("reconciliation_queue_size"): if cint(self.reconciliation_queue_size) < 5 or cint(self.reconciliation_queue_size) > 100: frappe.throw(_("Queue Size should be between 5 and 100")) + + def validate_invoice_mode_switch_in_pos(self): + pos_opening_entries_count = frappe.db.count( + "POS Opening Entry", filters={"docstatus": 1, "status": "Open"} + ) + if pos_opening_entries_count: + frappe.throw( + _("{0} can be enabled/disabled after all the POS Opening Entries are closed.").format( + frappe.bold(_("Use Sales Invoice")) + ), + title=_("Switch Invoice Mode Error"), + ) diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js index 7504c79141b..26c4591a854 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on("POS Closing Entry", { - onload: function (frm) { + onload: async function (frm) { frm.ignore_doctypes_on_cancel_all = ["POS Invoice Merge Log"]; frm.set_query("pos_profile", function (doc) { return { @@ -36,6 +36,15 @@ frappe.ui.form.on("POS Closing Entry", { } }); + const is_pos_using_sales_invoice = await frappe.db.get_single_value( + "Accounts Settings", + "use_sales_invoice_in_pos" + ); + + if (is_pos_using_sales_invoice) { + frm.set_df_property("pos_transactions", "hidden", 1); + } + set_html_data(frm); if (frm.doc.docstatus == 1) { @@ -83,6 +92,7 @@ frappe.ui.form.on("POS Closing Entry", { () => frappe.dom.freeze(__("Loading Invoices! Please Wait...")), () => frm.trigger("set_opening_amounts"), () => frm.trigger("get_pos_invoices"), + () => frm.trigger("get_sales_invoices"), () => frappe.dom.unfreeze(), ]); } @@ -113,7 +123,25 @@ frappe.ui.form.on("POS Closing Entry", { }, callback: (r) => { let pos_docs = r.message; - set_form_data(pos_docs, frm); + set_pos_transaction_form_data(pos_docs, frm); + refresh_fields(frm); + set_html_data(frm); + }, + }); + }, + + get_sales_invoices(frm) { + return frappe.call({ + method: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_sales_invoices", + args: { + start: frappe.datetime.get_datetime_as_string(frm.doc.period_start_date), + end: frappe.datetime.get_datetime_as_string(frm.doc.period_end_date), + pos_profile: frm.doc.pos_profile, + user: frm.doc.user, + }, + callback: (r) => { + let sales_docs = r.message; + set_sales_invoice_transaction_form_data(sales_docs, frm); refresh_fields(frm); set_html_data(frm); }, @@ -132,9 +160,40 @@ frappe.ui.form.on("POS Closing Entry", { row.expected_amount = row.opening_amount; } + const is_pos_using_sales_invoice = await frappe.db.get_single_value( + "Accounts Settings", + "use_sales_invoice_in_pos" + ); + + if (is_pos_using_sales_invoice) { + await Promise.all([ + frappe.call({ + method: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_pos_invoices", + args: { + start: frappe.datetime.get_datetime_as_string(frm.doc.period_start_date), + end: frappe.datetime.get_datetime_as_string(frm.doc.period_end_date), + pos_profile: frm.doc.pos_profile, + user: frm.doc.user, + }, + callback: (r) => { + let pos_invoices = r.message; + for (let doc of pos_invoices) { + frm.doc.grand_total += flt(doc.grand_total); + frm.doc.net_total += flt(doc.net_total); + frm.doc.total_quantity += flt(doc.total_qty); + refresh_payments(doc, frm, false); + refresh_taxes(doc, frm); + refresh_fields(frm); + set_html_data(frm); + } + }, + }), + ]); + } + await Promise.all([ frappe.call({ - method: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_pos_invoices", + method: "erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry.get_sales_invoices", args: { start: frappe.datetime.get_datetime_as_string(frm.doc.period_start_date), end: frappe.datetime.get_datetime_as_string(frm.doc.period_end_date), @@ -142,8 +201,8 @@ frappe.ui.form.on("POS Closing Entry", { user: frm.doc.user, }, callback: (r) => { - let pos_invoices = r.message; - for (let doc of pos_invoices) { + let sales_invoices = r.message; + for (let doc of sales_invoices) { frm.doc.grand_total += flt(doc.grand_total); frm.doc.net_total += flt(doc.net_total); frm.doc.total_quantity += flt(doc.total_qty); @@ -155,6 +214,7 @@ frappe.ui.form.on("POS Closing Entry", { }, }), ]); + frappe.dom.unfreeze(); }, }); @@ -166,7 +226,7 @@ frappe.ui.form.on("POS Closing Entry Detail", { }, }); -function set_form_data(data, frm) { +function set_pos_transaction_form_data(data, frm) { data.forEach((d) => { add_to_pos_transaction(d, frm); frm.doc.grand_total += flt(d.grand_total); @@ -177,6 +237,17 @@ function set_form_data(data, frm) { }); } +function set_sales_invoice_transaction_form_data(data, frm) { + data.forEach((d) => { + add_to_sales_invoice_transaction(d, frm); + frm.doc.grand_total += flt(d.grand_total); + frm.doc.net_total += flt(d.net_total); + frm.doc.total_quantity += flt(d.total_qty); + refresh_payments(d, frm, true); + refresh_taxes(d, frm); + }); +} + function add_to_pos_transaction(d, frm) { frm.add_child("pos_transactions", { pos_invoice: d.name, @@ -186,6 +257,15 @@ function add_to_pos_transaction(d, frm) { }); } +function add_to_sales_invoice_transaction(d, frm) { + frm.add_child("sales_invoice_transactions", { + sales_invoice: d.name, + posting_date: d.posting_date, + grand_total: d.grand_total, + customer: d.customer, + }); +} + function refresh_payments(d, frm, is_new) { d.payments.forEach((p) => { const payment = frm.doc.payment_reconciliation.find( @@ -226,6 +306,7 @@ function refresh_taxes(d, frm) { function reset_values(frm) { frm.set_value("pos_transactions", []); + frm.set_value("sales_invoice_transactions", []); frm.set_value("payment_reconciliation", []); frm.set_value("taxes", []); frm.set_value("grand_total", 0); @@ -235,6 +316,7 @@ function reset_values(frm) { function refresh_fields(frm) { frm.refresh_field("pos_transactions"); + frm.refresh_field("sales_invoice_transactions"); frm.refresh_field("payment_reconciliation"); frm.refresh_field("taxes"); frm.refresh_field("grand_total"); diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json index 641539260f7..da925e36b41 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.json @@ -21,6 +21,7 @@ "user", "section_break_12", "pos_transactions", + "sales_invoice_transactions", "section_break_9", "payment_reconciliation_details", "section_break_11", @@ -227,8 +228,15 @@ "label": "Posting Time", "no_copy": 1, "reqd": 1 + }, + { + "fieldname": "sales_invoice_transactions", + "fieldtype": "Table", + "label": "Sales Invoice Transactions", + "options": "Sales Invoice Reference" } ], + "grid_page_length": 50, "is_submittable": 1, "links": [ { @@ -236,7 +244,7 @@ "link_fieldname": "pos_closing_entry" } ], - "modified": "2024-03-27 13:10:14.073467", + "modified": "2025-03-19 19:49:58.845697", "modified_by": "Administrator", "module": "Accounts", "name": "POS Closing Entry", @@ -285,8 +293,9 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py index 4fa8317ff76..fe160f18673 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py @@ -28,8 +28,9 @@ class POSClosingEntry(StatusUpdater): from erpnext.accounts.doctype.pos_closing_entry_taxes.pos_closing_entry_taxes import ( POSClosingEntryTaxes, ) - from erpnext.accounts.doctype.pos_invoice_reference.pos_invoice_reference import ( - POSInvoiceReference, + from erpnext.accounts.doctype.pos_invoice_reference.pos_invoice_reference import POSInvoiceReference + from erpnext.accounts.doctype.sales_invoice_reference.sales_invoice_reference import ( + SalesInvoiceReference, ) amended_from: DF.Link | None @@ -45,6 +46,7 @@ class POSClosingEntry(StatusUpdater): pos_transactions: DF.Table[POSInvoiceReference] posting_date: DF.Date posting_time: DF.Time + sales_invoice_transactions: DF.Table[SalesInvoiceReference] status: DF.Literal["Draft", "Submitted", "Queued", "Failed", "Cancelled"] taxes: DF.Table[POSClosingEntryTaxes] total_quantity: DF.Float @@ -58,8 +60,20 @@ class POSClosingEntry(StatusUpdater): if frappe.db.get_value("POS Opening Entry", self.pos_opening_entry, "status") != "Open": frappe.throw(_("Selected POS Opening Entry should be open."), title=_("Invalid Opening Entry")) - self.validate_duplicate_pos_invoices() - self.validate_pos_invoices() + self.is_pos_using_sales_invoice = frappe.db.get_single_value( + "Accounts Settings", "use_sales_invoice_in_pos" + ) + + if self.is_pos_using_sales_invoice == 0: + self.validate_duplicate_pos_invoices() + self.validate_pos_invoices() + + if self.is_pos_using_sales_invoice == 1: + if len(self.pos_transactions) != 0: + frappe.throw(_("POS Invoices can't be added when Sales Invoice is enabled")) + + self.validate_duplicate_sales_invoices() + self.validate_sales_invoices() def validate_duplicate_pos_invoices(self): pos_occurences = {} @@ -114,6 +128,71 @@ class POSClosingEntry(StatusUpdater): frappe.throw(error_list, title=_("Invalid POS Invoices"), as_list=True) + def validate_duplicate_sales_invoices(self): + sales_invoice_occurrences = {} + for idx, inv in enumerate(self.sales_invoice_transactions, 1): + sales_invoice_occurrences.setdefault(inv.sales_invoice, []).append(idx) + + error_list = [] + for key, value in sales_invoice_occurrences.items(): + if len(value) > 1: + error_list.append( + _("{0} is added multiple times on rows: {1}").format(frappe.bold(key), frappe.bold(value)) + ) + + if error_list: + frappe.throw(error_list, title=_("Duplicate Sales Invoices found"), as_list=True) + + def validate_sales_invoices(self): + invalid_rows = [] + for d in self.sales_invoice_transactions: + invalid_row = {"idx": d.idx} + sales_invoice = frappe.db.get_values( + "Sales Invoice", + d.sales_invoice, + [ + "pos_profile", + "docstatus", + "is_pos", + "owner", + "is_created_using_pos", + "is_consolidated", + "pos_closing_entry", + ], + as_dict=1, + )[0] + if sales_invoice.pos_closing_entry: + invalid_row.setdefault("msg", []).append(_("Sales Invoice is already consolidated")) + invalid_rows.append(invalid_row) + continue + if sales_invoice.is_pos == 0: + invalid_row.setdefault("msg", []).append(_("Sales Invoice does not have Payments")) + if sales_invoice.is_created_using_pos == 0: + invalid_row.setdefault("msg", []).append(_("Sales Invoice is not created using POS")) + if sales_invoice.pos_profile != self.pos_profile: + invalid_row.setdefault("msg", []).append( + _("POS Profile doesn't match {}").format(frappe.bold(self.pos_profile)) + ) + if sales_invoice.docstatus != 1: + invalid_row.setdefault("msg", []).append(_("Sales Invoice is not submitted")) + if sales_invoice.owner != self.user: + invalid_row.setdefault("msg", []).append( + _("Sales Invoice isn't created by user {}").format(frappe.bold(self.owner)) + ) + + if invalid_row.get("msg"): + invalid_rows.append(invalid_row) + + if not invalid_rows: + return + + error_list = [] + for row in invalid_rows: + for msg in row.get("msg"): + error_list.append(_("Row #{}: {}").format(row.get("idx"), msg)) + + frappe.throw(error_list, title=_("Invalid Sales Invoices"), as_list=True) + @frappe.whitelist() def get_payment_reconciliation_details(self): currency = frappe.get_cached_value("Company", self.company, "default_currency") @@ -130,9 +209,13 @@ class POSClosingEntry(StatusUpdater): docname=f"POS Opening Entry/{self.pos_opening_entry}", ) + self.update_sales_invoices_closing_entry() + def on_cancel(self): unconsolidate_pos_invoices(closing_entry=self) + self.update_sales_invoices_closing_entry(cancel=True) + @frappe.whitelist() def retry(self): consolidate_pos_invoices(closing_entry=self) @@ -143,6 +226,12 @@ class POSClosingEntry(StatusUpdater): opening_entry.set_status() opening_entry.save() + def update_sales_invoices_closing_entry(self, cancel=False): + for d in self.sales_invoice_transactions: + frappe.db.set_value( + "Sales Invoice", d.sales_invoice, "pos_closing_entry", self.name if not cancel else None + ) + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs @@ -173,6 +262,33 @@ def get_pos_invoices(start, end, pos_profile, user): return data +@frappe.whitelist() +def get_sales_invoices(start, end, pos_profile, user): + data = frappe.db.sql( + """ + select + name, timestamp(posting_date, posting_time) as "timestamp" + from + `tabSales Invoice` + where + owner = %s + and docstatus = 1 + and is_pos = 1 + and pos_profile = %s + and is_created_using_pos = 1 + and ifnull(pos_closing_entry,'') = '' + """, + (user, pos_profile), + as_dict=1, + ) + + data = [d for d in data if get_datetime(start) <= get_datetime(d.timestamp) <= get_datetime(end)] + # need to get taxes and payments so can't avoid get_doc + data = [frappe.get_doc("Sales Invoice", d.name).as_dict() for d in data] + + return data + + def make_closing_entry_from_opening(opening_entry): closing_entry = frappe.new_doc("POS Closing Entry") closing_entry.pos_opening_entry = opening_entry.name @@ -185,7 +301,20 @@ def make_closing_entry_from_opening(opening_entry): closing_entry.net_total = 0 closing_entry.total_quantity = 0 - invoices = get_pos_invoices( + is_pos_using_sales_invoice = frappe.db.get_single_value("Accounts Settings", "use_sales_invoice_in_pos") + + pos_invoices = ( + get_pos_invoices( + closing_entry.period_start_date, + closing_entry.period_end_date, + closing_entry.pos_profile, + closing_entry.user, + ) + if is_pos_using_sales_invoice == 0 + else [] + ) + + sales_invoices = get_sales_invoices( closing_entry.period_start_date, closing_entry.period_end_date, closing_entry.pos_profile, @@ -193,6 +322,7 @@ def make_closing_entry_from_opening(opening_entry): ) pos_transactions = [] + sales_invoice_transactions = [] taxes = [] payments = [] for detail in opening_entry.balance_details: @@ -206,7 +336,7 @@ def make_closing_entry_from_opening(opening_entry): ) ) - for d in invoices: + for d in pos_invoices: pos_transactions.append( frappe._dict( { @@ -217,6 +347,20 @@ def make_closing_entry_from_opening(opening_entry): } ) ) + + for d in sales_invoices: + sales_invoice_transactions.append( + frappe._dict( + { + "sales_invoice": d.name, + "posting_date": d.posting_date, + "grand_total": d.grand_total, + "customer": d.customer, + } + ) + ) + + for d in [*pos_invoices, *sales_invoices]: closing_entry.grand_total += flt(d.grand_total) closing_entry.net_total += flt(d.net_total) closing_entry.total_quantity += flt(d.total_qty) @@ -246,6 +390,7 @@ def make_closing_entry_from_opening(opening_entry): ) closing_entry.set("pos_transactions", pos_transactions) + closing_entry.set("sales_invoice_transactions", sales_invoice_transactions) closing_entry.set("payment_reconciliation", payments) closing_entry.set("taxes", taxes) diff --git a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py index 55aede00350..d2bd406f03b 100644 --- a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py +++ b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py @@ -289,6 +289,46 @@ class TestPOSClosingEntry(IntegrationTestCase): batch_qty_with_pos = get_batch_qty(batch_no, "_Test Warehouse - _TC", item_code) self.assertEqual(batch_qty_with_pos, 10.0) + def test_closing_entries_with_sales_invoice(self): + from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + + test_user, pos_profile = init_user_and_profile() + # Deleting all opening entry + frappe.db.sql("delete from `tabPOS Opening Entry`") + + with self.change_settings("Accounts Settings", {"use_sales_invoice_in_pos": 1}): + opening_entry = create_opening_entry(pos_profile, test_user.name) + + pos_si = create_sales_invoice(qty=10, do_not_save=1) + pos_si.is_pos = 1 + pos_si.pos_profile = pos_profile.name + pos_si.is_created_using_pos = 1 + pos_si.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000}) + pos_si.save() + pos_si.submit() + + pos_si2 = create_sales_invoice(qty=5, do_not_save=1) + pos_si2.is_pos = 1 + pos_si2.pos_profile = pos_profile.name + pos_si2.is_created_using_pos = 1 + pos_si2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000}) + pos_si2.save() + pos_si2.submit() + + pcv_doc = make_closing_entry_from_opening(opening_entry) + payment = pcv_doc.payment_reconciliation[0] + + self.assertEqual(payment.mode_of_payment, "Cash") + + for d in pcv_doc.payment_reconciliation: + if d.mode_of_payment == "Cash": + d.closing_amount = 1500 + + pcv_doc.submit() + + self.assertEqual(pcv_doc.total_quantity, 15) + self.assertEqual(pcv_doc.net_total, 1500) + def init_user_and_profile(**args): user = "test@example.com" diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index f79a79f7e08..aa4eef46735 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -4,6 +4,7 @@ import frappe from frappe import _, bold +from frappe.model.mapper import map_child_doc, map_doc from frappe.query_builder.functions import IfNull, Sum from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate from frappe.utils.nestedset import get_descendants_of @@ -17,13 +18,10 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( ) from erpnext.accounts.party import get_due_date, get_party_account from erpnext.controllers.queries import item_query as _item_query +from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos -class PartialPaymentValidationError(frappe.ValidationError): - pass - - class POSInvoice(SalesInvoice): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -197,6 +195,7 @@ class POSInvoice(SalesInvoice): # run on validate method of selling controller super(SalesInvoice, self).validate() self.validate_pos_opening_entry() + self.validate_is_pos_using_sales_invoice() self.validate_auto_set_posting_time() self.validate_mode_of_payment() self.validate_uom_is_integer("stock_uom", "stock_qty") @@ -244,6 +243,9 @@ class POSInvoice(SalesInvoice): update_coupon_code_count(self.coupon_code, "used") self.clear_unallocated_mode_of_payments() + if self.is_return and self.is_pos_using_sales_invoice: + self.create_and_add_consolidated_sales_invoice() + def before_cancel(self): if ( self.consolidated_invoice @@ -287,6 +289,47 @@ class POSInvoice(SalesInvoice): sip = frappe.qb.DocType("Sales Invoice Payment") frappe.qb.from_(sip).delete().where(sip.parent == self.name).where(sip.amount == 0).run() + def create_and_add_consolidated_sales_invoice(self): + sales_inv = self.create_return_sales_invoice() + self.db_set("consolidated_invoice", sales_inv.name) + self.set_status(update=True) + + def create_return_sales_invoice(self): + return_sales_invoice = frappe.new_doc("Sales Invoice") + return_sales_invoice.is_pos = 1 + return_sales_invoice.is_return = 1 + map_doc(self, return_sales_invoice, table_map={"doctype": return_sales_invoice.doctype}) + return_sales_invoice.is_created_using_pos = 1 + return_sales_invoice.is_consolidated = 1 + return_sales_invoice.return_against = frappe.db.get_value( + "POS Invoice", self.return_against, "consolidated_invoice" + ) + items, taxes, payments = [], [], [] + for d in self.items: + si_item = map_child_doc(d, return_sales_invoice, {"doctype": "Sales Invoice Item"}) + si_item.pos_invoice = self.name + si_item.pos_invoice_item = d.name + si_item.sales_invoice_item = get_sales_invoice_item_from_consolidated_invoice( + self.return_against, d.pos_invoice_item + ) + items.append(si_item) + + for d in self.get("taxes"): + tax = map_child_doc(d, return_sales_invoice, {"doctype": "Sales Taxes and Charges"}) + taxes.append(tax) + + for d in self.get("payments"): + payment = map_child_doc(d, return_sales_invoice, {"doctype": "Sales Invoice Payment"}) + payments.append(payment) + + return_sales_invoice.set("items", items) + return_sales_invoice.set("taxes", taxes) + return_sales_invoice.set("payments", payments) + return_sales_invoice.save() + return_sales_invoice.submit() + + return return_sales_invoice + def delink_serial_and_batch_bundle(self): for row in self.items: if row.serial_and_batch_bundle: @@ -378,6 +421,13 @@ class POSInvoice(SalesInvoice): title=_("Item Unavailable"), ) + def validate_is_pos_using_sales_invoice(self): + self.is_pos_using_sales_invoice = frappe.db.get_single_value( + "Accounts Settings", "use_sales_invoice_in_pos" + ) + if self.is_pos_using_sales_invoice and not self.is_return: + frappe.throw(_("Sales Invoice mode is activated in POS. Please create Sales Invoice instead.")) + def validate_serialised_or_batched_item(self): error_msg = [] for d in self.get("items"): @@ -502,20 +552,6 @@ class POSInvoice(SalesInvoice): if self.redeem_loyalty_points and self.loyalty_program and self.loyalty_points: validate_loyalty_points(self, self.loyalty_points) - def validate_full_payment(self): - invoice_total = flt(self.rounded_total) or flt(self.grand_total) - - if self.docstatus == 1: - if self.is_return and self.paid_amount != invoice_total: - frappe.throw( - msg=_("Partial Payment in POS Invoice is not allowed."), exc=PartialPaymentValidationError - ) - - if self.paid_amount < invoice_total: - frappe.throw( - msg=_("Partial Payment in POS Invoice is not allowed."), exc=PartialPaymentValidationError - ) - def set_status(self, update=False, status=None, update_modified=True): if self.is_new(): if self.get("amended_from"): diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py index ad90032af9f..0cc157cab20 100644 --- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py @@ -7,8 +7,9 @@ import frappe from frappe import _ from frappe.tests import IntegrationTestCase -from erpnext.accounts.doctype.pos_invoice.pos_invoice import PartialPaymentValidationError, make_sales_return +from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile +from erpnext.accounts.doctype.sales_invoice.sales_invoice import PartialPaymentValidationError from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt 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 e069cfee7a0..765d5ad10ca 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 @@ -8,7 +8,6 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import map_child_doc, map_doc -from frappe.query_builder import DocType from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime from frappe.utils.background_jobs import enqueue, is_job_enqueued from frappe.utils.scheduler import is_scheduler_inactive @@ -16,6 +15,7 @@ from frappe.utils.scheduler import is_scheduler_inactive from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_checks_for_pl_and_bs_accounts, ) +from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice from erpnext.controllers.taxes_and_totals import ItemWiseTaxDetail @@ -238,7 +238,7 @@ class POSInvoiceMergeLog(Document): si_item.pos_invoice = doc.name si_item.pos_invoice_item = item.name if doc.is_return: - si_item.sales_invoice_item = get_sales_invoice_item( + si_item.sales_invoice_item = get_sales_invoice_item_from_consolidated_invoice( doc.return_against, item.pos_invoice_item ) if item.serial_and_batch_bundle: @@ -633,26 +633,3 @@ def get_error_message(message) -> str: return message["message"] except Exception: return str(message) - - -def get_sales_invoice_item(return_against_pos_invoice, pos_invoice_item): - try: - SalesInvoice = DocType("Sales Invoice") - SalesInvoiceItem = DocType("Sales Invoice Item") - - query = ( - frappe.qb.from_(SalesInvoice) - .from_(SalesInvoiceItem) - .select(SalesInvoiceItem.name) - .where( - (SalesInvoice.name == SalesInvoiceItem.parent) - & (SalesInvoice.is_return == 0) - & (SalesInvoiceItem.pos_invoice == return_against_pos_invoice) - & (SalesInvoiceItem.pos_invoice_item == pos_invoice_item) - ) - ) - - result = query.run(as_dict=True) - return result[0].name if result else None - except Exception: - return None diff --git a/erpnext/accounts/doctype/pos_profile/pos_profile.json b/erpnext/accounts/doctype/pos_profile/pos_profile.json index 3d42a75948d..f774391fe2b 100644 --- a/erpnext/accounts/doctype/pos_profile/pos_profile.json +++ b/erpnext/accounts/doctype/pos_profile/pos_profile.json @@ -417,6 +417,7 @@ "options": "Project" } ], + "grid_page_length": 50, "icon": "icon-cog", "idx": 1, "index_web_pages_for_search": 1, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js index 191378a6488..5d68ebeffaa 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js @@ -180,6 +180,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends ( } erpnext.accounts.unreconcile_payment.add_unreconcile_btn(me.frm); + + if (this.frm.doc.is_created_using_pos && !this.frm.doc.is_return) { + erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); + } } make_invoice_discounting() { diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index bd6265cd884..ef2f455f0c5 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -29,6 +29,8 @@ "update_billed_amount_in_delivery_note", "is_debit_note", "amended_from", + "is_created_using_pos", + "pos_closing_entry", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -2199,6 +2201,23 @@ "label": "Company Contact Person", "options": "Contact", "print_hide": 1 + }, + { + "default": "0", + "fieldname": "is_created_using_pos", + "fieldtype": "Check", + "hidden": 1, + "label": "Is created using POS", + "print_hide": 1 + }, + { + "depends_on": "is_created_using_pos", + "fieldname": "pos_closing_entry", + "fieldtype": "Link", + "hidden": 1, + "label": "POS Closing Entry", + "options": "POS Closing Entry", + "print_hide": 1 } ], "grid_page_length": 50, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 16834e504a7..0dde3c9037c 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -51,6 +51,10 @@ from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amou form_grid_templates = {"items": "templates/form_grid/item_grid.html"} +class PartialPaymentValidationError(frappe.ValidationError): + pass + + class SalesInvoice(SellingController): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -133,6 +137,7 @@ class SalesInvoice(SellingController): inter_company_invoice_reference: DF.Link | None is_cash_or_non_trade_discount: DF.Check is_consolidated: DF.Check + is_created_using_pos: DF.Check is_debit_note: DF.Check is_discounted: DF.Check is_internal_customer: DF.Check @@ -162,6 +167,7 @@ class SalesInvoice(SellingController): plc_conversion_rate: DF.Float po_date: DF.Date | None po_no: DF.Data | None + pos_closing_entry: DF.Link | None pos_profile: DF.Link | None posting_date: DF.Date posting_time: DF.Time | None @@ -306,6 +312,10 @@ class SalesInvoice(SellingController): if cint(self.is_pos): self.validate_pos() + if cint(self.is_created_using_pos): + self.validate_created_using_pos() + self.validate_full_payment() + self.validate_dropship_item() if cint(self.update_stock): @@ -528,7 +538,22 @@ class SalesInvoice(SellingController): ) frappe.throw(msg, title=_("Not Allowed")) + def check_if_created_using_pos_and_pos_closing_entry_generated(self): + if self.doctype == "Sales Invoice" and self.is_created_using_pos and self.pos_closing_entry: + pos_closing_entry_docstatus = frappe.db.get_value( + "POS Closing Entry", self.pos_closing_entry, "docstatus" + ) + if pos_closing_entry_docstatus == 1: + frappe.throw( + msg=_("To cancel this Sales Invoice you need to cancel the POS Closing Entry {}.").format( + get_link_to_form("POS Closing Entry", self.pos_closing_entry) + ), + title=_("Not Allowed"), + ) + def before_cancel(self): + # check if generated via POS and already included in POS Closing Entry + self.check_if_created_using_pos_and_pos_closing_entry_generated() self.check_if_consolidated_invoice() super().before_cancel() @@ -598,6 +623,15 @@ class SalesInvoice(SellingController): self.delete_auto_created_batches() + if ( + self.doctype == "Sales Invoice" + and self.is_pos + and self.is_return + and self.is_created_using_pos + and not self.pos_closing_entry + ): + self.cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode() + def update_status_updater_args(self): if not cint(self.update_stock): return @@ -669,6 +703,15 @@ class SalesInvoice(SellingController): timesheet.flags.ignore_validate_update_after_submit = True timesheet.db_update_all() + def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self): + pos_invoices = frappe.get_all( + "POS Invoice", filters={"consolidated_invoice": self.name}, pluck="name" + ) + if pos_invoices: + for pos_invoice in pos_invoices: + pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice) + pos_invoice_doc.cancel() + @frappe.whitelist() def set_missing_values(self, for_validate=False): pos = self.set_pos_fields(for_validate) @@ -704,6 +747,13 @@ class SalesInvoice(SellingController): "allow_print_before_pay": pos.get("allow_print_before_pay"), } + @frappe.whitelist() + def reset_mode_of_payments(self): + if self.pos_profile: + pos_profile = frappe.get_cached_doc("POS Profile", self.pos_profile) + update_multi_mode_option(self, pos_profile) + self.paid_amount = 0 + def update_time_sheet(self, sales_invoice): for d in self.timesheets: if d.time_sheet: @@ -1025,6 +1075,32 @@ class SalesInvoice(SellingController): ) > 1.0 / (10.0 ** (self.precision("grand_total") + 1.0)): frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total")) + def validate_created_using_pos(self): + if self.is_created_using_pos and not self.pos_profile: + frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction.")) + + self.is_pos_using_sales_invoice = frappe.db.get_single_value( + "Accounts Settings", "use_sales_invoice_in_pos" + ) + if not self.is_pos_using_sales_invoice and not self.is_return: + frappe.throw(_("Transactions using Sales Invoice in POS are disabled.")) + + def validate_full_payment(self): + invoice_total = flt(self.rounded_total) or flt(self.grand_total) + + if self.docstatus == 1: + if self.is_return and self.paid_amount != invoice_total: + frappe.throw( + msg=_("Partial Payment in POS Transactions are not allowed."), + exc=PartialPaymentValidationError, + ) + + if self.paid_amount < invoice_total: + frappe.throw( + msg=_("Partial Payment in POS Transactions are not allowed."), + exc=PartialPaymentValidationError, + ) + def validate_warehouse(self): super().validate_warehouse() diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 5ae12883031..bd039b00ff1 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -4386,6 +4386,27 @@ class TestSalesInvoice(IntegrationTestCase): self.assertRaises(StockOverReturnError, return_doc.save) + def test_pos_sales_invoice_creation_during_pos_invoice_mode(self): + # Deleting all opening entry + frappe.db.sql("delete from `tabPOS Opening Entry`") + + with self.change_settings("Accounts Settings", {"use_sales_invoice_in_pos": 0}): + pos_profile = make_pos_profile() + + pos_profile.payments = [] + pos_profile.append("payments", {"default": 1, "mode_of_payment": "Cash"}) + + pos_profile.save() + + pos = create_sales_invoice(qty=10, do_not_save=True) + + pos.is_pos = 1 + pos.pos_profile = pos_profile.name + pos.is_created_using_pos = 1 + + pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000}) + self.assertRaises(frappe.ValidationError, pos.insert) + def set_advance_flag(company, flag, default_account): frappe.db.set_value( diff --git a/erpnext/accounts/doctype/sales_invoice_reference/__init__.py b/erpnext/accounts/doctype/sales_invoice_reference/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/accounts/doctype/sales_invoice_reference/sales_invoice_reference.json b/erpnext/accounts/doctype/sales_invoice_reference/sales_invoice_reference.json new file mode 100644 index 00000000000..decd75adedf --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice_reference/sales_invoice_reference.json @@ -0,0 +1,85 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-03-19 15:01:28.834774", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "sales_invoice", + "posting_date", + "column_break_fear", + "customer", + "grand_total", + "is_return", + "return_against" + ], + "fields": [ + { + "fieldname": "sales_invoice", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Sales Invoice", + "options": "Sales Invoice", + "reqd": 1 + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "column_break_fear", + "fieldtype": "Column Break" + }, + { + "fetch_from": "sales_invoice.customer", + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer", + "read_only": 1, + "reqd": 1 + }, + { + "default": "0", + "fetch_from": "sales_invoice.is_return", + "fieldname": "is_return", + "fieldtype": "Check", + "label": "Is Return", + "read_only": 1 + }, + { + "fetch_from": "sales_invoice.return_against", + "fieldname": "return_against", + "fieldtype": "Link", + "label": "Return Against", + "options": "Sales Invoice", + "read_only": 1 + }, + { + "fetch_from": "sales_invoice.grand_total", + "fieldname": "grand_total", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Amount", + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-03-20 01:14:57.890299", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Sales Invoice Reference", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/erpnext/accounts/doctype/sales_invoice_reference/sales_invoice_reference.py b/erpnext/accounts/doctype/sales_invoice_reference/sales_invoice_reference.py new file mode 100644 index 00000000000..1f558631df1 --- /dev/null +++ b/erpnext/accounts/doctype/sales_invoice_reference/sales_invoice_reference.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class SalesInvoiceReference(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + customer: DF.Link + grand_total: DF.Currency + is_return: DF.Check + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + posting_date: DF.Date + return_against: DF.Link | None + sales_invoice: DF.Link + # end: auto-generated types + + pass diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 0f77ab763d6..63ab5db5b2b 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -6,6 +6,7 @@ from collections import defaultdict import frappe from frappe import _ from frappe.model.meta import get_field_precision +from frappe.query_builder import DocType from frappe.utils import cint, flt, format_datetime, get_datetime import erpnext @@ -387,6 +388,8 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai if doc.get("is_return"): if doc.doctype == "Sales Invoice" or doc.doctype == "POS Invoice": doc.consolidated_invoice = "" + if doc.doctype == "Sales Invoice": + doc.pos_closing_entry = "" # no copy enabled for party_account_currency doc.party_account_currency = source.party_account_currency doc.set("payments", []) @@ -1179,26 +1182,49 @@ def get_payment_data(invoice): @frappe.whitelist() -def get_pos_invoice_item_returned_qty(pos_invoice, customer, item_row_name): - is_return, docstatus = frappe.db.get_value("POS Invoice", pos_invoice, ["is_return", "docstatus"]) +def get_invoice_item_returned_qty(doctype, invoice, customer, item_row_name): + is_return, docstatus = frappe.db.get_value(doctype, invoice, ["is_return", "docstatus"]) if not is_return and docstatus == 1: - return get_returned_qty_map_for_row(pos_invoice, customer, item_row_name, "POS Invoice") + return get_returned_qty_map_for_row(invoice, customer, item_row_name, doctype) @frappe.whitelist() -def is_pos_invoice_returnable(pos_invoice): +def is_invoice_returnable(doctype, invoice): is_return, docstatus, customer = frappe.db.get_value( - "POS Invoice", pos_invoice, ["is_return", "docstatus", "customer"] + doctype, invoice, ["is_return", "docstatus", "customer"] ) if is_return or docstatus == 0: return False - invoice_item_qty = frappe.db.get_all("POS Invoice Item", {"parent": pos_invoice}, ["name", "qty"]) + invoice_item_qty = frappe.db.get_all(f"{doctype} Item", {"parent": invoice}, ["name", "qty"]) already_full_returned = 0 for d in invoice_item_qty: - returned_qty = get_returned_qty_map_for_row(pos_invoice, customer, d.name, "POS Invoice") + returned_qty = get_returned_qty_map_for_row(invoice, customer, d.name, doctype) if returned_qty.qty == d.qty: already_full_returned += 1 return len(invoice_item_qty) != already_full_returned + + +def get_sales_invoice_item_from_consolidated_invoice(return_against_pos_invoice, pos_invoice_item): + try: + SalesInvoice = DocType("Sales Invoice") + SalesInvoiceItem = DocType("Sales Invoice Item") + + query = ( + frappe.qb.from_(SalesInvoice) + .from_(SalesInvoiceItem) + .select(SalesInvoiceItem.name) + .where( + (SalesInvoice.name == SalesInvoiceItem.parent) + & (SalesInvoice.is_return == 0) + & (SalesInvoiceItem.pos_invoice == return_against_pos_invoice) + & (SalesInvoiceItem.pos_invoice_item == pos_invoice_item) + ) + ) + + result = query.run(as_dict=True) + return result[0].name if result else None + except Exception: + return None 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 9be5a656a8e..cfa26639fed 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -5,7 +5,7 @@ import json import frappe -from frappe.utils import cint +from frappe.utils import cint, get_datetime from frappe.utils.nestedset import get_root_of from erpnext.accounts.doctype.pos_invoice.pos_invoice import get_stock_availability @@ -328,25 +328,59 @@ def get_past_order_list(search_term, status, limit=20): invoice_list = [] if search_term and status: - invoices_by_customer = frappe.db.get_list( + pos_invoices_by_customer = frappe.db.get_list( "POS Invoice", - filters={"customer": ["like", f"%{search_term}%"], "status": status}, + filters=get_invoice_filters("POS Invoice", status, customer=search_term), fields=fields, page_length=limit, ) - invoices_by_name = frappe.db.get_list( + pos_invoices_by_name = frappe.db.get_list( "POS Invoice", - filters={"name": ["like", f"%{search_term}%"], "status": status}, + filters=get_invoice_filters("POS Invoice", status, name=search_term), fields=fields, page_length=limit, ) - invoice_list = invoices_by_customer + invoices_by_name - elif status: - invoice_list = frappe.db.get_list( - "POS Invoice", filters={"status": status}, fields=fields, page_length=limit + pos_invoice_list = add_doctype_to_results( + "POS Invoice", pos_invoices_by_customer + pos_invoices_by_name ) + sales_invoices_by_customer = frappe.db.get_list( + "Sales Invoice", + filters=get_invoice_filters("Sales Invoice", status, customer=search_term), + fields=fields, + page_length=limit, + ) + sales_invoices_by_name = frappe.db.get_list( + "Sales Invoice", + filters=get_invoice_filters("Sales Invoice", status, name=search_term), + fields=fields, + page_length=limit, + ) + + sales_invoice_list = add_doctype_to_results( + "Sales Invoice", sales_invoices_by_customer + sales_invoices_by_name + ) + + elif status: + pos_invoice_list = frappe.db.get_list( + "POS Invoice", + filters=get_invoice_filters("POS Invoice", status), + fields=fields, + page_length=limit, + ) + pos_invoice_list = add_doctype_to_results("POS Invoice", pos_invoice_list) + + sales_invoice_list = frappe.db.get_list( + "Sales Invoice", + filters=get_invoice_filters("Sales Invoice", status), + fields=fields, + page_length=limit, + ) + sales_invoice_list = add_doctype_to_results("Sales Invoice", sales_invoice_list) + + invoice_list = order_results_by_posting_date([*pos_invoice_list, *sales_invoice_list]) + return invoice_list @@ -402,3 +436,68 @@ def get_pos_profile_data(pos_profile): pos_profile.customer_groups = _customer_groups_with_children return pos_profile + + +def add_doctype_to_results(doctype, results): + for result in results: + result["doctype"] = doctype + + return results + + +def order_results_by_posting_date(results): + return sorted( + results, + key=lambda x: get_datetime(f"{x.get('posting_date')} {x.get('posting_time')}"), + reverse=True, + ) + + +def get_invoice_filters(doctype, status, name=None, customer=None): + filters = {} + + if name: + filters["name"] = ["like", f"%{name}%"] + if customer: + filters["customer"] = ["like", f"%{customer}%"] + + if doctype == "POS Invoice": + filters["status"] = status + return filters + + if doctype == "Sales Invoice": + filters["is_created_using_pos"] = 1 + filters["is_consolidated"] = 0 + + if status == "Draft": + filters["docstatus"] = 0 + else: + filters["docstatus"] = 1 + if status == "Paid": + filters["is_return"] = 0 + if status == "Return": + filters["is_return"] = 1 + + filters["pos_closing_entry"] = ["is", "set"] if status == "Consolidated" else ["is", "not set"] + + return filters + + +@frappe.whitelist() +def get_customer_recent_transactions(customer): + sales_invoices = frappe.db.get_list( + "Sales Invoice", + filters={"customer": customer, "docstatus": 1, "is_pos": 1, "is_consolidated": 0}, + fields=["name", "grand_total", "status", "posting_date", "posting_time", "currency"], + page_length=20, + ) + + pos_invoices = frappe.db.get_list( + "POS Invoice", + filters={"customer": customer, "docstatus": 1}, + fields=["name", "grand_total", "status", "posting_date", "posting_time", "currency"], + page_length=20, + ) + + invoices = order_results_by_posting_date(sales_invoices + pos_invoices) + return invoices diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 39f6af1e81e..c67ca67dac2 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -139,6 +139,11 @@ erpnext.PointOfSale.Controller = class { this.allow_negative_stock = flt(message.allow_negative_stock) || false; }); + const use_sales_invoice_in_pos = await frappe.db.get_single_value( + "Accounts Settings", + "use_sales_invoice_in_pos" + ); + frappe.call({ method: "erpnext.selling.page.point_of_sale.point_of_sale.get_pos_profile_data", args: { pos_profile: this.pos_profile }, @@ -146,6 +151,7 @@ erpnext.PointOfSale.Controller = class { const profile = res.message; Object.assign(this.settings, profile); this.settings.customer_groups = profile.customer_groups.map((group) => group.name); + this.settings.frm_doctype = use_sales_invoice_in_pos ? "Sales Invoice" : "POS Invoice"; this.make_app(); }, }); @@ -455,8 +461,9 @@ erpnext.PointOfSale.Controller = class { this.recent_order_list = new erpnext.PointOfSale.PastOrderList({ wrapper: this.$components_wrapper, events: { - open_invoice_data: (name) => { - frappe.db.get_doc("POS Invoice", name).then((doc) => { + open_invoice_data: (doctype, name) => { + if (!["POS Invoice", "Sales Invoice"].includes(doctype)) return; + frappe.db.get_doc(doctype, name).then((doc) => { this.order_summary.load_summary_of(doc); }); }, @@ -472,21 +479,26 @@ erpnext.PointOfSale.Controller = class { events: { get_frm: () => this.frm, - process_return: (name) => { + process_return: (doctype, name) => { this.recent_order_list.toggle_component(false); - frappe.db.get_doc("POS Invoice", name).then((doc) => { + frappe.db.get_doc(doctype, name).then((doc) => { frappe.run_serially([ + () => frappe.dom.freeze(), + () => this.make_invoice_frm(doc.doctype), () => this.make_return_invoice(doc), () => this.cart.load_invoice(), () => this.item_selector.toggle_component(true), () => this.item_selector.resize_selector(false), () => this.item_details.toggle_component(false), + () => frappe.dom.unfreeze(), ]); }); }, - edit_order: (name) => { + edit_order: (doctype, name) => { this.recent_order_list.toggle_component(false); frappe.run_serially([ + () => this.make_invoice_frm(doctype), + () => this.sync_draft_invoice_to_frm(doctype, name), () => this.frm.refresh(name), () => this.frm.call("reset_mode_of_payments"), () => this.cart.load_invoice(), @@ -495,9 +507,11 @@ erpnext.PointOfSale.Controller = class { () => this.item_details.toggle_component(false), ]); }, - delete_order: (name) => { - frappe.model.delete_doc(this.frm.doc.doctype, name, () => { - this.recent_order_list.refresh_list(); + delete_order: (doctype, name) => { + frappe.model.with_doctype(doctype, () => { + frappe.model.delete_doc(doctype, name, () => { + this.recent_order_list.refresh_list(); + }); }); }, new_order: () => { @@ -529,7 +543,7 @@ erpnext.PointOfSale.Controller = class { make_new_invoice() { return frappe.run_serially([ () => frappe.dom.freeze(), - () => this.make_sales_invoice_frm(), + () => this.make_invoice_frm(this.settings.frm_doctype), () => this.set_pos_profile_data(), () => this.set_pos_profile_status(), () => this.cart.load_invoice(), @@ -537,27 +551,27 @@ erpnext.PointOfSale.Controller = class { ]); } - make_sales_invoice_frm() { - const doctype = "POS Invoice"; + make_invoice_frm(doctype) { return new Promise((resolve) => { - if (this.frm) { - this.frm = this.get_new_frm(this.frm); + if (this.frm && this.frm.doctype == doctype) { + this.frm = this.get_new_frm(this.frm, doctype); this.frm.doc.items = []; this.frm.doc.is_pos = 1; + if (doctype == "Sales Invoice") this.frm.doc.is_created_using_pos = 1; resolve(); } else { frappe.model.with_doctype(doctype, () => { - this.frm = this.get_new_frm(); + this.frm = this.get_new_frm(undefined, doctype); this.frm.doc.items = []; this.frm.doc.is_pos = 1; + if (doctype == "Sales Invoice") this.frm.doc.is_created_using_pos = 1; resolve(); }); } }); } - get_new_frm(_frm) { - const doctype = "POS Invoice"; + get_new_frm(_frm, doctype = this.settings.frm_doctype) { const page = $("