@teincfood/core 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/business.d.ts +1 -3
- package/dist/adapters/business.js +3 -1
- package/dist/adapters/connectivity.d.ts +1 -1
- package/dist/adapters/connectivity.js +6 -3
- package/dist/adapters/filesystem.d.ts +22 -0
- package/dist/adapters/filesystem.js +6 -0
- package/dist/adapters/print.d.ts +29 -0
- package/dist/adapters/print.js +12 -0
- package/dist/cart/hooks.d.ts +36 -0
- package/dist/cart/hooks.js +45 -0
- package/dist/cart/store.d.ts +33 -0
- package/dist/cart/store.js +122 -0
- package/dist/catalog/hooks.d.ts +25 -0
- package/dist/catalog/hooks.js +160 -0
- package/dist/hooks/kiosk.hooks.d.ts +5 -0
- package/dist/hooks/kiosk.hooks.js +5 -0
- package/dist/hooks/orders.hooks.d.ts +16 -0
- package/dist/hooks/orders.hooks.js +93 -0
- package/dist/image-cache/service.d.ts +29 -0
- package/dist/image-cache/service.js +125 -0
- package/dist/index.d.ts +19 -2
- package/dist/index.js +27 -2
- package/dist/printer/hooks.d.ts +32 -0
- package/dist/printer/hooks.js +53 -0
- package/dist/printer/receipt.d.ts +12 -0
- package/dist/printer/receipt.js +315 -0
- package/dist/printer/service.d.ts +31 -0
- package/dist/printer/service.js +119 -0
- package/dist/printer/store.d.ts +24 -0
- package/dist/printer/store.js +94 -0
- package/dist/printer/types.d.ts +12 -0
- package/dist/printer/types.js +1 -0
- package/dist/sync/command-queue.service.js +17 -12
- package/package.json +1 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local receipt generation — 1:1 port of TeincFoodBackend.Orders.Receipt
|
|
3
|
+
* Generates ESC/POS (80mm, 32 chars) and HTML from a SellerOrder snapshot.
|
|
4
|
+
* Works fully offline for queued DD-RRR-CCC orders without backend fetch.
|
|
5
|
+
*
|
|
6
|
+
* Backend source: lib/teinc_food_backend/orders/receipt.ex
|
|
7
|
+
* Keep this file in sync with backend when template changes.
|
|
8
|
+
*/
|
|
9
|
+
const LINE_WIDTH = 32;
|
|
10
|
+
const ESC_INIT = "\x1B\x40";
|
|
11
|
+
const ESC_ALIGN_LEFT = "\x1B\x61\x00";
|
|
12
|
+
const ESC_ALIGN_CENTER = "\x1B\x61\x01";
|
|
13
|
+
const ESC_BOLD_ON = "\x1B\x45\x01";
|
|
14
|
+
const ESC_BOLD_OFF = "\x1B\x45\x00";
|
|
15
|
+
const GS_SIZE_NORMAL = "\x1D\x21\x00";
|
|
16
|
+
const GS_SIZE_DOUBLE_W = "\x1D\x21\x11";
|
|
17
|
+
const GS_SIZE_DOUBLE_WH = "\x1D\x21\x33";
|
|
18
|
+
const LF = "\x0A";
|
|
19
|
+
const ESC_FEED_3 = "\x1B\x64\x03";
|
|
20
|
+
const GS_CUT_FULL = "\x1D\x56\x00";
|
|
21
|
+
function padR(text, width) {
|
|
22
|
+
if (text.length >= width)
|
|
23
|
+
return text.slice(0, width);
|
|
24
|
+
return text.padEnd(width, " ");
|
|
25
|
+
}
|
|
26
|
+
function currencySymbol(currency) {
|
|
27
|
+
switch (currency) {
|
|
28
|
+
case "GHS": return "₵";
|
|
29
|
+
case "USD": return "$";
|
|
30
|
+
case "EUR": return "€";
|
|
31
|
+
case "GBP": return "£";
|
|
32
|
+
default: return currency ? currency + " " : "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function formatMoney(amountMinor, order) {
|
|
36
|
+
if (amountMinor == null)
|
|
37
|
+
return "---";
|
|
38
|
+
const sym = currencySymbol(order.currency_code || order.currency);
|
|
39
|
+
return `${sym}${(amountMinor / 100).toFixed(2)}`;
|
|
40
|
+
}
|
|
41
|
+
function formatDatetime(iso) {
|
|
42
|
+
if (!iso)
|
|
43
|
+
return "---";
|
|
44
|
+
try {
|
|
45
|
+
const d = new Date(iso);
|
|
46
|
+
// Backend: Calendar.strftime(dt, "%d %b %Y %H:%M") e.g. "03 Sep 2026 14:05"
|
|
47
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
48
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
49
|
+
const mon = months[d.getMonth()];
|
|
50
|
+
const year = d.getFullYear();
|
|
51
|
+
const hh = String(d.getHours()).padStart(2, "0");
|
|
52
|
+
const mm = String(d.getMinutes()).padStart(2, "0");
|
|
53
|
+
return `${day} ${mon} ${year} ${hh}:${mm}`;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return iso;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function calloutNumber(order) {
|
|
60
|
+
const num = order.order_number || "";
|
|
61
|
+
if (!num)
|
|
62
|
+
return "----";
|
|
63
|
+
const last4 = num.slice(-4);
|
|
64
|
+
const n = parseInt(last4, 10);
|
|
65
|
+
if (Number.isNaN(n))
|
|
66
|
+
return last4;
|
|
67
|
+
return String(n);
|
|
68
|
+
}
|
|
69
|
+
function itemName(item) {
|
|
70
|
+
return item.menu_item?.name || "(deleted item)";
|
|
71
|
+
}
|
|
72
|
+
function addonName(addon) {
|
|
73
|
+
const a = addon;
|
|
74
|
+
if (a.addon?.name)
|
|
75
|
+
return a.addon.name;
|
|
76
|
+
if (a.name)
|
|
77
|
+
return a.name;
|
|
78
|
+
return "Add-on";
|
|
79
|
+
}
|
|
80
|
+
function orderTypeText(order) {
|
|
81
|
+
const t = order.order_type;
|
|
82
|
+
if (t === "takeout")
|
|
83
|
+
return "Takeout";
|
|
84
|
+
if (t === "dine_in")
|
|
85
|
+
return "Dine-in";
|
|
86
|
+
if (t === "delivery")
|
|
87
|
+
return "Delivery";
|
|
88
|
+
if (order.payment_mode === "pay_on_delivery")
|
|
89
|
+
return "Pay on Delivery";
|
|
90
|
+
if (order.payment_mode === "prepaid")
|
|
91
|
+
return "Prepaid";
|
|
92
|
+
return "Walk-in";
|
|
93
|
+
}
|
|
94
|
+
function paymentLineText(order) {
|
|
95
|
+
const m = order.pos_payment_method;
|
|
96
|
+
if (m)
|
|
97
|
+
return `Payment: ${m.charAt(0).toUpperCase() + m.slice(1)}`;
|
|
98
|
+
if (order.payment_mode === "pay_on_delivery")
|
|
99
|
+
return "Payment: Cash / Pay on Delivery";
|
|
100
|
+
if (order.payment_mode === "prepaid")
|
|
101
|
+
return "Payment: Prepaid (Online)";
|
|
102
|
+
return "Payment: Walk-in";
|
|
103
|
+
}
|
|
104
|
+
function escHtml(s) {
|
|
105
|
+
if (!s)
|
|
106
|
+
return "";
|
|
107
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
108
|
+
}
|
|
109
|
+
function businessAddress(business) {
|
|
110
|
+
const b = business;
|
|
111
|
+
if (!b)
|
|
112
|
+
return null;
|
|
113
|
+
if (b.location?.address)
|
|
114
|
+
return b.location.address;
|
|
115
|
+
if (b.address)
|
|
116
|
+
return b.address;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// ── ESC/POS ────────────────────────────────────────────────────────────
|
|
120
|
+
function doubleLineEscpos() { return "═".repeat(LINE_WIDTH) + LF; }
|
|
121
|
+
function singleLineEscpos() { return "─".repeat(LINE_WIDTH); }
|
|
122
|
+
function customerLineEscpos(order) {
|
|
123
|
+
if (order.customer_name)
|
|
124
|
+
return `Customer: ${order.customer_name}${LF}`;
|
|
125
|
+
if (order.customer_phone)
|
|
126
|
+
return `Phone: ${order.customer_phone}${LF}`;
|
|
127
|
+
if (order.user?.full_name)
|
|
128
|
+
return `Customer: ${order.user.full_name}${LF}`;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
function orderTypeEscpos(order) { return orderTypeText(order); }
|
|
132
|
+
function paymentLineEscpos(order) { return paymentLineText(order); }
|
|
133
|
+
export function generateEscpos(order) {
|
|
134
|
+
const business = order.business;
|
|
135
|
+
const items = order.items || [];
|
|
136
|
+
const taxLines = (order.order_tax_lines ?? order.tax_lines ?? []);
|
|
137
|
+
const parts = [];
|
|
138
|
+
const push = (s) => { if (s != null)
|
|
139
|
+
parts.push(s); };
|
|
140
|
+
// Header — matches receipt.ex to_escpos/1
|
|
141
|
+
push(ESC_INIT);
|
|
142
|
+
push(ESC_ALIGN_CENTER);
|
|
143
|
+
push(GS_SIZE_DOUBLE_WH);
|
|
144
|
+
push(business?.name || "Teinc Food");
|
|
145
|
+
push(LF);
|
|
146
|
+
push(GS_SIZE_NORMAL);
|
|
147
|
+
const addr = businessAddress(business);
|
|
148
|
+
if (addr)
|
|
149
|
+
push(addr + LF);
|
|
150
|
+
const bPhone = business?.phone;
|
|
151
|
+
if (bPhone)
|
|
152
|
+
push(`Tel: ${bPhone}${LF}`);
|
|
153
|
+
push(GS_SIZE_NORMAL);
|
|
154
|
+
push(doubleLineEscpos());
|
|
155
|
+
// Order info
|
|
156
|
+
push(ESC_ALIGN_LEFT);
|
|
157
|
+
push(`Order: ${order.order_number}${LF}`);
|
|
158
|
+
// callout_box_escpos
|
|
159
|
+
push(ESC_ALIGN_CENTER);
|
|
160
|
+
push(GS_SIZE_DOUBLE_W);
|
|
161
|
+
push(`Callout: ${calloutNumber(order)}${LF}`);
|
|
162
|
+
push(GS_SIZE_NORMAL);
|
|
163
|
+
push(LF);
|
|
164
|
+
push(ESC_ALIGN_LEFT);
|
|
165
|
+
push(`Date: ${formatDatetime(order.inserted_at)}${LF}`);
|
|
166
|
+
push(`Type: ${orderTypeEscpos(order)}${LF}`);
|
|
167
|
+
const custLine = customerLineEscpos(order);
|
|
168
|
+
if (custLine)
|
|
169
|
+
push(custLine);
|
|
170
|
+
push(LF);
|
|
171
|
+
// Barcode — CODE128 GS k 73
|
|
172
|
+
push(ESC_ALIGN_CENTER);
|
|
173
|
+
const data = order.order_number || "";
|
|
174
|
+
push(String.fromCharCode(0x1d, 0x6b, 0x49, data.length) + data);
|
|
175
|
+
push(LF);
|
|
176
|
+
// Items
|
|
177
|
+
push(ESC_ALIGN_LEFT);
|
|
178
|
+
push(`${padR("Item", 16)} Qty Price`);
|
|
179
|
+
push(LF);
|
|
180
|
+
push(singleLineEscpos());
|
|
181
|
+
push(LF);
|
|
182
|
+
for (const item of items) {
|
|
183
|
+
const qty = item.quantity || 1;
|
|
184
|
+
const price = formatMoney((item.unit_price ?? 0) * qty, order);
|
|
185
|
+
push(` ${padR(itemName(item), 16)} ${padR(String(qty), 4)} ${padR(price, 9)}${LF}`);
|
|
186
|
+
const addons = item.order_item_addons ?? item.addons ?? [];
|
|
187
|
+
for (const addon of addons) {
|
|
188
|
+
const aName = addonName(addon);
|
|
189
|
+
const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
|
|
190
|
+
const addonPrice = formatMoney(aPriceMinor, order);
|
|
191
|
+
push(` + ${padR(aName, 12)} ${padR(addonPrice, 9)}${LF}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
push(singleLineEscpos());
|
|
195
|
+
push(LF);
|
|
196
|
+
// Subtotal section — mirrors subtotal_section_escpos/2
|
|
197
|
+
push(`${padR("Subtotal:", 20)} ${formatMoney(order.subtotal_minor, order)}${LF}`);
|
|
198
|
+
if (order.delivery_fee_minor && order.delivery_fee_minor > 0) {
|
|
199
|
+
push(`${padR("Delivery Fee:", 20)} ${formatMoney(order.delivery_fee_minor, order)}${LF}`);
|
|
200
|
+
}
|
|
201
|
+
for (const tax of taxLines) {
|
|
202
|
+
const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
|
|
203
|
+
const label = `${tax.name || ""} ${rateStr}`.trim().slice(0, 19);
|
|
204
|
+
push(`${padR(label, 20)} ${formatMoney(tax.tax_amount_minor, order)}${LF}`);
|
|
205
|
+
}
|
|
206
|
+
if (order.discount_minor && order.discount_minor > 0) {
|
|
207
|
+
push(`${padR("Discount:", 20)} -${formatMoney(order.discount_minor, order)}${LF}`);
|
|
208
|
+
}
|
|
209
|
+
push(ESC_BOLD_ON);
|
|
210
|
+
push(`${padR("TOTAL:", 20)} ${formatMoney(order.total_minor, order)}${LF}`);
|
|
211
|
+
push(ESC_BOLD_OFF);
|
|
212
|
+
push(LF);
|
|
213
|
+
push(ESC_ALIGN_CENTER);
|
|
214
|
+
push(paymentLineEscpos(order));
|
|
215
|
+
push(LF);
|
|
216
|
+
// Footer
|
|
217
|
+
push(doubleLineEscpos());
|
|
218
|
+
push("Thank you for your order!" + LF);
|
|
219
|
+
push("Come back soon!" + LF);
|
|
220
|
+
push(ESC_FEED_3);
|
|
221
|
+
push(GS_CUT_FULL);
|
|
222
|
+
return parts.filter((p) => p != null).join("");
|
|
223
|
+
}
|
|
224
|
+
// ── HTML ───────────────────────────────────────────────────────────────
|
|
225
|
+
export function generateHtml(order, logoDataUri) {
|
|
226
|
+
const business = order.business;
|
|
227
|
+
const items = order.items || [];
|
|
228
|
+
const taxLines = ((order.order_tax_lines) ?? order.tax_lines ?? []);
|
|
229
|
+
const callout = calloutNumber(order);
|
|
230
|
+
const logoSrc = logoDataUri || business?.logo_url || "";
|
|
231
|
+
const hasLogo = !!logoSrc;
|
|
232
|
+
const businessName = escHtml(business?.name || "Teinc Food");
|
|
233
|
+
const addr = businessAddress(business);
|
|
234
|
+
const bPhone = business?.phone;
|
|
235
|
+
const itemRows = items.map((item) => {
|
|
236
|
+
const qty = item.quantity || 1;
|
|
237
|
+
// unit_price is already minor? backend format_money expects minor
|
|
238
|
+
const price = formatMoney((item.unit_price ?? 0) * qty, order);
|
|
239
|
+
const main = `<tr><td class="item-name">${escHtml(itemName(item))}</td><td class="item-qty">${qty}</td><td class="item-price">${price}</td></tr>`;
|
|
240
|
+
const addons = (item.order_item_addons ?? item.addons ?? []);
|
|
241
|
+
const addonRows = addons.map((addon) => {
|
|
242
|
+
const aName = escHtml(addonName(addon));
|
|
243
|
+
const aPriceMinor = addon.price_minor ?? addon.price ?? 0;
|
|
244
|
+
return `<tr class="addon-row"><td class="item-name">+ ${aName}</td><td class="item-qty"></td><td class="addon-price">${formatMoney(aPriceMinor, order)}</td></tr>`;
|
|
245
|
+
}).join("\n");
|
|
246
|
+
return [main, addonRows].filter(Boolean).join("\n");
|
|
247
|
+
}).join("\n");
|
|
248
|
+
const taxRowsHtml = taxLines.map((tax) => {
|
|
249
|
+
const rateStr = tax.rate_basis_points ? `(${Math.floor(tax.rate_basis_points / 100)}%)` : "";
|
|
250
|
+
return `<tr><td class="label">${escHtml(tax.name || "")} ${escHtml(rateStr)}</td><td class="amount">${formatMoney(tax.tax_amount_minor, order)}</td></tr>`;
|
|
251
|
+
}).join("\n");
|
|
252
|
+
// Exact style from receipt.ex to_html/2
|
|
253
|
+
return `<!DOCTYPE html>
|
|
254
|
+
<html>
|
|
255
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
256
|
+
<style>
|
|
257
|
+
body{font-family:monospace;max-width:320px;margin:0 auto;padding:16px;background:#fff;color:#111;}
|
|
258
|
+
.header{text-align:center;border-top:2px solid #333;border-bottom:2px solid #333;padding:8px 0;margin-bottom:12px;}
|
|
259
|
+
.header .logo{max-width:180px;max-height:60px;margin:4px auto;display:block;object-fit:contain;}
|
|
260
|
+
.header h2{margin:4px 0 0;font-size:14px;}
|
|
261
|
+
.header .address{font-size:11px;color:#555;margin:2px 0;}
|
|
262
|
+
.callout{border:3px double #333;text-align:center;padding:10px;margin:10px 0;}
|
|
263
|
+
.callout .number{font-size:36px;font-weight:bold;letter-spacing:4px;margin:0;}
|
|
264
|
+
.callout .label{font-size:10px;color:#555;margin:0;}
|
|
265
|
+
.order-info{font-size:12px;margin:8px 0;}
|
|
266
|
+
table{width:100%;font-size:12px;border-collapse:collapse;}
|
|
267
|
+
td{padding:2px 0;vertical-align:top;}
|
|
268
|
+
.item-name{width:50%;}
|
|
269
|
+
.item-qty{width:15%;text-align:center;}
|
|
270
|
+
.item-price{width:35%;text-align:right;}
|
|
271
|
+
.addon-row td{font-size:11px;color:#666;padding-left:12px;}
|
|
272
|
+
.addon-row td.addon-price{text-align:right;}
|
|
273
|
+
.totals{margin-top:8px;}
|
|
274
|
+
.totals td.label{text-align:left;width:70%;}
|
|
275
|
+
.totals td.amount{text-align:right;width:30%;}
|
|
276
|
+
.total-row{font-weight:bold;font-size:15px;border-top:2px solid #333;border-bottom:2px solid #333;padding:4px 0;}
|
|
277
|
+
.footer{text-align:center;margin-top:16px;padding-top:8px;font-size:12px;color:#555;}
|
|
278
|
+
@media print{body{max-width:80mm;padding:0;}}
|
|
279
|
+
</style></head>
|
|
280
|
+
<body>
|
|
281
|
+
<div class="header">
|
|
282
|
+
${hasLogo ? `<img class="logo" src="${escHtml(logoSrc)}" alt="${businessName}" />` : ""}
|
|
283
|
+
<h2>${businessName}</h2>
|
|
284
|
+
<div class="address">${escHtml(addr || "")}</div>
|
|
285
|
+
${bPhone ? `<div class="address">Tel: ${escHtml(bPhone)}</div>` : ""}
|
|
286
|
+
</div>
|
|
287
|
+
<div class="order-info">
|
|
288
|
+
<strong>Order:</strong> ${escHtml(order.order_number)}<br>
|
|
289
|
+
<strong>Date:</strong> ${escHtml(formatDatetime(order.inserted_at))}<br>
|
|
290
|
+
<strong>Type:</strong> ${escHtml(orderTypeText(order))}<br>
|
|
291
|
+
${order.customer_name ? `<strong>Customer:</strong> ${escHtml(order.customer_name)}<br>` : order.user?.full_name ? `<strong>Customer:</strong> ${escHtml(order.user.full_name)}<br>` : ""}
|
|
292
|
+
</div>
|
|
293
|
+
<div class="callout">
|
|
294
|
+
<div class="number">${escHtml(callout)}</div>
|
|
295
|
+
<div class="label">CALL OUT NUMBER</div>
|
|
296
|
+
</div>
|
|
297
|
+
<table>
|
|
298
|
+
<tr style="border-bottom:1px solid #ccc;"><td class="item-name"><strong>Item</strong></td><td class="item-qty"><strong>Qty</strong></td><td class="item-price"><strong>Price</strong></td></tr>
|
|
299
|
+
${itemRows}
|
|
300
|
+
</table>
|
|
301
|
+
<div class="totals">
|
|
302
|
+
<table>
|
|
303
|
+
<tr><td class="label">Subtotal</td><td class="amount">${formatMoney(order.subtotal_minor, order)}</td></tr>
|
|
304
|
+
${order.delivery_fee_minor ? `<tr><td class="label">Delivery Fee</td><td class="amount">${formatMoney(order.delivery_fee_minor, order)}</td></tr>` : ""}
|
|
305
|
+
${taxRowsHtml}
|
|
306
|
+
${order.discount_minor ? `<tr><td class="label">Discount</td><td class="amount">-${formatMoney(order.discount_minor, order)}</td></tr>` : ""}
|
|
307
|
+
<tr class="total-row"><td class="label">TOTAL</td><td class="amount">${formatMoney(order.total_minor, order)}</td></tr>
|
|
308
|
+
<tr><td colspan="2" style="text-align:center;padding-top:8px;">${escHtml(paymentLineText(order))}</td></tr>
|
|
309
|
+
</table>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="footer">
|
|
312
|
+
Thank you for your order!<br>Come back soon!
|
|
313
|
+
</div>
|
|
314
|
+
</body></html>`;
|
|
315
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Printer service — fetch receipt from backend when online,
|
|
3
|
+
* otherwise generate locally from SellerOrder snapshot (backend-identical template).
|
|
4
|
+
* Sends ESC/POS via TcpSocketAdapter, falls back to PrintFileAdapter.
|
|
5
|
+
*/
|
|
6
|
+
import { generateEscpos, generateHtml } from "./receipt";
|
|
7
|
+
import type { SellerOrder } from "../types/api/seller.api";
|
|
8
|
+
declare function fetchReceipt(orderId: string, format?: "html" | "escpos"): Promise<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateLocalReceipt(order: SellerOrder, format?: "html" | "escpos", logoDataUri?: string | null): string;
|
|
13
|
+
/**
|
|
14
|
+
* Print an order — tries TCP ESC/POS if a default printer is configured, otherwise
|
|
15
|
+
* generates HTML and uses the PrintFileAdapter (expo-print / Tauri print).
|
|
16
|
+
* `order` must be a full SellerOrder snapshot; for online orders we attempt to
|
|
17
|
+
* fetch the authoritative receipt first so the business logo/address are server-rendered.
|
|
18
|
+
*/
|
|
19
|
+
export declare function printOrderReceipt(order: SellerOrder, opts?: {
|
|
20
|
+
logoDataUri?: string | null;
|
|
21
|
+
}): Promise<void>;
|
|
22
|
+
export declare function sendToTcpPrinter(ip: string, port: number, text: string): Promise<void>;
|
|
23
|
+
export declare const printerService: {
|
|
24
|
+
fetchReceipt: typeof fetchReceipt;
|
|
25
|
+
generateLocalReceipt: typeof generateLocalReceipt;
|
|
26
|
+
printOrderReceipt: typeof printOrderReceipt;
|
|
27
|
+
sendToTcpPrinter: typeof sendToTcpPrinter;
|
|
28
|
+
generateEscpos: typeof generateEscpos;
|
|
29
|
+
generateHtml: typeof generateHtml;
|
|
30
|
+
};
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Printer service — fetch receipt from backend when online,
|
|
3
|
+
* otherwise generate locally from SellerOrder snapshot (backend-identical template).
|
|
4
|
+
* Sends ESC/POS via TcpSocketAdapter, falls back to PrintFileAdapter.
|
|
5
|
+
*/
|
|
6
|
+
import { getHttpClient } from "../adapters/http";
|
|
7
|
+
import { getTcpAdapter, getPrintFileAdapter } from "../adapters/print";
|
|
8
|
+
import { getPrinterState } from "./store";
|
|
9
|
+
import { generateEscpos, generateHtml } from "./receipt";
|
|
10
|
+
async function fetchReceipt(orderId, format = "html") {
|
|
11
|
+
const http = getHttpClient();
|
|
12
|
+
// http adapter wraps axios; fallback to fetch if not set
|
|
13
|
+
if (http) {
|
|
14
|
+
const res = await http.get(`/orders/${orderId}/receipt?format=${format}`, {
|
|
15
|
+
headers: { Accept: format === "html" ? "text/html" : "text/plain" },
|
|
16
|
+
});
|
|
17
|
+
// http.get may return { data: string } or raw string; handle both
|
|
18
|
+
const data = res.data ?? res;
|
|
19
|
+
if (typeof data === "string")
|
|
20
|
+
return data;
|
|
21
|
+
return String(data);
|
|
22
|
+
}
|
|
23
|
+
// Fallback fetch using API_BASE_URL already injected
|
|
24
|
+
const { API_BASE_URL } = await import("../utils/constants");
|
|
25
|
+
const url = `${API_BASE_URL}/orders/${orderId}/receipt?format=${format}`;
|
|
26
|
+
const resp = await fetch(url, { headers: { Accept: format === "html" ? "text/html" : "text/plain" } });
|
|
27
|
+
if (!resp.ok) {
|
|
28
|
+
const body = await resp.text().catch(() => "");
|
|
29
|
+
throw new Error(`Failed to fetch receipt (${resp.status}): ${body.slice(0, 200)}`);
|
|
30
|
+
}
|
|
31
|
+
return resp.text();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Generate receipt locally without network — used for offline queued DD-RRR-CCC orders.
|
|
35
|
+
*/
|
|
36
|
+
export function generateLocalReceipt(order, format = "escpos", logoDataUri) {
|
|
37
|
+
return format === "escpos" ? generateEscpos(order) : generateHtml(order, logoDataUri);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Print an order — tries TCP ESC/POS if a default printer is configured, otherwise
|
|
41
|
+
* generates HTML and uses the PrintFileAdapter (expo-print / Tauri print).
|
|
42
|
+
* `order` must be a full SellerOrder snapshot; for online orders we attempt to
|
|
43
|
+
* fetch the authoritative receipt first so the business logo/address are server-rendered.
|
|
44
|
+
*/
|
|
45
|
+
export async function printOrderReceipt(order, opts) {
|
|
46
|
+
const { autoPrint, printers, defaultPrinterId } = getPrinterState();
|
|
47
|
+
const needsTcp = !!defaultPrinterId && autoPrint !== false;
|
|
48
|
+
const printer = needsTcp ? printers.find((p) => p.id === defaultPrinterId) : null;
|
|
49
|
+
const isTcp = !!(printer && printer.type === "tcp");
|
|
50
|
+
// Try authoritative backend receipt first when online (includes logo data URI)
|
|
51
|
+
// For offline/queued orders we generate locally.
|
|
52
|
+
let escpos = null;
|
|
53
|
+
let html = null;
|
|
54
|
+
if (isTcp) {
|
|
55
|
+
try {
|
|
56
|
+
// Prefer backend escpos when we have a real UUID (not DD-RRR-CCC)
|
|
57
|
+
const isLocal = /^[0-9]{2}-[0-9]{3}-[0-9]{3}$/.test(order.order_number) || /^[0-9A-F-]{36}$/.test(order.id) === false && order.id === order.order_number;
|
|
58
|
+
if (!isLocal || order.id) {
|
|
59
|
+
try {
|
|
60
|
+
escpos = await fetchReceipt(order.id, "escpos");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// fall back to local
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!escpos)
|
|
67
|
+
escpos = generateLocalReceipt(order, "escpos", opts?.logoDataUri ?? null);
|
|
68
|
+
const tcp = getTcpAdapter();
|
|
69
|
+
if (!tcp)
|
|
70
|
+
throw new Error("TCP adapter not configured");
|
|
71
|
+
await tcp.sendToTcpPrinter(printer.ip, printer.port, escpos);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
console.debug("[print] TCP print failed, falling back to HTML", e);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Fallback: HTML via PrintFileAdapter or local share
|
|
79
|
+
try {
|
|
80
|
+
try {
|
|
81
|
+
html = await fetchReceipt(order.id, "html");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// offline
|
|
85
|
+
}
|
|
86
|
+
if (!html)
|
|
87
|
+
html = generateLocalReceipt(order, "html", opts?.logoDataUri ?? null);
|
|
88
|
+
const printAdapter = getPrintFileAdapter();
|
|
89
|
+
if (printAdapter) {
|
|
90
|
+
const { uri } = await printAdapter.printToFileAsync(html);
|
|
91
|
+
if (printAdapter.isSharingAvailable && printAdapter.shareAsync) {
|
|
92
|
+
const available = await printAdapter.isSharingAvailable();
|
|
93
|
+
if (available)
|
|
94
|
+
await printAdapter.shareAsync(uri, { dialogTitle: "Print Receipt", mimeType: "application/pdf" });
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// No adapter — throw so host can show preview fallback
|
|
99
|
+
throw new Error("PrintFileAdapter not configured — use generateLocalReceipt + WebView preview");
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
console.debug("[print] HTML print failed", e);
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export async function sendToTcpPrinter(ip, port, text) {
|
|
107
|
+
const tcp = getTcpAdapter();
|
|
108
|
+
if (!tcp)
|
|
109
|
+
throw new Error("TCP socket not available");
|
|
110
|
+
return tcp.sendToTcpPrinter(ip, port, text);
|
|
111
|
+
}
|
|
112
|
+
export const printerService = {
|
|
113
|
+
fetchReceipt,
|
|
114
|
+
generateLocalReceipt,
|
|
115
|
+
printOrderReceipt,
|
|
116
|
+
sendToTcpPrinter,
|
|
117
|
+
generateEscpos,
|
|
118
|
+
generateHtml,
|
|
119
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Printer store — local device config, persisted via KV driver.
|
|
3
|
+
* Business app invariant: printers are device-local, never cleared on logout.
|
|
4
|
+
*/
|
|
5
|
+
import type { PrinterConfig, PrinterState } from "./types";
|
|
6
|
+
export declare function hydratePrinterStore(): Promise<void>;
|
|
7
|
+
export declare function getPrinterState(): PrinterState;
|
|
8
|
+
export declare function subscribePrinter(listener: () => void): () => void;
|
|
9
|
+
export declare function getPrinterSnapshot(): PrinterState;
|
|
10
|
+
export declare function setDefaultPrinter(id: string | null): void;
|
|
11
|
+
export declare function addPrinter(printer: PrinterConfig): void;
|
|
12
|
+
export declare function updatePrinter(id: string, updates: Partial<PrinterConfig>): void;
|
|
13
|
+
export declare function removePrinter(id: string): void;
|
|
14
|
+
export declare function setAutoPrint(enabled: boolean): void;
|
|
15
|
+
export declare const printerStore: {
|
|
16
|
+
getState: typeof getPrinterState;
|
|
17
|
+
subscribe: typeof subscribePrinter;
|
|
18
|
+
hydrate: typeof hydratePrinterStore;
|
|
19
|
+
setDefaultPrinter: typeof setDefaultPrinter;
|
|
20
|
+
addPrinter: typeof addPrinter;
|
|
21
|
+
updatePrinter: typeof updatePrinter;
|
|
22
|
+
removePrinter: typeof removePrinter;
|
|
23
|
+
setAutoPrint: typeof setAutoPrint;
|
|
24
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Printer store — local device config, persisted via KV driver.
|
|
3
|
+
* Business app invariant: printers are device-local, never cleared on logout.
|
|
4
|
+
*/
|
|
5
|
+
import { getKVDriver } from "../adapters/kv";
|
|
6
|
+
import { STORAGE_KEYS } from "../utils/constants";
|
|
7
|
+
const listeners = new Set();
|
|
8
|
+
let state = {
|
|
9
|
+
defaultPrinterId: null,
|
|
10
|
+
printers: [],
|
|
11
|
+
autoPrint: false,
|
|
12
|
+
};
|
|
13
|
+
let hydrated = false;
|
|
14
|
+
function notify() {
|
|
15
|
+
listeners.forEach((l) => l());
|
|
16
|
+
}
|
|
17
|
+
async function persist() {
|
|
18
|
+
try {
|
|
19
|
+
const kv = getKVDriver();
|
|
20
|
+
await kv.setItem(STORAGE_KEYS.PRINTER_SETTINGS, JSON.stringify(state));
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
}
|
|
24
|
+
export async function hydratePrinterStore() {
|
|
25
|
+
if (hydrated)
|
|
26
|
+
return;
|
|
27
|
+
hydrated = true;
|
|
28
|
+
try {
|
|
29
|
+
const kv = getKVDriver();
|
|
30
|
+
const raw = await kv.getItem(STORAGE_KEYS.PRINTER_SETTINGS);
|
|
31
|
+
if (raw) {
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
state = {
|
|
34
|
+
defaultPrinterId: parsed.defaultPrinterId ?? null,
|
|
35
|
+
printers: Array.isArray(parsed.printers) ? parsed.printers : [],
|
|
36
|
+
autoPrint: !!parsed.autoPrint,
|
|
37
|
+
};
|
|
38
|
+
notify();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch { }
|
|
42
|
+
}
|
|
43
|
+
export function getPrinterState() {
|
|
44
|
+
return state;
|
|
45
|
+
}
|
|
46
|
+
export function subscribePrinter(listener) {
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
return () => listeners.delete(listener);
|
|
49
|
+
}
|
|
50
|
+
export function getPrinterSnapshot() {
|
|
51
|
+
return state;
|
|
52
|
+
}
|
|
53
|
+
export function setDefaultPrinter(id) {
|
|
54
|
+
state = { ...state, defaultPrinterId: id };
|
|
55
|
+
notify();
|
|
56
|
+
void persist();
|
|
57
|
+
}
|
|
58
|
+
export function addPrinter(printer) {
|
|
59
|
+
state = { ...state, printers: [...state.printers, printer] };
|
|
60
|
+
notify();
|
|
61
|
+
void persist();
|
|
62
|
+
}
|
|
63
|
+
export function updatePrinter(id, updates) {
|
|
64
|
+
state = {
|
|
65
|
+
...state,
|
|
66
|
+
printers: state.printers.map((p) => (p.id === id ? { ...p, ...updates } : p)),
|
|
67
|
+
};
|
|
68
|
+
notify();
|
|
69
|
+
void persist();
|
|
70
|
+
}
|
|
71
|
+
export function removePrinter(id) {
|
|
72
|
+
state = {
|
|
73
|
+
...state,
|
|
74
|
+
printers: state.printers.filter((p) => p.id !== id),
|
|
75
|
+
defaultPrinterId: state.defaultPrinterId === id ? null : state.defaultPrinterId,
|
|
76
|
+
};
|
|
77
|
+
notify();
|
|
78
|
+
void persist();
|
|
79
|
+
}
|
|
80
|
+
export function setAutoPrint(enabled) {
|
|
81
|
+
state = { ...state, autoPrint: enabled };
|
|
82
|
+
notify();
|
|
83
|
+
void persist();
|
|
84
|
+
}
|
|
85
|
+
export const printerStore = {
|
|
86
|
+
getState: getPrinterState,
|
|
87
|
+
subscribe: subscribePrinter,
|
|
88
|
+
hydrate: hydratePrinterStore,
|
|
89
|
+
setDefaultPrinter,
|
|
90
|
+
addPrinter,
|
|
91
|
+
updatePrinter,
|
|
92
|
+
removePrinter,
|
|
93
|
+
setAutoPrint,
|
|
94
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -13,7 +13,7 @@ import { insertCommand, getPendingCommands, markCommandInFlight, updateCommandSt
|
|
|
13
13
|
import { syncLogger } from "../utils/sync-logger";
|
|
14
14
|
import { generateEventId } from "../utils/uuid";
|
|
15
15
|
import { generateLocalCalloutNumber, generateOrderNumberForBusiness, } from "./order-number";
|
|
16
|
-
import { subscribeConnectivity } from "../adapters/connectivity";
|
|
16
|
+
import { getConnectivityState, subscribeConnectivity } from "../adapters/connectivity";
|
|
17
17
|
const MAX_RETRIES = 5;
|
|
18
18
|
const QUEUED_STATUS_BY_COMMAND = {
|
|
19
19
|
"order:accept": "accepted",
|
|
@@ -65,6 +65,10 @@ function buildOptimisticEvent(command) {
|
|
|
65
65
|
function buildOptimisticOrder(command, payload) {
|
|
66
66
|
const items = payload.items ?? [];
|
|
67
67
|
const totalMinor = payload.total_minor ?? 0;
|
|
68
|
+
const subtotalMinor = payload.subtotal_minor ?? totalMinor;
|
|
69
|
+
const taxMinor = payload.tax_minor ?? 0;
|
|
70
|
+
const taxLines = payload.tax_lines ?? [];
|
|
71
|
+
const currencyCode = payload.currency_code ?? "GHS";
|
|
68
72
|
const now = new Date().toISOString();
|
|
69
73
|
const orderNumber = payload.order_number ?? generateOrderNumberForBusiness(command.business_id);
|
|
70
74
|
return {
|
|
@@ -85,20 +89,20 @@ function buildOptimisticOrder(command, payload) {
|
|
|
85
89
|
is_paid: payload.pos_payment_method === "cash",
|
|
86
90
|
reference: null,
|
|
87
91
|
amount_minor: totalMinor,
|
|
88
|
-
currency:
|
|
92
|
+
currency: currencyCode,
|
|
89
93
|
paid_at: payload.pos_payment_method === "cash" ? now : null,
|
|
90
94
|
},
|
|
91
95
|
payment_mode: "pos_cash",
|
|
92
96
|
total_minor: totalMinor,
|
|
93
|
-
subtotal_minor:
|
|
94
|
-
delivery_fee_minor: 0,
|
|
95
|
-
tax_minor:
|
|
96
|
-
tip_minor: null,
|
|
97
|
-
discount_minor: 0,
|
|
98
|
-
promo_code: null,
|
|
99
|
-
tax_lines:
|
|
100
|
-
currency:
|
|
101
|
-
currency_code:
|
|
97
|
+
subtotal_minor: subtotalMinor,
|
|
98
|
+
delivery_fee_minor: payload.delivery_fee_minor ?? 0,
|
|
99
|
+
tax_minor: taxMinor,
|
|
100
|
+
tip_minor: payload.tip_minor ?? null,
|
|
101
|
+
discount_minor: payload.discount_minor ?? 0,
|
|
102
|
+
promo_code: payload.promo_code ?? null,
|
|
103
|
+
tax_lines: taxLines,
|
|
104
|
+
currency: currencyCode,
|
|
105
|
+
currency_code: currencyCode,
|
|
102
106
|
business_id: command.business_id,
|
|
103
107
|
business: null,
|
|
104
108
|
delivery_address: null,
|
|
@@ -147,7 +151,8 @@ class CommandQueueService {
|
|
|
147
151
|
// timeout-induced `unreachable` flip, then the next successful probe sets
|
|
148
152
|
// `reachable`). No polling — event-driven via connectivity state.
|
|
149
153
|
try {
|
|
150
|
-
subscribeConnectivity((
|
|
154
|
+
subscribeConnectivity(() => {
|
|
155
|
+
const s = getConnectivityState();
|
|
151
156
|
if (s.isCloudReachable === "reachable" && s.isOnline) {
|
|
152
157
|
this.replay().catch(() => { });
|
|
153
158
|
}
|
package/package.json
CHANGED