diff --git a/POS/src/components/ShiftClosingDialog.vue b/POS/src/components/ShiftClosingDialog.vue index 8359f063b..8f110ed2b 100644 --- a/POS/src/components/ShiftClosingDialog.vue +++ b/POS/src/components/ShiftClosingDialog.vue @@ -486,6 +486,10 @@ {{ __('✓ Shift closed successfully') }} +
+ {{ __('EOD report pending print') }} +
+ + + @@ -509,8 +523,10 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from "vue" import { storeToRefs } from "pinia" import { useShift, shiftState } from "../composables/useShift" import { useFormatters } from "../composables/useFormatters" +import { useToast } from "../composables/useToast" import { usePOSSettingsStore } from "../stores/posSettings" import { usePOSShiftStore } from "../stores/posShift" +import { printEODReport } from "../utils/printEod" import TranslatedHTML from "./common/TranslatedHTML.vue" const props = defineProps({ @@ -534,6 +550,7 @@ const open = computed({ const { getClosingShiftData, submitClosingShift } = useShift() const { formatCurrency, formatQuantity, formatDateTime, formatTime } = useFormatters() +const { showSuccess, showWarning } = useToast() const posSettingsStore = usePOSSettingsStore() const { hideExpectedAmount } = storeToRefs(posSettingsStore) @@ -545,6 +562,8 @@ const submitResource = submitClosingShift const showInvoiceDetails = ref(false) const showSuccessReport = ref(false) // Track if shift is closed and showing report const errorMessage = ref('') // User-friendly error message +const eodPrintFailed = ref(null) +const retryPrintLoading = ref(false) const showIdleWarning = ref(false) let _idleWarningTimer = null @@ -571,6 +590,7 @@ watch(open, async (isOpen) => { clearTimeout(_idleWarningTimer) _idleWarningTimer = null } + eodPrintFailed.value = null } }) @@ -660,7 +680,20 @@ async function submitClosing() { } // Submit to server - await submitResource.submit({ closing_shift: closingData.value }) + const result = await submitResource.submit({ closing_shift: closingData.value }) + const closingShiftName = result?.name ?? submitResource.data?.name + if (closingShiftName) { + try { + await printEODReport(closingShiftName) + eodPrintFailed.value = null + } catch (err) { + console.warn("[eod] print failed", err) + showWarning(__("EOD report did not print. Use the Reprint button to retry.")) + eodPrintFailed.value = { closingShiftName } + showSuccessReport.value = true + return + } + } // If hideExpectedAmount is enabled, show success report before closing if (hideExpectedAmount.value) { @@ -680,6 +713,24 @@ async function submitClosing() { } } +async function retryEodPrint() { + const closingShiftName = eodPrintFailed.value?.closingShiftName + if (!closingShiftName) return + + retryPrintLoading.value = true + try { + await printEODReport(closingShiftName) + eodPrintFailed.value = null + showSuccess(__("EOD report printed successfully")) + closeDialog() + } catch (err) { + console.warn("[eod] retry print failed", err) + showWarning(__("EOD report did not print. Please check QZ Tray and retry.")) + } finally { + retryPrintLoading.value = false + } +} + function closeDialog() { // Emit shift-closed event if we're closing from success report if (showSuccessReport.value) { @@ -691,6 +742,7 @@ function closeDialog() { showInvoiceDetails.value = false showSuccessReport.value = false // Reset report view errorMessage.value = '' // Clear error messages + eodPrintFailed.value = null } // UI State Computed Properties diff --git a/POS/src/utils/printEod.js b/POS/src/utils/printEod.js new file mode 100644 index 000000000..aa2a2d618 --- /dev/null +++ b/POS/src/utils/printEod.js @@ -0,0 +1,7 @@ +import { silentPrintDoc } from "./printInvoice" + +const EOD_PRINT_FORMAT = "POS Next EOD Report" + +export async function printEODReport(closingShiftName) { + await silentPrintDoc("POS Closing Shift", closingShiftName, EOD_PRINT_FORMAT) +} diff --git a/POS/src/utils/printInvoice.js b/POS/src/utils/printInvoice.js index d6111d989..235cc6e71 100644 --- a/POS/src/utils/printInvoice.js +++ b/POS/src/utils/printInvoice.js @@ -361,6 +361,28 @@ export async function printInvoiceByName(invoiceName, printFormat = null, letter // Silent printing (QZ Tray — no browser dialog) // ============================================================================ +export async function silentPrintDoc(doctype, name, printFormat) { + const result = await call("frappe.www.printview.get_html_and_style", { + doc: doctype, + name, + print_format: printFormat, + no_letterhead: 1, + }) + + const html = result?.html || result?.message?.html + const style = result?.style || result?.message?.style || "" + if (!html) throw new Error("Failed to get print HTML from server") + + const fullHTML = ` + + +${html} +` + + await qzPrintHTML(fullHTML) + return true +} + /** * Fetch the server-rendered print HTML and send it to a thermal printer * via QZ Tray. Uses Frappe's get_html_and_style API which returns the @@ -381,24 +403,7 @@ export async function silentPrintInvoice(invoiceName, printFormat = null) { } const format = printFormat || DEFAULT_PRINT_FORMAT - const result = await call("frappe.www.printview.get_html_and_style", { - doc: "Sales Invoice", - name: invoiceName, - print_format: format, - no_letterhead: 1, - }) - - const html = result?.html || result?.message?.html - const style = result?.style || result?.message?.style || "" - if (!html) throw new Error("Failed to get print HTML from server") - - const fullHTML = ` - - -${html} -` - - await qzPrintHTML(fullHTML) + await silentPrintDoc("Sales Invoice", invoiceName, format) log.info(`Silent print sent for ${invoiceName}`) return true } diff --git a/docs/superpowers/plans/2026-05-24-eod-shift-report-print.md b/docs/superpowers/plans/2026-05-24-eod-shift-report-print.md new file mode 100644 index 000000000..4d5390ff4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-eod-shift-report-print.md @@ -0,0 +1,88 @@ +# Plan: EOD Shift Report Auto-Print + Reprint + +Date: 2026-05-24 +Feature: EOD shift report print on closing shift + +## Work Breakdown + +### 1) Documentation + +- Add design spec file under `docs/superpowers/specs/` +- Add implementation plan file under `docs/superpowers/plans/` + +### 2) Backend (Print Data + Jinja hook) + +- Create `pos_next/pos_next/utils/pos_closing_print.py` + - implement `get_items_sold(doc)` + - aggregate by `item_code`, `item_name` + - sum `qty`, `amount` + - order by `amount` descending + - handle `sales_invoice` and `pos_invoice` + - follow `POS Invoice.consolidated_invoice` when set +- Register Jinja method in `pos_next/hooks.py` + +### 3) Backend Tests + +- Create `pos_next/pos_next/utils/tests/test_pos_closing_print.py` + - verify mixed invoice references are handled + - verify consolidated invoice path is followed + - verify output shape uses float values + +### 4) Print Format + +- Add fixture: + - `pos_next/pos_next/print_format/pos_next_eod_report/pos_next_eod_report.json` +- Configure: + - `doc_type = POS Closing Shift` + - `name = POS Next EOD Report` + - `module = POS Next` + - Jinja type with 80mm thermal styling + - explicit LTR layout for receipt rows + +### 5) Desk Reprint Action + +- Update `pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js` + - on submitted docs add custom button `Print EOD Report` under `Print` + - call `frappe.utils.print` using `POS Next EOD Report` + +### 6) POS Frontend Auto-Print + Retry + +- Update `POS/src/utils/printInvoice.js` + - add reusable `silentPrintDoc(doctype, name, printFormat)` +- Create `POS/src/utils/printEod.js` + - implement `printEODReport(closingShiftName)` with `silentPrintDoc` +- Update `POS/src/components/ShiftClosingDialog.vue` + - after successful submit, resolve `result.name` first + - attempt EOD print + - on print failure: + - warning toast + - keep dialog open + - show retry action + - on retry success: + - success toast + - emit `shift-closed` + - close dialog + +### 7) Uninstall Cleanup + +- Update `pos_next/uninstall.py` + - include `POS Next EOD Report` in print format cleanup list + +### 8) Validation + +- Run migrate to sync print format: + - `bench --site migrate` +- Run backend tests: + - `bench --site run-tests pos_next.utils.tests.test_pos_closing_print` +- Run frontend build: + - `cd POS && npm run build` + +## Sequenced Commits + +1. `docs: spec EOD shift report auto-print + closing-shift reprint` +2. `docs: plan EOD shift report auto-print + closing-shift reprint` +3. `feat(eod): add EOD shift report print format + helper + Closing Shift button` +4. `feat(eod): auto-print EOD report on shift close with retry path` +5. `fix(eod): resolve closing shift name from submit response` +6. `fix(eod): follow consolidated_invoice for items + force LTR layout` +7. `feat(eod): polish POS Next EOD Report print format` diff --git a/docs/superpowers/specs/2026-05-24-eod-shift-report-print-design.md b/docs/superpowers/specs/2026-05-24-eod-shift-report-print-design.md new file mode 100644 index 000000000..cbad04796 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-eod-shift-report-print-design.md @@ -0,0 +1,122 @@ +# EOD Shift Report Print Design + +Date: 2026-05-24 +App: POS Next +Scope: POS Closing Shift auto-print and Desk reprint for End-of-Day report. + +## Problem Statement + +Cashiers need a printed EOD report when closing a shift. Currently, shift close does not automatically print this report, and Desk users do not have a dedicated reprint action for submitted closing shifts. + +## Goal + +Implement EOD report printing for `POS Closing Shift` with: + +- Auto-print after successful shift close in POS frontend +- Non-blocking print failure behavior (close succeeds, warning shown, retry path provided) +- Reprint button on submitted `POS Closing Shift` in Desk +- Thermal 80mm print format named `POS Next EOD Report` + +## Non-Goals + +- No schema changes to `POS Closing Shift` or related doctypes +- No changes to receipt transport beyond existing QZ Tray pipeline +- No changes to `POS Next Receipt` format + +## Primary Flow + +1. Cashier closes shift in POS. +2. Backend submits `POS Closing Shift`. +3. Frontend resolves returned `closing_shift` name from submit response. +4. Frontend requests print HTML/style via `frappe.www.printview.get_html_and_style`. +5. Frontend sends full HTML document to QZ Tray (`qzPrintHTML`). +6. If printing fails: + - Shift remains submitted (no rollback) + - Warning toast appears + - Dialog stays open with retry button + +## Data Sources + +- `doc.pos_transactions` child rows (Sales Invoice Reference) + - rows may contain `sales_invoice` and/or `pos_invoice` +- `doc.payment_reconciliation` rows (POS Closing Shift Detail) +- `doc.taxes` rows (POS Closing Shift Taxes) +- totals on closing doc: + - `grand_total`, `net_total`, `total_quantity` + +## Consolidation Requirement + +For closed shifts where ERPNext consolidated POS Invoices into Sales Invoices: + +- `get_items_sold(doc)` must follow `POS Invoice.consolidated_invoice` +- If `consolidated_invoice` is set, fetch items from `Sales Invoice Item` with: + - `parent = consolidated_invoice` + - `parenttype = "Sales Invoice"` +- Otherwise use: + - `parent = pos_invoice` + - `parenttype = "POS Invoice"` + +## Backend Components + +- New helper module: + - `pos_next/pos_next/utils/pos_closing_print.py` + - exposed to Jinja via `hooks.py` +- Jinja method: + - `get_items_sold(doc)` returning aggregated item rows sorted by amount desc +- Test module: + - `pos_next/pos_next/utils/tests/test_pos_closing_print.py` + +## Print Format + +- New print format fixture JSON: + - `pos_next/pos_next/print_format/pos_next_eod_report/pos_next_eod_report.json` +- Name: `POS Next EOD Report` +- DocType: `POS Closing Shift` +- Thermal styling: + - 80mm width + - explicit `direction: ltr` on receipt container and rows +- Sections: + - Header + - Session details + - Sales totals + - Items sold + - Taxes + - Payments + - Final summary + - Footer with print timestamp and user + +## Desk Integration + +File: `pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js` + +- On submitted doc (`docstatus === 1`), add button: + - Label: `Print EOD Report` + - Group: `Print` + - Calls `frappe.utils.print` with print format `POS Next EOD Report` + +## POS Frontend Integration + +Files: + +- `POS/src/utils/printInvoice.js` + - add generic `silentPrintDoc(doctype, name, printFormat)` +- `POS/src/utils/printEod.js` + - add `printEODReport(closingShiftName)` +- `POS/src/components/ShiftClosingDialog.vue` + - after successful submit, print EOD + - if print fails: show warning + keep dialog open with retry action + - retry success closes dialog and emits `shift-closed` + +## Error Handling + +- Print errors are non-fatal +- Submit errors remain fatal for closing flow +- Print retry does not resubmit shift + +## Acceptance Criteria + +- Shift close triggers EOD print via QZ Tray +- Print failure does not block close +- Retry button can print and finish dialog flow +- Desk submitted `POS Closing Shift` supports one-click EOD reprint +- Items sold section works for consolidated and non-consolidated invoices diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 3816db681..a08c97b63 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -79,10 +79,11 @@ # ---------- # add methods and filters to jinja environment -# jinja = { -# "methods": "pos_next.utils.jinja_methods", -# "filters": "pos_next.utils.jinja_filters" -# } +jinja = { + "methods": [ + "pos_next.pos_next.utils.pos_closing_print.get_items_sold", + ] +} # Fixtures # -------- diff --git a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js index 900af6a7f..e5201644f 100644 --- a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js +++ b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.js @@ -24,6 +24,24 @@ frappe.ui.form.on("POS Closing Shift", { if (frm.doc.docstatus === 1) set_html_data(frm); }, + refresh(frm) { + if (frm.doc.docstatus !== 1) return; + + frm.add_custom_button( + __("Print EOD Report"), + () => { + frappe.utils.print( + frm.doctype, + frm.docname, + "POS Next EOD Report", + frm.doc.letter_head, + frm.doc.language || frappe.boot.lang, + ); + }, + __("Print"), + ); + }, + pos_opening_shift(frm) { if (frm.doc.pos_opening_shift && frm.doc.user) { reset_values(frm); diff --git a/pos_next/pos_next/print_format/pos_next_eod_report/pos_next_eod_report.json b/pos_next/pos_next/print_format/pos_next_eod_report/pos_next_eod_report.json new file mode 100644 index 000000000..563ebad7a --- /dev/null +++ b/pos_next/pos_next/print_format/pos_next_eod_report/pos_next_eod_report.json @@ -0,0 +1,25 @@ +{ + "absolute_value": 0, + "align_labels_right": 0, + "custom_format": 1, + "default_print_language": "en", + "disabled": 0, + "doc_type": "POS Closing Shift", + "docstatus": 0, + "doctype": "Print Format", + "font_size": 11, + "html": "\n\n{%- set company_doc = frappe.get_cached_doc(\"Company\", doc.company) -%}\n{%- set profile_doc = frappe.get_cached_doc(\"POS Profile\", doc.pos_profile) -%}\n{%- set user_doc = frappe.get_cached_doc(\"User\", doc.user) -%}\n\n{%- set items = get_items_sold(doc) -%}\n\n{% set total_expected = namespace(v=0.0) %}\n{% set total_counted = namespace(v=0.0) %}\n\n{% for p in doc.payment_reconciliation %}\n {% set total_expected.v = total_expected.v + (p.expected_amount or 0) %}\n {% set total_counted.v = total_counted.v + (p.closing_amount or 0) %}\n{% endfor %}\n\n{% set short_over = total_counted.v - total_expected.v %}\n\n
\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 Qty {{ \"{:g}\".format(it.qty | float) }}\n \u00d7\n {{ \"{:,.2f}\".format((it.amount / it.qty) if it.qty else 0) }}\n
\n\n
\n {{ \"{:,.2f}\".format(it.amount | float) }}\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 Printed:\n {{ frappe.utils.format_datetime(frappe.utils.now_datetime()) }}\n\n
\n\n by {{ frappe.session.user }}\n\n
\n *** END ***\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