feat: sales invoice integration with pos (#46485)

* feat: pos configuration to activate real time update of gl and stock ledger

* feat: sales invoice on pos order list

* fix: syntax

* feat: past order list with sales invoice

* feat: customer recent transaction with sales invoice

* fix: real_time_update validation

added a validation to restrict switching between sales invoice and pos invoice in case there's already a pos opening entry

* fix: use sales invoice on accounts settings

moved the check to use sales invoice instead of pos invoice from pos profile to accounts settings.

* fix: using accounts settings to get frm doctype

* fix: added support for sales invoice in  process return

* feat: event listeners for sales invoice on pos

* fix: create and load return invoice

* fix: edit order

* refactor: function rename

* fix: sales invoice generation using pos

added fields to distinguish sales invoice generated using pos

* feat: credit note in pos invoice during sales invoice mode

* feat: pos closing entry support for sales invoices

* refactor: resolving linter issues

* refactor: fix linter issue

* fix: filters for sales invoice in toggle recent orders

* feat: disable partial payments on sales invoice transactions made using pos

* fix: resolve import error

* fix: recent order list and pos invoice returns during sales invoice mode

* fix: reset pos_closing_entry on return sales invoice

* fix: filtering out consolidated return sales invoice for pos invoice return

* fix: pos delete order

* fix: added missing reference to consolidated sales invoice item

* fix: added check to restrict sales invoice creation

* refactor: variable name

* fix: integrating sales_invoice in make_closing_entry_from_opening method

* test: test for sales invoice integration in pos

* fix: issue with accounting dimension on sales invoice

* refactor: moved invoice switching mode validation in backend

* test: removed test case

Removed Test Case for Full Payment of Sales Invoice created using POS as planning to add feature to accept Partial Payment from POS.

* test: fixed the failing tests

* test: remove explicit use of frappe.db.commit()

* test: fixing pos invoice test

* test: removed test
This commit is contained in:
Diptanil Saha
2025-04-25 17:33:39 +05:30
committed by GitHub
parent 3193e1c1a2
commit 1ad61fb572
25 changed files with 880 additions and 155 deletions

View File

@@ -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
}
}

View File

@@ -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"),
)

View File

@@ -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");

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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"):

View File

@@ -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

View File

@@ -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

View File

@@ -417,6 +417,7 @@
"options": "Project"
}
],
"grid_page_length": 50,
"icon": "icon-cog",
"idx": 1,
"index_web_pages_for_search": 1,

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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()

View File

