From 61fc829a919f7de157ce980ea12269c98a384d23 Mon Sep 17 00:00:00 2001 From: Viet An Date: Thu, 25 Jun 2026 09:51:45 +0700 Subject: [PATCH] feat: print receipt --- .../pos/composables/usePlaceOrder.js | 39 ++++++++-- package-lock.json | 18 +++++ package.json | 1 + print/print_receipt.py | 71 +++++++++++++++++++ print/receipt_template.txt | 13 ++++ server/api/print/receipt.post.ts | 43 +++++++++++ 6 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 print/print_receipt.py create mode 100644 print/receipt_template.txt create mode 100644 server/api/print/receipt.post.ts diff --git a/app/components/pos/composables/usePlaceOrder.js b/app/components/pos/composables/usePlaceOrder.js index 5e4bd82..44f85f1 100644 --- a/app/components/pos/composables/usePlaceOrder.js +++ b/app/components/pos/composables/usePlaceOrder.js @@ -12,6 +12,8 @@ export default function usePlaceOrder({ const isPending = ref(false); const paidPaymentStatus = ref(); const invoice = ref(); + const invoice_details = ref(); + const payment_record = ref(); const showVietQRModal = ref(); /** @@ -41,12 +43,13 @@ export default function usePlaceOrder({ delivery_method: orderInfo.value.deliveryMethod.id, }; - const invoice = await $insertapi("Invoice", { + const invoiceRecord = await $insertapi("Invoice", { data: invoicePayload, notify: false, }); - return invoice; + invoice.value = invoiceRecord; + return invoiceRecord; } catch (error) { console.error(error); $snackbar("Tạo đơn hàng không thành công", "Error"); @@ -55,6 +58,27 @@ export default function usePlaceOrder({ } } + async function printReceipt() { + return await $fetch("/api/print/receipt", { + method: "POST", + body: { + invoice: invoice.value, + invoice_details: invoice_details.value, + payment_record: payment_record.value, + }, + }); + } + + /** + * Prints receipt, displays snackbar, resets cart, closes modal + */ + async function onOrderSuccess() { + $snackbar("Khởi tạo đơn hàng thành công", "Success"); + printReceipt(); + resetCart(); + onSuccess(); + } + /** * Mark sold IMEIs, create invoice details, delivery info, payment records, reset cart. * Runs when order payment is confirmed. @@ -67,7 +91,7 @@ export default function usePlaceOrder({ } const imeisSold = await markImeisSold(); - await createInvoiceDetails(invoiceId, imeisSold); + invoice_details.value = await createInvoiceDetails(invoiceId, imeisSold); await createPaymentRecord(invoiceId); if (orderInfo.value.deliveryMethod.code === "HOME_DELIVERY") { @@ -76,9 +100,7 @@ export default function usePlaceOrder({ // update invoice.payment_status from PENDING to PAID await $patchapi("Invoice", { id: invoiceId, payment_status: paidPaymentStatus.value.id }); - $snackbar("Khởi tạo đơn hàng thành công", "Success"); - resetCart(); - onSuccess(); + await onOrderSuccess(); } async function markImeisSold() { @@ -110,6 +132,8 @@ export default function usePlaceOrder({ const invoiceDetails = await $insertapi("Invoice_Detail", { data: invoiceDetailPayload, notify: false, + values: + "id,code,invoice,variant,variant__code,variant__product,variant__product__name,variant__color,variant__color__name,variant__ram__code,variant__ram__capacity,variant__internal_storage__code,variant__internal_storage__capacity,variant__price,variant__image__path,imei_sold,price,status,note,deleted,create_time,update_time", }); return invoiceDetails; @@ -150,6 +174,7 @@ export default function usePlaceOrder({ notify: false, }); + payment_record.value = paymentRecord; return paymentRecord; } @@ -168,7 +193,7 @@ export default function usePlaceOrder({ } async function placeOrder() { - invoice.value = await createOrder(); + await createOrder(); if (orderInfo.value.paymentMethod.code === "CASH") { finalizeOrder(invoice.value.id); diff --git a/package-lock.json b/package-lock.json index 210a8e0..de0eac5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "devDependencies": { "@iconify-json/material-symbols": "^1.2.69", "@nuxt/icon": "^2.2.1", + "@types/node": "^26.0.0", "nuxt": "^4.4.7", "prettier": "3.8.3", "typescript": "^6.0.3" @@ -4293,6 +4294,16 @@ "@types/unist": "*" } }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -12289,6 +12300,13 @@ "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", "license": "MIT" }, +"node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", diff --git a/package.json b/package.json index 0cfd403..8ddb2f4 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "devDependencies": { "@iconify-json/material-symbols": "^1.2.69", "@nuxt/icon": "^2.2.1", + "@types/node": "^26.0.0", "nuxt": "^4.4.7", "prettier": "3.8.3", "typescript": "^6.0.3" diff --git a/print/print_receipt.py b/print/print_receipt.py new file mode 100644 index 0000000..4587f8a --- /dev/null +++ b/print/print_receipt.py @@ -0,0 +1,71 @@ +import sys +import json +from datetime import datetime +from escpos.printer import Win32Raw + +LINE_WIDTH = 48 # 80mm paper + +def safe_float(v): + return float(v) if v is not None else 0.0 + +def money(n): + return f"{int(safe_float(n)):,}" + +def space_between(left, right, width=LINE_WIDTH): + space = max(1, width - len(str(left)) - len(str(right))) + return f"{left}{' ' * space}{right}" + +def print_receipt(data, printer_name="XP-80C"): + p = Win32Raw(printer_name=printer_name) + + # Big bold header + p.set(align='center', bold=True, double_height=True, double_width=True) + p.text("ERP\n") + + p.set(align='center', bold=False, normal_textsize=True) + p.text(f"Invoice: {data['code']}\n") + p.text(f"{data['ordered_at'][:10]}\n") + p.text("-" * LINE_WIDTH + "\n") + + p.set(align='left') + p.text(f"Customer: {data['customer_name']}\n") + p.text(f"Phone: {data['customer_phone']}\n") + p.text(f"Address: {data['shipping_address']}\n") + p.text("-" * LINE_WIDTH + "\n") + + for item in data['items']: + p.text(f"{item['name']}\n") + p.text(f" {item['variant']}\n") + p.text(space_between("", money(item['price'])) + "\n") + + p.text("-" * LINE_WIDTH + "\n") + + p.text(space_between("Subtotal:", money(data['product_amount'])) + "\n") + if safe_float(data['shipping_fee']) > 0: + p.text(space_between("Shipping:", money(data['shipping_fee'])) + "\n") + if safe_float(data['discount_amount']) > 0: + p.text(space_between("Discount:", f"-{money(data['discount_amount'])}") + "\n") + + p.set(bold=True) + p.text(space_between("TOTAL:", money(data['final_amount']) + " VND") + "\n") + p.set(bold=False) + + p.text("-" * LINE_WIDTH + "\n") + + if safe_float(data['cash_amount']) > 0: + p.text(space_between("Cash:", money(data['cash_amount'])) + "\n") + if safe_float(data['transfer_amount']) > 0: + p.text(space_between("Transfer:", money(data['transfer_amount'])) + "\n") + + p.text("\n\n") + p.set(align='center') + now_str = datetime.now().strftime("%H:%M %d/%m/%Y") + p.text(f"{now_str}\n") + + p.text("\n" * 3) + p.cut() + +if __name__ == "__main__": + data = json.loads(sys.argv[1]) + print_receipt(data) + print("Sent to printer.") \ No newline at end of file diff --git a/print/receipt_template.txt b/print/receipt_template.txt new file mode 100644 index 0000000..a9cb1de --- /dev/null +++ b/print/receipt_template.txt @@ -0,0 +1,13 @@ +{big_title} +{header} +{line} +{customer_block} +{line} +{items} +{line} +{totals} +{line} +{payment} +{line_break} +{footer_time} +{blanks} \ No newline at end of file diff --git a/server/api/print/receipt.post.ts b/server/api/print/receipt.post.ts new file mode 100644 index 0000000..193767a --- /dev/null +++ b/server/api/print/receipt.post.ts @@ -0,0 +1,43 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import { cloneDeepWith, deburr } from "es-toolkit"; + +const execFileAsync = promisify(execFile); + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + const bodyNonAccent = cloneDeepWith(body, (value) => (typeof value === "string" ? deburr(value) : undefined)); + const { invoice, invoice_details, payment_record } = bodyNonAccent; + + const items = invoice_details.map((d: any) => ({ + name: d.variant__product__name, + variant: `${d.variant__color__name} ${d.variant__ram__code}/${d.variant__internal_storage__code}`, + imei: d.imei_sold, + price: d.price, + })); + + const payload = JSON.stringify({ + code: invoice.code, + ordered_at: invoice.ordered_at, + customer_name: invoice.customer_name, + customer_phone: invoice.customer_phone, + shipping_address: invoice.shipping_address, + items, + product_amount: invoice.product_amount, + shipping_fee: invoice.shipping_fee, + discount_amount: invoice.discount_amount, + final_amount: invoice.final_amount, + cash_amount: payment_record.cash_amount, + transfer_amount: payment_record.transfer_amount, + }); + + const scriptPath = path.join(process.cwd(), "print", "print_receipt.py"); + + try { + const { stdout } = await execFileAsync("python", [scriptPath, payload]); + return { success: true, output: stdout }; + } catch (err) { + throw createError({ statusCode: 500, statusMessage: "Print failed", data: String(err) }); + } +});