feat: print receipt

This commit is contained in:
Viet An
2026-06-25 09:51:45 +07:00
parent 07151bf78e
commit 61fc829a91
6 changed files with 178 additions and 7 deletions

View File

@@ -12,6 +12,8 @@ export default function usePlaceOrder({
const isPending = ref(false); const isPending = ref(false);
const paidPaymentStatus = ref(); const paidPaymentStatus = ref();
const invoice = ref(); const invoice = ref();
const invoice_details = ref();
const payment_record = ref();
const showVietQRModal = ref(); const showVietQRModal = ref();
/** /**
@@ -41,12 +43,13 @@ export default function usePlaceOrder({
delivery_method: orderInfo.value.deliveryMethod.id, delivery_method: orderInfo.value.deliveryMethod.id,
}; };
const invoice = await $insertapi("Invoice", { const invoiceRecord = await $insertapi("Invoice", {
data: invoicePayload, data: invoicePayload,
notify: false, notify: false,
}); });
return invoice; invoice.value = invoiceRecord;
return invoiceRecord;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
$snackbar("Tạo đơn hàng không thành công", "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. * Mark sold IMEIs, create invoice details, delivery info, payment records, reset cart.
* Runs when order payment is confirmed. * Runs when order payment is confirmed.
@@ -67,7 +91,7 @@ export default function usePlaceOrder({
} }
const imeisSold = await markImeisSold(); const imeisSold = await markImeisSold();
await createInvoiceDetails(invoiceId, imeisSold); invoice_details.value = await createInvoiceDetails(invoiceId, imeisSold);
await createPaymentRecord(invoiceId); await createPaymentRecord(invoiceId);
if (orderInfo.value.deliveryMethod.code === "HOME_DELIVERY") { if (orderInfo.value.deliveryMethod.code === "HOME_DELIVERY") {
@@ -76,9 +100,7 @@ export default function usePlaceOrder({
// update invoice.payment_status from PENDING to PAID // update invoice.payment_status from PENDING to PAID
await $patchapi("Invoice", { id: invoiceId, payment_status: paidPaymentStatus.value.id }); await $patchapi("Invoice", { id: invoiceId, payment_status: paidPaymentStatus.value.id });
$snackbar("Khởi tạo đơn hàng thành công", "Success"); await onOrderSuccess();
resetCart();
onSuccess();
} }
async function markImeisSold() { async function markImeisSold() {
@@ -110,6 +132,8 @@ export default function usePlaceOrder({
const invoiceDetails = await $insertapi("Invoice_Detail", { const invoiceDetails = await $insertapi("Invoice_Detail", {
data: invoiceDetailPayload, data: invoiceDetailPayload,
notify: false, 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; return invoiceDetails;
@@ -150,6 +174,7 @@ export default function usePlaceOrder({
notify: false, notify: false,
}); });
payment_record.value = paymentRecord;
return paymentRecord; return paymentRecord;
} }
@@ -168,7 +193,7 @@ export default function usePlaceOrder({
} }
async function placeOrder() { async function placeOrder() {
invoice.value = await createOrder(); await createOrder();
if (orderInfo.value.paymentMethod.code === "CASH") { if (orderInfo.value.paymentMethod.code === "CASH") {
finalizeOrder(invoice.value.id); finalizeOrder(invoice.value.id);

18
package-lock.json generated
View File

@@ -35,6 +35,7 @@
"devDependencies": { "devDependencies": {
"@iconify-json/material-symbols": "^1.2.69", "@iconify-json/material-symbols": "^1.2.69",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@types/node": "^26.0.0",
"nuxt": "^4.4.7", "nuxt": "^4.4.7",
"prettier": "3.8.3", "prettier": "3.8.3",
"typescript": "^6.0.3" "typescript": "^6.0.3"
@@ -4293,6 +4294,16 @@
"@types/unist": "*" "@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": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -12289,6 +12300,13 @@
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
"license": "MIT" "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": { "node_modules/unenv": {
"version": "2.0.0-rc.24", "version": "2.0.0-rc.24",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",

View File

@@ -40,6 +40,7 @@
"devDependencies": { "devDependencies": {
"@iconify-json/material-symbols": "^1.2.69", "@iconify-json/material-symbols": "^1.2.69",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@types/node": "^26.0.0",
"nuxt": "^4.4.7", "nuxt": "^4.4.7",
"prettier": "3.8.3", "prettier": "3.8.3",
"typescript": "^6.0.3" "typescript": "^6.0.3"

71
print/print_receipt.py Normal file
View File

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

View File

@@ -0,0 +1,13 @@
{big_title}
{header}
{line}
{customer_block}
{line}
{items}
{line}
{totals}
{line}
{payment}
{line_break}
{footer_time}
{blanks}

View File

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