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

18
package-lock.json generated
View File

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

View File

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

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