\n\n \n\n
\n End of Day Report\n
\n\n
\n
\n {{ company_doc.company_name }}\n
\n\n {% if profile_doc.company_address %}\n
\n {{ profile_doc.company_address }}\n
\n {% endif %}\n
\n\n \n\n
\n Session Information\n
\n\n
\n POS Profile\n {{ doc.pos_profile }}\n
\n\n
\n Cashier\n \n {{ user_doc.full_name or doc.user }}\n \n
\n\n
\n Opened\n \n {{ frappe.utils.format_datetime(doc.period_start_date) }}\n \n
\n\n
\n Closed\n \n {{ frappe.utils.format_datetime(doc.period_end_date) }}\n \n
\n\n
\n Closing ID\n {{ doc.name }}\n
\n\n \n\n
\n Sales Totals\n
\n\n
\n Invoices\n \n {{ doc.pos_transactions|length }}\n \n
\n\n
\n Items Sold\n \n {{ \"{:.2f}\".format(doc.total_quantity or 0) }}\n \n
\n\n
\n Net Total\n \n {{ \"{:,.2f}\".format(doc.net_total or 0) }}\n \n
\n\n {% set tax_total = doc.taxes|sum(attribute='amount') if doc.taxes else 0 %}\n\n
\n Tax Total\n \n {{ \"{:,.2f}\".format(tax_total) }}\n \n
\n\n
\n Grand Total\n \n {{ \"{:,.2f}\".format(doc.grand_total or 0) }}\n \n
\n\n \n\n {% if items %}\n\n
\n Items Sold\n
\n\n {% for it in items %}\n\n
\n\n
\n {{ it.item_name or it.item_code }}\n
\n\n
\n\n
\n\n {% endfor %}\n\n {% endif %}\n\n \n\n {% if doc.taxes %}\n\n
\n Taxes\n
\n\n {% for t in doc.taxes %}\n\n
\n\n \n {{ t.account_head }}\n {% if t.rate %}\n ({{ \"{:g}\".format(t.rate | float) }}%)\n {% endif %}\n \n\n \n {{ \"{:,.2f}\".format(t.amount or 0) }}\n \n\n
\n\n {% endfor %}\n\n {% endif %}\n\n \n\n
\n Payments\n
\n\n {% for p in doc.payment_reconciliation %}\n\n {% set diff = (p.closing_amount or 0) - (p.expected_amount or 0) %}\n\n
\n\n
\n {{ p.mode_of_payment }}\n
\n\n
\n Opening\n \n {{ \"{:,.2f}\".format(p.opening_amount or 0) }}\n \n
\n\n
\n Expected\n \n {{ \"{:,.2f}\".format(p.expected_amount or 0) }}\n \n
\n\n
\n Counted\n \n {{ \"{:,.2f}\".format(p.closing_amount or 0) }}\n \n
\n\n
\n\n \n Difference\n \n\n \n\n {{ \"{:,.2f}\".format(diff) }}\n\n {% if diff < 0 %}\n \n SHORT\n \n {% elif diff > 0 %}\n \n OVER\n \n {% endif %}\n\n \n\n
\n\n
\n\n {% endfor %}\n\n \n\n
\n Final Summary\n
\n\n
\n Total Expected\n \n {{ \"{:,.2f}\".format(total_expected.v) }}\n \n
\n\n
\n Total Counted\n \n {{ \"{:,.2f}\".format(total_counted.v) }}\n \n
\n\n
\n\n \n Short / Over\n \n\n \n\n {{ \"{:,.2f}\".format(short_over) }}\n\n {% if short_over < 0 %}\n \n SHORT\n \n {% elif short_over > 0 %}\n \n OVER\n \n {% endif %}\n\n \n\n
\n\n \n\n \n\n
",
+ "line_breaks": 0,
+ "margin_bottom": 0.0,
+ "margin_left": 0.0,
+ "margin_right": 0.0,
+ "margin_top": 0.0,
+ "module": "POS Next",
+ "name": "POS Next EOD Report",
+ "print_format_builder": 0,
+ "print_format_builder_beta": 0,
+ "print_format_type": "Jinja",
+ "raw_printing": 0,
+ "show_section_headings": 0,
+ "standard": "No"
+}
diff --git a/pos_next/pos_next/utils/__init__.py b/pos_next/pos_next/utils/__init__.py
new file mode 100644
index 000000000..9cd97727a
--- /dev/null
+++ b/pos_next/pos_next/utils/__init__.py
@@ -0,0 +1 @@
+# Utility package for POS Next doctypes/helpers.
diff --git a/pos_next/pos_next/utils/pos_closing_print.py b/pos_next/pos_next/utils/pos_closing_print.py
new file mode 100644
index 000000000..e31cb2416
--- /dev/null
+++ b/pos_next/pos_next/utils/pos_closing_print.py
@@ -0,0 +1,105 @@
+from __future__ import annotations
+
+from typing import Iterable
+
+import frappe
+from frappe.query_builder import DocType
+from frappe.utils import flt
+from pypika import Order
+from pypika.functions import Sum
+
+
+def _as_closing_doc(doc):
+ if isinstance(doc, str):
+ return frappe.get_doc("POS Closing Shift", doc)
+ return doc
+
+
+def _collect_parent_targets(pos_transactions: Iterable) -> set[tuple[str, str]]:
+ sales_invoice_targets: set[tuple[str, str]] = set()
+ pos_invoices: set[str] = set()
+
+ for row in pos_transactions or []:
+ sales_invoice = row.get("sales_invoice")
+ pos_invoice = row.get("pos_invoice")
+
+ if sales_invoice:
+ sales_invoice_targets.add((sales_invoice, "Sales Invoice"))
+ continue
+
+ if pos_invoice:
+ pos_invoices.add(pos_invoice)
+
+ return sales_invoice_targets | _get_pos_invoice_parent_targets(pos_invoices)
+
+
+def _get_pos_invoice_parent_targets(pos_invoices: set[str]) -> set[tuple[str, str]]:
+ if not pos_invoices:
+ return set()
+
+ targets: set[tuple[str, str]] = set()
+ rows = frappe.get_all(
+ "POS Invoice",
+ filters={"name": ["in", list(pos_invoices)]},
+ fields=["name", "consolidated_invoice"],
+ limit_page_length=0,
+ )
+
+ for row in rows:
+ consolidated_invoice = row.get("consolidated_invoice")
+ if consolidated_invoice:
+ targets.add((consolidated_invoice, "Sales Invoice"))
+ else:
+ targets.add((row.get("name"), "POS Invoice"))
+
+ return targets
+
+
+def _fetch_items_for_targets(parent_targets: set[tuple[str, str]]) -> list[dict]:
+ if not parent_targets:
+ return []
+
+ sales_invoice_item = DocType("Sales Invoice Item")
+ amount_sum = Sum(sales_invoice_item.amount)
+ qty_sum = Sum(sales_invoice_item.qty)
+
+ condition = None
+ for parent, parenttype in sorted(parent_targets):
+ current = (sales_invoice_item.parent == parent) & (
+ sales_invoice_item.parenttype == parenttype
+ )
+ condition = current if condition is None else (condition | current)
+
+ query = (
+ frappe.qb.from_(sales_invoice_item)
+ .select(
+ sales_invoice_item.item_code,
+ sales_invoice_item.item_name,
+ qty_sum.as_("qty"),
+ amount_sum.as_("amount"),
+ )
+ .where(condition)
+ .groupby(sales_invoice_item.item_code, sales_invoice_item.item_name)
+ .orderby(amount_sum, order=Order.desc)
+ )
+
+ return query.run(as_dict=True)
+
+
+def get_items_sold(doc) -> list[dict]:
+ closing_doc = _as_closing_doc(doc)
+ parent_targets = _collect_parent_targets(closing_doc.get("pos_transactions"))
+ if not parent_targets:
+ return []
+
+ items = _fetch_items_for_targets(parent_targets)
+
+ return [
+ {
+ "item_code": row.get("item_code"),
+ "item_name": row.get("item_name"),
+ "qty": flt(row.get("qty")),
+ "amount": flt(row.get("amount")),
+ }
+ for row in items
+ ]
diff --git a/pos_next/pos_next/utils/tests/__init__.py b/pos_next/pos_next/utils/tests/__init__.py
new file mode 100644
index 000000000..649d3b52a
--- /dev/null
+++ b/pos_next/pos_next/utils/tests/__init__.py
@@ -0,0 +1 @@
+# Tests for POS Next utility helpers.
diff --git a/pos_next/pos_next/utils/tests/test_pos_closing_print.py b/pos_next/pos_next/utils/tests/test_pos_closing_print.py
new file mode 100644
index 000000000..9be6b0317
--- /dev/null
+++ b/pos_next/pos_next/utils/tests/test_pos_closing_print.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from frappe.tests.utils import FrappeTestCase
+
+from pos_next.pos_next.utils.pos_closing_print import (
+ _collect_parent_targets,
+ get_items_sold,
+)
+
+
+class TestPOSClosingPrint(FrappeTestCase):
+ @patch("pos_next.pos_next.utils.pos_closing_print.frappe.get_all")
+ def test_collect_parent_targets_prefers_sales_invoice(self, mock_get_all):
+ mock_get_all.return_value = [{"name": "POSINV-0002", "consolidated_invoice": None}]
+
+ targets = _collect_parent_targets(
+ [
+ {"sales_invoice": "SINV-0001", "pos_invoice": "POSINV-0001"},
+ {"pos_invoice": "POSINV-0002"},
+ {"sales_invoice": "SINV-0002"},
+ ]
+ )
+
+ self.assertEqual(
+ targets,
+ {
+ ("SINV-0001", "Sales Invoice"),
+ ("POSINV-0002", "POS Invoice"),
+ ("SINV-0002", "Sales Invoice"),
+ },
+ )
+ mock_get_all.assert_called_once()
+
+ @patch("pos_next.pos_next.utils.pos_closing_print.frappe.get_all")
+ def test_collect_parent_targets_follows_consolidated_invoice(self, mock_get_all):
+ mock_get_all.return_value = [{"name": "POSINV-0001", "consolidated_invoice": "SINV-0999"}]
+
+ targets = _collect_parent_targets([{"pos_invoice": "POSINV-0001"}])
+
+ self.assertEqual(targets, {("SINV-0999", "Sales Invoice")})
+
+ @patch("pos_next.pos_next.utils.pos_closing_print._fetch_items_for_targets")
+ def test_get_items_sold_returns_float_values(self, mock_fetch):
+ mock_fetch.return_value = [
+ {
+ "item_code": "ITEM-001",
+ "item_name": "Latte",
+ "qty": "2",
+ "amount": "150.50",
+ }
+ ]
+
+ doc = {"pos_transactions": [{"sales_invoice": "SINV-0001"}]}
+ result = get_items_sold(doc)
+
+ self.assertEqual(
+ result,
+ [
+ {
+ "item_code": "ITEM-001",
+ "item_name": "Latte",
+ "qty": 2.0,
+ "amount": 150.5,
+ }
+ ],
+ )
+ mock_fetch.assert_called_once_with({("SINV-0001", "Sales Invoice")})
+
+ @patch("pos_next.pos_next.utils.pos_closing_print._fetch_items_for_targets")
+ def test_get_items_sold_returns_empty_when_no_transactions(self, mock_fetch):
+ doc = {"pos_transactions": []}
+ result = get_items_sold(doc)
+
+ self.assertEqual(result, [])
+ mock_fetch.assert_not_called()
diff --git a/pos_next/translations/ar.csv b/pos_next/translations/ar.csv
index f89e6390a..478d4f6bf 100644
--- a/pos_next/translations/ar.csv
+++ b/pos_next/translations/ar.csv
@@ -47,6 +47,7 @@
"Item removed from cart","تم الحذف من السلة",""
"Open Shift","فتح وردية",""
"Close Shift","إغلاق الوردية",""
+"Print EOD Report","طباعة تقرير نهاية اليوم",""
"Shift Opening","بداية الوردية",""
"Shift Closing","نهاية الوردية",""
"Opening Amount","عهدة الفتح",""
diff --git a/pos_next/translations/id.csv b/pos_next/translations/id.csv
index c6012d9bb..39ae1c4e5 100644
--- a/pos_next/translations/id.csv
+++ b/pos_next/translations/id.csv
@@ -46,6 +46,7 @@
"Item removed from cart","Dihapus dari keranjang",""
"Open Shift","Buka Shift",""
"Close Shift","Tutup Shift",""
+"Print EOD Report","Cetak Laporan Akhir Hari",""
"Shift Opening","Pembukaan Shift",""
"Shift Closing","Penutupan Shift",""
"Opening Amount","Jumlah Pembukaan Shift",""
diff --git a/pos_next/translations/pt-br.csv b/pos_next/translations/pt-br.csv
index de859ba11..683402896 100644
--- a/pos_next/translations/pt-br.csv
+++ b/pos_next/translations/pt-br.csv
@@ -44,6 +44,7 @@
"Item removed from cart","Item removido do carrinho",""
"Open Shift","Abrir Turno",""
"Close Shift","Fechar Turno",""
+"Print EOD Report","Imprimir Relatório de Fim de Dia",""
"Shift Opening","Abertura do Turno",""
"Shift Closing","Fechamento do Turno",""
"Opening Amount","Valor de Abertura",""
diff --git a/pos_next/uninstall.py b/pos_next/uninstall.py
index a3ee275a9..cd8a783be 100644
--- a/pos_next/uninstall.py
+++ b/pos_next/uninstall.py
@@ -87,6 +87,7 @@ def remove_print_formats():
# List of print formats to remove
print_formats = [
"POS Next Receipt",
+ "POS Next EOD Report",
]
removed_count = 0