@@ -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(

View File

@@ -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": []
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = $("<div>");
const frm = _frm || new frappe.ui.form.Form(doctype, page, false);
const name = frappe.model.make_new_doc_and_get_name(doctype, true);
@@ -566,12 +580,18 @@ erpnext.PointOfSale.Controller = class {
return frm;
}
sync_draft_invoice_to_frm(doctype, invoice) {
return frappe.db.get_doc(doctype, invoice).then((doc) => {
frappe.model.sync(doc);
});
}
async make_return_invoice(doc) {
frappe.dom.freeze();
this.frm = this.get_new_frm(this.frm);
this.frm.doc.items = [];
return frappe.call({
method: "erpnext.accounts.doctype.pos_invoice.pos_invoice.make_sales_return",
method:
doc.doctype == "POS Invoice"
? "erpnext.accounts.doctype.pos_invoice.pos_invoice.make_sales_return"
: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return",
args: {
source_name: doc.name,
target_doc: this.frm.doc,
@@ -579,9 +599,7 @@ erpnext.PointOfSale.Controller = class {
callback: (r) => {
frappe.model.sync(r.message);
frappe.get_doc(r.message.doctype, r.message.name).__run_link_triggers = false;
this.set_pos_profile_data().then(() => {
frappe.dom.unfreeze();
});
this.set_pos_profile_data();
},
});
}

View File

@@ -209,6 +209,11 @@ erpnext.PointOfSale.ItemCart = class {
// called when discount is applied
this.update_totals_section(frm);
});
frappe.ui.form.on("Sales Invoice", "paid_amount", (frm) => {
// called when discount is applied
this.update_totals_section(frm);
});
}
attach_shortcuts() {
@@ -989,13 +994,13 @@ erpnext.PointOfSale.ItemCart = class {
}
fetch_customer_transactions() {
frappe.db
.get_list("POS Invoice", {
filters: { customer: this.customer_info.customer, docstatus: 1 },
fields: ["name", "grand_total", "status", "posting_date", "posting_time", "currency"],
limit: 20,
frappe
.call({
method: "erpnext.selling.page.point_of_sale.point_of_sale.get_customer_recent_transactions",
args: { customer: this.customer_info.customer },
})
.then((res) => {
res = res.message;
const transaction_container = this.$customer_section.find(".customer-transactions");
if (!res.length) {
@@ -1019,6 +1024,7 @@ erpnext.PointOfSale.ItemCart = class {
Draft: "red",
Return: "gray",
Consolidated: "blue",
"Credit Note Issued": "gray",
};
transaction_container.append(

View File

@@ -6,6 +6,7 @@ erpnext.PointOfSale.ItemDetails = class {
this.allow_rate_change = settings.allow_rate_change;
this.allow_discount_change = settings.allow_discount_change;
this.current_item = {};
this.frm_doctype = settings.frm_doctype;
this.init_component();
}
@@ -323,7 +324,9 @@ erpnext.PointOfSale.ItemDetails = class {
};
}
frappe.model.on("POS Invoice Item", "*", (fieldname, value, item_row) => {
const frm_doctype = this.events.get_frm().doc.doctype;
frappe.model.on(`${frm_doctype} Item`, "*", (fieldname, value, item_row) => {
const field_control = this[`${fieldname}_control`];
const item_row_is_being_edited = this.compare_with_current_item(item_row);
if (
@@ -423,7 +426,7 @@ erpnext.PointOfSale.ItemDetails = class {
warehouse: this.warehouse_control.get_value() || "",
batch_nos: this.current_item.batch_no || "",
posting_date: expiry_date,
for_doctype: "POS Invoice",
for_doctype: this.frm_doctype,
},
});

View File

@@ -38,9 +38,10 @@ erpnext.PointOfSale.PastOrderList = class {
});
const me = this;
this.$invoices_container.on("click", ".invoice-wrapper", function () {
const invoice_doctype = $(this).attr("data-invoice-doctype");
const invoice_name = unescape($(this).attr("data-invoice-name"));
me.events.open_invoice_data(invoice_name);
me.events.open_invoice_data(invoice_doctype, invoice_name);
});
}
@@ -99,7 +100,9 @@ erpnext.PointOfSale.PastOrderList = class {
const posting_datetime = frappe.datetime.str_to_user(
invoice.posting_date + " " + invoice.posting_time
);
return `<div class="invoice-wrapper" data-invoice-name="${escape(invoice.name)}">
return `<div class="invoice-wrapper" data-invoice-doctype="${
invoice.doctype
}" data-invoice-name="${escape(invoice.name)}">
<div class="invoice-name-date">
<div class="invoice-name">${invoice.name}</div>
<div class="invoice-date">

View File

@@ -117,9 +117,10 @@ erpnext.PointOfSale.PastOrderSummary = class {
async function get_returned_qty() {
const r = await frappe.call({
method: "erpnext.controllers.sales_and_purchase_return.get_pos_invoice_item_returned_qty",
method: "erpnext.controllers.sales_and_purchase_return.get_invoice_item_returned_qty",
args: {
pos_invoice: doc.name,
doctype: doc.doctype,
invoice: doc.name,
customer: doc.customer,
item_row_name: item_data.name,
},
@@ -192,7 +193,7 @@ erpnext.PointOfSale.PastOrderSummary = class {
bind_events() {
this.$summary_container.on("click", ".return-btn", async () => {
const r = await this.is_pos_invoice_returnable(this.doc.name);
const r = await this.is_invoice_returnable(this.doc.doctype, this.doc.name);
if (!r) {
frappe.msgprint({
title: __("Invalid Return"),
@@ -201,21 +202,21 @@ erpnext.PointOfSale.PastOrderSummary = class {
});
return;
}
this.events.process_return(this.doc.name);
this.events.process_return(this.doc.doctype, this.doc.name);
this.toggle_component(false);
this.$component.find(".no-summary-placeholder").css("display", "flex");
this.$summary_wrapper.css("display", "none");
});
this.$summary_container.on("click", ".edit-btn", () => {
this.events.edit_order(this.doc.name);
this.events.edit_order(this.doc.doctype, this.doc.name);
this.toggle_component(false);
this.$component.find(".no-summary-placeholder").css("display", "flex");
this.$summary_wrapper.css("display", "none");
});
this.$summary_container.on("click", ".delete-btn", () => {
this.events.delete_order(this.doc.name);
this.events.delete_order(this.doc.doctype, this.doc.name);
this.show_summary_placeholder();
});
@@ -461,11 +462,12 @@ erpnext.PointOfSale.PastOrderSummary = class {
show ? this.$component.css("display", "flex") : this.$component.css("display", "none");
}
async is_pos_invoice_returnable(invoice) {
async is_invoice_returnable(doctype, invoice) {
const r = await frappe.call({
method: "erpnext.controllers.sales_and_purchase_return.is_pos_invoice_returnable",
method: "erpnext.controllers.sales_and_purchase_return.is_invoice_returnable",
args: {
pos_invoice: invoice,
doctype: doctype,
invoice: invoice,
},
});
return r.message;

View File

@@ -164,36 +164,12 @@ erpnext.PointOfSale.Payment = class {
}
});
frappe.ui.form.on("POS Invoice", "contact_mobile", (frm) => {
const contact = frm.doc.contact_mobile;
const request_button = $(this.request_for_payment_field?.$input[0]);
if (contact) {
request_button.removeClass("btn-default").addClass("btn-primary");
} else {
request_button.removeClass("btn-primary").addClass("btn-default");
}
frappe.ui.form.on("POS Invoice", "coupon_code", (frm) => {
this.bind_coupon_code_event(frm);
});
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;
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",
});
}
}
frappe.ui.form.on("Sales Invoice", "coupon_code", (frm) => {
this.bind_coupon_code_event(frm);
});
this.setup_listener_for_payments();
@@ -225,19 +201,19 @@ erpnext.PointOfSale.Payment = class {
});
frappe.ui.form.on("POS Invoice", "paid_amount", (frm) => {
this.update_totals_section(frm.doc);
// need to re calculate cash shortcuts after discount is applied
const is_cash_shortcuts_invisible = !this.$payment_modes.find(".cash-shortcuts").is(":visible");
this.attach_cash_shortcuts(frm.doc);
!is_cash_shortcuts_invisible &&
this.$payment_modes.find(".cash-shortcuts").css("display", "grid");
this.render_payment_mode_dom();
this.bind_paid_amount_event(frm);
});
frappe.ui.form.on("POS Invoice", "loyalty_amount", (frm) => {
const formatted_currency = format_currency(frm.doc.loyalty_amount, frm.doc.currency);
this.$payment_modes.find(`.loyalty-amount-amount`).html(formatted_currency);
this.bind_loyalty_amount_event(frm);
});
frappe.ui.form.on("Sales Invoice", "paid_amount", (frm) => {
this.bind_paid_amount_event(frm);
});
frappe.ui.form.on("Sales Invoice", "loyalty_amount", (frm) => {
this.bind_loyalty_amount_event(frm);
});
frappe.ui.form.on("Sales Invoice Payment", "amount", (frm, cdt, cdn) => {
@@ -250,6 +226,43 @@ erpnext.PointOfSale.Payment = class {
});
}
bind_coupon_code_event(frm) {
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",
});
}
}
}
bind_paid_amount_event(frm) {
this.update_totals_section(frm.doc);
// need to re calculate cash shortcuts after discount is applied
const is_cash_shortcuts_invisible = !this.$payment_modes.find(".cash-shortcuts").is(":visible");
this.attach_cash_shortcuts(frm.doc);
!is_cash_shortcuts_invisible && this.$payment_modes.find(".cash-shortcuts").css("display", "grid");
this.render_payment_mode_dom();
}
bind_loyalty_amount_event(frm) {
const formatted_currency = format_currency(frm.doc.loyalty_amount, frm.doc.currency);
this.$payment_modes.find(`.loyalty-amount-amount`).html(formatted_currency);
}
setup_listener_for_payments() {
frappe.realtime.on("process_phone_payment", (data) => {
const doc = this.events.get_frm().doc;