832 lines
34 KiB
Markdown
832 lines
34 KiB
Markdown
|
|
# Network (Ethernet) Thermal Printer Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Add support for printing sale receipts and delivery notes to a network (Ethernet) Epson TM-T20II printer from the `public/pos` PWA, alongside the existing Web Bluetooth path, working on both Android and iOS.
|
||
|
|
|
||
|
|
**Architecture:** The printer exposes Epson's ePOS-Print XML API over **HTTPS** (confirmed live against the shop's TM-T20II at `192.168.1.33` — see verification notes below), with an open CORS header (`Access-Control-Allow-Origin: *`). Because both the PWA and the printer are HTTPS, calling the printer directly from the browser hits neither mixed-content blocking nor Safari's lack of Web Bluetooth support — no native wrapper is needed. A new `public/pos/epos-print.js` module builds the ePOS-Print XML (mirroring the existing `buildEscPos()` byte-builder in `receipt.html`) and POSTs it with `fetch()`. `receipt.html` gains a printer-type setting (Bluetooth vs. Network) persisted in `localStorage`, and `printThermal()`/`printDelivery()` branch on it.
|
||
|
|
|
||
|
|
**Tech Stack:** Vanilla JS (no build step — this is a static PWA), `fetch`, `<canvas>` for logo rasterization, Epson ePOS-Print XML.
|
||
|
|
|
||
|
|
**Verified findings this plan relies on (from live testing against the shop printer):**
|
||
|
|
- `http://192.168.1.33/` 301-redirects to `https://192.168.1.33/` (self-signed cert).
|
||
|
|
- `POST https://192.168.1.33/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000` with an ePOS-Print SOAP XML body returns `HTTP 200` and `<response success="true" .../>`, and a real slip printed with text, item list, subtotal/VAT/total, and the WFDASH logo image.
|
||
|
|
- Raw ESC/POS bytes also print successfully over plain TCP to port 9100 (confirms the byte-building logic already in `buildEscPos()` is sound, though this plan uses the HTTPS path since browsers can't open raw sockets).
|
||
|
|
- The response's `success="true"` was observed once without a physical printout (cause not fully isolated — possibly a transient state on the printer). Task 10 below re-verifies each XML feature individually against the real printer before calling this done.
|
||
|
|
- Each till device (iOS or Android) must visit `https://192.168.1.33` once in its browser and accept the self-signed certificate warning before `fetch()` calls from the PWA will succeed silently.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: Fix the pre-existing `const d` redeclaration bug
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `public/pos/receipt.html:535-591` (the `buildEscPos` function)
|
||
|
|
|
||
|
|
This is a blocking bug unrelated to network printing, but it sits inside the exact function this plan extends, and it currently breaks the entire inline `<script>` block (lines 319-657) — meaning `renderReceipt`, `printThermal`, and everything else on this page silently fails to run today.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Confirm the bug**
|
||
|
|
|
||
|
|
Run: `grep -n "const d" public/pos/receipt.html`
|
||
|
|
Expected output includes both:
|
||
|
|
```
|
||
|
|
552: const d = new Date(s.createdAt);
|
||
|
|
565: const d = s.discount || { kind: 'none', input: 0, amount: 0 };
|
||
|
|
```
|
||
|
|
Both are in the same function scope (`buildEscPos`, no intervening block) — this is a `SyntaxError: Identifier 'd' has already been declared`.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Rename the second declaration**
|
||
|
|
|
||
|
|
In `public/pos/receipt.html`, inside `buildEscPos(s){...}`, change:
|
||
|
|
|
||
|
|
```js
|
||
|
|
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
|
||
|
|
const vat = s.vat != null ? s.vat : (s.total * 15/115);
|
||
|
|
const d = s.discount || { kind: 'none', input: 0, amount: 0 };
|
||
|
|
const hasDiscount = d.kind !== 'none' && Number(d.amount) > 0;
|
||
|
|
if (hasDiscount) {
|
||
|
|
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
|
||
|
|
const grossSub = gross / 1.15;
|
||
|
|
const grossVat = gross - grossSub;
|
||
|
|
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
|
||
|
|
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
|
||
|
|
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
|
||
|
|
cmds.push(`Discount${d.kind==='percent' ? ` (${d.input}%)` : ''}${' '.repeat(15)}- R ${d.amount.toFixed(2)}\n`);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
to:
|
||
|
|
|
||
|
|
```js
|
||
|
|
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
|
||
|
|
const vat = s.vat != null ? s.vat : (s.total * 15/115);
|
||
|
|
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
|
||
|
|
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
|
||
|
|
if (hasDiscount) {
|
||
|
|
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
|
||
|
|
const grossSub = gross / 1.15;
|
||
|
|
const grossVat = gross - grossSub;
|
||
|
|
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
|
||
|
|
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
|
||
|
|
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
|
||
|
|
cmds.push(`Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify the script now parses**
|
||
|
|
|
||
|
|
Run: `node --check <(sed -n '319,657p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
|
||
|
|
|
||
|
|
(This extracts just the inline script body and checks it as a standalone JS file.) Expected: no output, exit code 0. If Node reports a different syntax error, fix it before continuing — do not proceed to Task 2 with a script that doesn't parse.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/receipt.html
|
||
|
|
git commit -m "fix(pos): resolve duplicate const d declaration breaking receipt.html script"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: Create `epos-print.js` with printer settings persistence
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `public/pos/epos-print.js`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the file with settings get/set**
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Network (ePOS-Print) thermal printing for Epson TM-series Ethernet
|
||
|
|
// printers. Runs alongside the existing Web Bluetooth path in
|
||
|
|
// receipt.html — the till picks a transport in Printer settings.
|
||
|
|
//
|
||
|
|
// The printer's ePOS-Print endpoint is served over HTTPS with a
|
||
|
|
// self-signed cert and open CORS (`Access-Control-Allow-Origin: *`),
|
||
|
|
// confirmed by direct testing against the shop's TM-T20II. Each till
|
||
|
|
// device must visit `https://<printer-ip>` once in its browser and
|
||
|
|
// accept the certificate warning before fetch() calls here succeed.
|
||
|
|
window.EposPrint = (function () {
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
const SETTINGS_KEY = 'pos.printerSettings';
|
||
|
|
|
||
|
|
function getSettings() {
|
||
|
|
try {
|
||
|
|
return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || { type: 'bluetooth', ip: '' };
|
||
|
|
} catch (e) {
|
||
|
|
return { type: 'bluetooth', ip: '' };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function setSettings(settings) {
|
||
|
|
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||
|
|
}
|
||
|
|
|
||
|
|
return { getSettings, setSettings };
|
||
|
|
})();
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify it loads without error**
|
||
|
|
|
||
|
|
Run: `node --check public/pos/epos-print.js`
|
||
|
|
Expected: no output, exit code 0.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/epos-print.js
|
||
|
|
git commit -m "feat(pos): add printer settings persistence for network printing"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: Add logo rasterization to `epos-print.js`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `public/pos/epos-print.js`
|
||
|
|
|
||
|
|
The ePOS-Print `<image>` tag needs a packed 1-bit-per-pixel bitmap, MSB-first, each row padded to a whole byte, where bit `1` means "print a dot". This matches the format manually verified against the real printer during testing (Python/Pillow prototype), reimplemented here with `<canvas>` since the PWA has no build step to run Python.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add `imageToRaster` inside the IIFE, before the `return` statement**
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Rasterize a same-origin image into the packed 1bpp bitmap the
|
||
|
|
// ePOS-Print <image> tag expects. targetWidth must be a multiple of
|
||
|
|
// 8; 384 is the width verified against the shop's TM-T20II.
|
||
|
|
function imageToRaster(url, targetWidth) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const img = new Image();
|
||
|
|
img.onload = () => {
|
||
|
|
const targetHeight = Math.round(img.height * targetWidth / img.width);
|
||
|
|
const canvas = document.createElement('canvas');
|
||
|
|
canvas.width = targetWidth;
|
||
|
|
canvas.height = targetHeight;
|
||
|
|
const ctx = canvas.getContext('2d');
|
||
|
|
ctx.fillStyle = '#fff';
|
||
|
|
ctx.fillRect(0, 0, targetWidth, targetHeight);
|
||
|
|
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
||
|
|
let imageData;
|
||
|
|
try {
|
||
|
|
imageData = ctx.getImageData(0, 0, targetWidth, targetHeight);
|
||
|
|
} catch (e) {
|
||
|
|
reject(new Error('Could not read logo pixels (cross-origin image?): ' + e.message));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const data = imageData.data;
|
||
|
|
const rowBytes = Math.ceil(targetWidth / 8);
|
||
|
|
const packed = new Uint8Array(rowBytes * targetHeight);
|
||
|
|
for (let y = 0; y < targetHeight; y++) {
|
||
|
|
for (let x = 0; x < targetWidth; x++) {
|
||
|
|
const i = (y * targetWidth + x) * 4;
|
||
|
|
const luminance = data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11;
|
||
|
|
const alpha = data[i + 3];
|
||
|
|
if (alpha > 128 && luminance < 128) {
|
||
|
|
packed[y * rowBytes + (x >> 3)] |= 0x80 >> (x % 8);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let binary = '';
|
||
|
|
for (let i = 0; i < packed.length; i++) binary += String.fromCharCode(packed[i]);
|
||
|
|
resolve({ width: targetWidth, height: targetHeight, base64: btoa(binary) });
|
||
|
|
};
|
||
|
|
img.onerror = () => reject(new Error('Could not load logo image: ' + url));
|
||
|
|
img.src = url;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Export it from the module**
|
||
|
|
|
||
|
|
Change the `return` statement at the end of the IIFE from:
|
||
|
|
|
||
|
|
```js
|
||
|
|
return { getSettings, setSettings };
|
||
|
|
```
|
||
|
|
|
||
|
|
to:
|
||
|
|
|
||
|
|
```js
|
||
|
|
return { getSettings, setSettings, imageToRaster };
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify it loads without error**
|
||
|
|
|
||
|
|
Run: `node --check public/pos/epos-print.js`
|
||
|
|
Expected: no output, exit code 0.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Manual browser check**
|
||
|
|
|
||
|
|
Serve `public/` locally (e.g. `npx serve public` or via the app's own dev server) and open `public/pos/index.html`'s console, then run:
|
||
|
|
|
||
|
|
```js
|
||
|
|
EposPrint.imageToRaster('/wfdash-logo.png', 384).then(r => console.log(r.width, r.height, r.base64.length))
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: logs `384 <some-height> <some-length>` with no thrown error.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/epos-print.js
|
||
|
|
git commit -m "feat(pos): add canvas-based logo rasterization for ePOS-Print"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: Add ePOS-Print XML builder for sale receipts
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `public/pos/epos-print.js`
|
||
|
|
|
||
|
|
This mirrors `buildEscPos()` in `receipt.html:535-591` line-for-line in content (same fields, same discount handling, same layout), just emitting ePOS-Print XML tags instead of raw ESC/POS bytes.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add `xmlEscape` and `buildEposXml` inside the IIFE, before the `return` statement**
|
||
|
|
|
||
|
|
```js
|
||
|
|
function xmlEscape(s) {
|
||
|
|
return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mirrors buildEscPos() in receipt.html — same fields, same discount
|
||
|
|
// math — but emits ePOS-Print XML tags instead of raw ESC/POS bytes.
|
||
|
|
function buildEposXml(s, logo) {
|
||
|
|
const parts = [];
|
||
|
|
if (logo) parts.push(`<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>`);
|
||
|
|
parts.push('<feed line="1"/>');
|
||
|
|
parts.push('<text align="center" width="2" height="2">WFSALES POS </text>');
|
||
|
|
if (s.letterhead && s.letterhead.name) {
|
||
|
|
parts.push(`<text align="center">${xmlEscape(s.letterhead.name)} </text>`);
|
||
|
|
}
|
||
|
|
const addrLines = [];
|
||
|
|
if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0]));
|
||
|
|
if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone));
|
||
|
|
if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number));
|
||
|
|
if (addrLines.length) parts.push(`<text align="center">${addrLines.join(' ')} </text>`);
|
||
|
|
parts.push('<text align="left">-------------------------------- </text>');
|
||
|
|
const d = new Date(s.createdAt);
|
||
|
|
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} </text>`);
|
||
|
|
if (s.customer) parts.push(`<text align="left">Customer: ${xmlEscape(s.customer.name)} </text>`);
|
||
|
|
parts.push('<text align="left">-------------------------------- </text>');
|
||
|
|
const itemLines = s.items.map(it => {
|
||
|
|
const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`;
|
||
|
|
return `${xmlEscape(it.description || it.sku)} ${xmlEscape(priceLine)} `;
|
||
|
|
}).join('');
|
||
|
|
parts.push(`<text align="left">${itemLines}</text>`);
|
||
|
|
parts.push('<text align="left">-------------------------------- </text>');
|
||
|
|
const sub = s.sub != null ? s.sub : (s.total - (s.vat || 0));
|
||
|
|
const vat = s.vat != null ? s.vat : (s.total * 15 / 115);
|
||
|
|
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
|
||
|
|
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
|
||
|
|
if (hasDiscount) {
|
||
|
|
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.qty) || 0), 0);
|
||
|
|
const grossSub = gross / 1.15;
|
||
|
|
const grossVat = gross - grossSub;
|
||
|
|
parts.push(`<text align="left">Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} </text>`);
|
||
|
|
}
|
||
|
|
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} </text>`);
|
||
|
|
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} </text>`);
|
||
|
|
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
|
||
|
|
parts.push(`<text align="left">Paid: ${xmlEscape(paidLabel)} </text>`);
|
||
|
|
if (s.payment === 'cash') parts.push(`<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} </text>`);
|
||
|
|
parts.push('<text align="left">-------------------------------- </text>');
|
||
|
|
if (s.letterhead && s.letterhead.footer_text) parts.push(`<text align="center">${xmlEscape(s.letterhead.footer_text)} </text>`);
|
||
|
|
parts.push('<feed line="3"/>');
|
||
|
|
parts.push('<cut type="feed"/>');
|
||
|
|
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Export it from the module**
|
||
|
|
|
||
|
|
Change the `return` statement to:
|
||
|
|
|
||
|
|
```js
|
||
|
|
return { getSettings, setSettings, imageToRaster, buildEposXml };
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify it loads without error**
|
||
|
|
|
||
|
|
Run: `node --check public/pos/epos-print.js`
|
||
|
|
Expected: no output, exit code 0.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Manual browser check with a fake sale**
|
||
|
|
|
||
|
|
In the browser console (same page as Task 3's check):
|
||
|
|
|
||
|
|
```js
|
||
|
|
const fakeSale = {
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
localId: 'test0001',
|
||
|
|
payment: 'cash', cashGiven: 300, change: 5.51,
|
||
|
|
items: [{ description: 'Widget A', qty: 2, selling_price: 45 }],
|
||
|
|
sub: 78.26, vat: 11.74, total: 90,
|
||
|
|
letterhead: { name: 'WFDASH POS', address: '123 Shop Street', phone: '021 555 0123' }
|
||
|
|
};
|
||
|
|
console.log(EposPrint.buildEposXml(fakeSale, null));
|
||
|
|
```
|
||
|
|
|
||
|
|
Expected: a full XML string starting with `<?xml version="1.0"...` and containing `<epos-print`, item text, and `<cut type="feed"/>`, no thrown error.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/epos-print.js
|
||
|
|
git commit -m "feat(pos): add ePOS-Print XML builder for sale receipts"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: Add the network transport (`printViaNetwork`)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `public/pos/epos-print.js`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add `printViaNetwork` inside the IIFE, before the `return` statement**
|
||
|
|
|
||
|
|
```js
|
||
|
|
async function printViaNetwork(ip, xml) {
|
||
|
|
const res = await fetch(`https://${ip}/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '""' },
|
||
|
|
body: xml
|
||
|
|
});
|
||
|
|
if (!res.ok) throw new Error('Printer HTTP error: ' + res.status);
|
||
|
|
const text = await res.text();
|
||
|
|
const doc = new DOMParser().parseFromString(text, 'text/xml');
|
||
|
|
const response = doc.getElementsByTagName('response')[0];
|
||
|
|
const success = response && response.getAttribute('success') === 'true';
|
||
|
|
if (!success) {
|
||
|
|
const code = response ? response.getAttribute('code') : null;
|
||
|
|
throw new Error('Printer rejected job' + (code ? ' (code: ' + code + ')' : ''));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Export it from the module**
|
||
|
|
|
||
|
|
Change the `return` statement to:
|
||
|
|
|
||
|
|
```js
|
||
|
|
return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork };
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify it loads without error**
|
||
|
|
|
||
|
|
Run: `node --check public/pos/epos-print.js`
|
||
|
|
Expected: no output, exit code 0.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Manual test against the real printer**
|
||
|
|
|
||
|
|
With a device on the shop wifi that has already visited `https://192.168.1.33` and trusted the cert, open the browser console on any page served from the app (must be same-origin `fetch`, no special config needed since the printer sends `Access-Control-Allow-Origin: *`) and run:
|
||
|
|
|
||
|
|
```js
|
||
|
|
EposPrint.printViaNetwork('192.168.1.33', EposPrint.buildEposXml(fakeSale, null)).then(() => console.log('OK')).catch(e => console.error(e));
|
||
|
|
```
|
||
|
|
|
||
|
|
(reuse `fakeSale` from Task 4's check). Expected: logs `OK`, and a real slip prints on the TM-T20II with the fake sale's items and totals.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/epos-print.js
|
||
|
|
git commit -m "feat(pos): send ePOS-Print jobs over HTTPS to network printers"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 6: Add printer settings UI to `receipt.html`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `public/pos/receipt.html`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add CSS for the settings block**
|
||
|
|
|
||
|
|
In `public/pos/receipt.html`, find the `.status` rule block (around line 134):
|
||
|
|
|
||
|
|
```css
|
||
|
|
.status{padding:12px 14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);font-size:13px;display:flex;align-items:center;gap:10px;color:var(--fg);line-height:1.4}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add immediately after it:
|
||
|
|
|
||
|
|
```css
|
||
|
|
.printer-settings{padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-card);display:flex;flex-direction:column;gap:8px}
|
||
|
|
.printer-settings label{font-size:12px;font-weight:600;color:var(--ink-soft);display:flex;flex-direction:column;gap:4px}
|
||
|
|
.printer-settings select,.printer-settings input{padding:9px 10px;border-radius:8px;border:1px solid var(--muted);background:var(--bg);color:var(--fg);font:inherit}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add the settings toggle row and form to the markup**
|
||
|
|
|
||
|
|
In `public/pos/receipt.html`, find (around line 295-298):
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="status" id="status">
|
||
|
|
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg></span>
|
||
|
|
<span class="t" id="statusText">Ready to print.</span>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
Add immediately after it:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="status" id="printerSettingsToggle" style="cursor:pointer" onclick="togglePrinterSettings()">
|
||
|
|
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg></span>
|
||
|
|
<span class="t" id="printerSettingsLabel">Printer: Bluetooth</span>
|
||
|
|
</div>
|
||
|
|
<div class="printer-settings" id="printerSettingsForm" style="display:none">
|
||
|
|
<label>Printer type
|
||
|
|
<select id="printerTypeSelect">
|
||
|
|
<option value="bluetooth">Bluetooth</option>
|
||
|
|
<option value="network">Network (Ethernet)</option>
|
||
|
|
</select>
|
||
|
|
</label>
|
||
|
|
<label>Printer IP address
|
||
|
|
<input id="printerIpInput" placeholder="192.168.1.33">
|
||
|
|
</label>
|
||
|
|
<button class="btn" style="min-height:40px" onclick="savePrinterSettings()">Save printer settings</button>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add `epos-print.js` script tag**
|
||
|
|
|
||
|
|
Find (around line 317):
|
||
|
|
|
||
|
|
```html
|
||
|
|
<script src="pos-config.js"></script>
|
||
|
|
<script src="theme.js"></script>
|
||
|
|
```
|
||
|
|
|
||
|
|
Change to:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<script src="pos-config.js"></script>
|
||
|
|
<script src="theme.js"></script>
|
||
|
|
<script src="epos-print.js"></script>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Add the settings toggle/save functions and init call**
|
||
|
|
|
||
|
|
Inside the main inline `<script>` IIFE in `receipt.html`, immediately after the `paintNet();` call near the end of the file (currently the last line before `})();`), add — but call it earlier, right after the existing `paintNet();` line at the bottom (around line 655):
|
||
|
|
|
||
|
|
Find:
|
||
|
|
|
||
|
|
```js
|
||
|
|
paintNet();
|
||
|
|
})();
|
||
|
|
```
|
||
|
|
|
||
|
|
Change to:
|
||
|
|
|
||
|
|
```js
|
||
|
|
function togglePrinterSettings(){
|
||
|
|
const form = document.getElementById('printerSettingsForm');
|
||
|
|
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
|
||
|
|
}
|
||
|
|
|
||
|
|
function savePrinterSettings(){
|
||
|
|
const type = document.getElementById('printerTypeSelect').value;
|
||
|
|
const ip = document.getElementById('printerIpInput').value.trim();
|
||
|
|
window.EposPrint.setSettings({ type, ip });
|
||
|
|
paintPrinterSettings();
|
||
|
|
document.getElementById('printerSettingsForm').style.display = 'none';
|
||
|
|
}
|
||
|
|
|
||
|
|
function paintPrinterSettings(){
|
||
|
|
const settings = window.EposPrint.getSettings();
|
||
|
|
document.getElementById('printerTypeSelect').value = settings.type;
|
||
|
|
document.getElementById('printerIpInput').value = settings.ip || '';
|
||
|
|
document.getElementById('printerSettingsLabel').textContent =
|
||
|
|
'Printer: ' + (settings.type === 'network' ? ('Network' + (settings.ip ? ' (' + settings.ip + ')' : '')) : 'Bluetooth');
|
||
|
|
}
|
||
|
|
|
||
|
|
paintPrinterSettings();
|
||
|
|
paintNet();
|
||
|
|
})();
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Verify the script still parses**
|
||
|
|
|
||
|
|
Run: `node --check <(sed -n '319,700p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
|
||
|
|
Expected: no output, exit code 0. (Adjust the line range in the `sed -n` command to match wherever the inline `<script>...</script>` block now starts and ends after your edits.)
|
||
|
|
|
||
|
|
- [ ] **Step 6: Manual browser check**
|
||
|
|
|
||
|
|
Open `receipt.html` in a browser (with a `pos.lastSale` value in `sessionStorage`, or just check that the page doesn't error in the console). Click the new "Printer: Bluetooth" row — the settings form should expand. Switch the select to "Network (Ethernet)", type `192.168.1.33`, click "Save printer settings" — the row should update to read "Printer: Network (192.168.1.33)" and the value should persist across a page reload (check via `localStorage.getItem('pos.printerSettings')` in the console).
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/receipt.html
|
||
|
|
git commit -m "feat(pos): add printer type/IP settings UI to receipt page"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 7: Wire `printThermal()` to the network path
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `public/pos/receipt.html`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Rename the existing Bluetooth flow**
|
||
|
|
|
||
|
|
Find (around line 487):
|
||
|
|
|
||
|
|
```js
|
||
|
|
async function printThermal(){
|
||
|
|
const btn = document.getElementById('printBtn');
|
||
|
|
```
|
||
|
|
|
||
|
|
Change the function name only:
|
||
|
|
|
||
|
|
```js
|
||
|
|
async function printThermalBluetooth(){
|
||
|
|
const btn = document.getElementById('printBtn');
|
||
|
|
```
|
||
|
|
|
||
|
|
(Leave the rest of that function's body untouched.)
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add the network variant and the dispatcher**
|
||
|
|
|
||
|
|
Immediately before the (now renamed) `printThermalBluetooth` function, add:
|
||
|
|
|
||
|
|
```js
|
||
|
|
async function printThermal(){
|
||
|
|
const settings = window.EposPrint.getSettings();
|
||
|
|
if(settings.type === 'network') return printThermalNetwork(settings);
|
||
|
|
return printThermalBluetooth();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function printThermalNetwork(settings){
|
||
|
|
const btn = document.getElementById('printBtn');
|
||
|
|
const status = document.getElementById('statusText');
|
||
|
|
const errBox = document.getElementById('status');
|
||
|
|
errBox.classList.remove('err');
|
||
|
|
btn.disabled = true;
|
||
|
|
if(!settings.ip){
|
||
|
|
status.textContent = 'Set the printer IP address in Printer settings first.';
|
||
|
|
errBox.classList.add('err');
|
||
|
|
btn.disabled = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
status.textContent = 'Sending to ' + settings.ip + '…';
|
||
|
|
try{
|
||
|
|
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
|
||
|
|
const xml = window.EposPrint.buildEposXml(sale, logo);
|
||
|
|
await window.EposPrint.printViaNetwork(settings.ip, xml);
|
||
|
|
status.textContent = 'Printed · ' + settings.ip;
|
||
|
|
}catch(e){
|
||
|
|
status.textContent = 'Print failed: ' + e.message;
|
||
|
|
errBox.classList.add('err');
|
||
|
|
}finally{
|
||
|
|
btn.disabled = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify the script still parses**
|
||
|
|
|
||
|
|
Run: `node --check <(sed -n '319,760p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
|
||
|
|
Expected: no output, exit code 0. (Adjust line range as needed.)
|
||
|
|
|
||
|
|
- [ ] **Step 4: Manual end-to-end test against the real printer**
|
||
|
|
|
||
|
|
On a device on the shop wifi that has trusted the printer's certificate, open a real sale's receipt page (so `sessionStorage['pos.lastSale']` is populated), set Printer type to Network with IP `192.168.1.33` via the UI from Task 6, tap "Print slip". Expected: status text shows "Printed · 192.168.1.33" and a real receipt (logo, items, subtotal, VAT, total, footer) prints on the TM-T20II.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/receipt.html
|
||
|
|
git commit -m "feat(pos): print sale receipts over the network transport"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 8: Add network transport for delivery notes
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `public/pos/epos-print.js`
|
||
|
|
- Modify: `public/pos/receipt.html`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add `buildEposDeliveryXml` to `epos-print.js`, before the `return` statement**
|
||
|
|
|
||
|
|
Mirrors the delivery-note ESC/POS builder in `receipt.html`'s `printDelivery()` (currently inline, lines ~607-637).
|
||
|
|
|
||
|
|
```js
|
||
|
|
function buildEposDeliveryXml(s, showPrices) {
|
||
|
|
const parts = [];
|
||
|
|
parts.push('<text align="center" width="1" height="2">WFSALES POS </text>');
|
||
|
|
parts.push('<text align="center">Delivery Note </text>');
|
||
|
|
if (s.customer && s.customer.company) parts.push(`<text align="left">Company: ${xmlEscape(s.customer.company)} </text>`);
|
||
|
|
parts.push(`<text align="left">Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} </text>`);
|
||
|
|
const d = new Date(s.createdAt);
|
||
|
|
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} </text>`);
|
||
|
|
parts.push('<text align="left">-------------------------------- </text>');
|
||
|
|
const itemLines = s.items.map(it => {
|
||
|
|
const qtyLine = ` ${it.qty} ${it.unit || 'ea'}`;
|
||
|
|
const priceSuffix = showPrices ? ` R ${((it.selling_price || 0) * it.qty).toFixed(2)}` : '';
|
||
|
|
return `${xmlEscape(it.description || it.sku)} ${xmlEscape(qtyLine + priceSuffix)} `;
|
||
|
|
}).join('');
|
||
|
|
parts.push(`<text align="left">${itemLines}</text>`);
|
||
|
|
parts.push('<text align="left">-------------------------------- </text>');
|
||
|
|
if (showPrices) parts.push(`<text align="left">TOTAL: R ${s.total.toFixed(2)} </text>`);
|
||
|
|
parts.push('<text align="left">Received by: __________ Driver: __________ </text>');
|
||
|
|
parts.push('<feed line="3"/>');
|
||
|
|
parts.push('<cut type="feed"/>');
|
||
|
|
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Export it**
|
||
|
|
|
||
|
|
Change the `return` statement to:
|
||
|
|
|
||
|
|
```js
|
||
|
|
return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml };
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify it loads without error**
|
||
|
|
|
||
|
|
Run: `node --check public/pos/epos-print.js`
|
||
|
|
Expected: no output, exit code 0.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Wire `printDelivery()` in `receipt.html`**
|
||
|
|
|
||
|
|
Find (around line 593):
|
||
|
|
|
||
|
|
```js
|
||
|
|
async function printDelivery(){
|
||
|
|
if(!sale) return;
|
||
|
|
const btn = document.getElementById('printDnBtn');
|
||
|
|
const status = document.getElementById('statusText');
|
||
|
|
const errBox = document.getElementById('status');
|
||
|
|
errBox.classList.remove('err');
|
||
|
|
btn.disabled = true;
|
||
|
|
status.textContent = 'Printing delivery note…';
|
||
|
|
if(!btChar){
|
||
|
|
status.textContent = 'Pair a Bluetooth printer first to print.';
|
||
|
|
errBox.classList.add('err');
|
||
|
|
btn.disabled = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Change to:
|
||
|
|
|
||
|
|
```js
|
||
|
|
async function printDelivery(){
|
||
|
|
if(!sale) return;
|
||
|
|
const btn = document.getElementById('printDnBtn');
|
||
|
|
const status = document.getElementById('statusText');
|
||
|
|
const errBox = document.getElementById('status');
|
||
|
|
errBox.classList.remove('err');
|
||
|
|
btn.disabled = true;
|
||
|
|
status.textContent = 'Printing delivery note…';
|
||
|
|
const printerSettings = window.EposPrint.getSettings();
|
||
|
|
if(printerSettings.type === 'network'){
|
||
|
|
if(!printerSettings.ip){
|
||
|
|
status.textContent = 'Set the printer IP address in Printer settings first.';
|
||
|
|
errBox.classList.add('err');
|
||
|
|
btn.disabled = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const dnToggleNet = document.getElementById('dnShowPrices');
|
||
|
|
const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : true;
|
||
|
|
try{
|
||
|
|
const xml = window.EposPrint.buildEposDeliveryXml(sale, showPricesNet);
|
||
|
|
await window.EposPrint.printViaNetwork(printerSettings.ip, xml);
|
||
|
|
status.textContent = 'Delivery note printed · ' + printerSettings.ip;
|
||
|
|
}catch(e){
|
||
|
|
errBox.classList.add('err');
|
||
|
|
status.textContent = 'DN print failed: ' + e.message;
|
||
|
|
}finally{
|
||
|
|
btn.disabled = false;
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if(!btChar){
|
||
|
|
status.textContent = 'Pair a Bluetooth printer first to print.';
|
||
|
|
errBox.classList.add('err');
|
||
|
|
btn.disabled = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
(Leave the rest of the existing Bluetooth body below this point unchanged.)
|
||
|
|
|
||
|
|
- [ ] **Step 5: Verify the script still parses**
|
||
|
|
|
||
|
|
Run: `node --check <(sed -n '319,800p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
|
||
|
|
Expected: no output, exit code 0. (Adjust line range as needed.)
|
||
|
|
|
||
|
|
- [ ] **Step 6: Manual test against the real printer**
|
||
|
|
|
||
|
|
With Printer type set to Network in the UI, open an account-payment sale's receipt (so the delivery note section is visible), tap "Print delivery note". Expected: a delivery-note slip prints with company/customer, item list, and the received-by/driver line.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/epos-print.js public/pos/receipt.html
|
||
|
|
git commit -m "feat(pos): print delivery notes over the network transport"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 9: Register `epos-print.js` with the service worker
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `public/pos/sw.js`
|
||
|
|
|
||
|
|
Without this, the till would work online but `epos-print.js` wouldn't be available offline (the printer itself is only reachable on the shop LAN anyway, but the PWA shell — including this script — should still be cached like every other JS file here).
|
||
|
|
|
||
|
|
- [ ] **Step 1: Add the file to the precache list and bump the cache version**
|
||
|
|
|
||
|
|
Find (lines 1-17):
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
|
||
|
|
const CACHE = "pos-v15";
|
||
|
|
const APP_SHELL = [
|
||
|
|
'./',
|
||
|
|
'./index.html',
|
||
|
|
'./pos.html',
|
||
|
|
'./receipt.html',
|
||
|
|
'./history.html',
|
||
|
|
'./cashup.html',
|
||
|
|
'./pos-config.js',
|
||
|
|
'./pos-app.js',
|
||
|
|
'./theme.js',
|
||
|
|
'./manifest.json',
|
||
|
|
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
|
||
|
|
'./fa/all.min.css',
|
||
|
|
'./fa/webfonts/fa-solid-900.woff2',
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
Change to:
|
||
|
|
|
||
|
|
```js
|
||
|
|
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
|
||
|
|
const CACHE = "pos-v16";
|
||
|
|
const APP_SHELL = [
|
||
|
|
'./',
|
||
|
|
'./index.html',
|
||
|
|
'./pos.html',
|
||
|
|
'./receipt.html',
|
||
|
|
'./history.html',
|
||
|
|
'./cashup.html',
|
||
|
|
'./pos-config.js',
|
||
|
|
'./pos-app.js',
|
||
|
|
'./epos-print.js',
|
||
|
|
'./theme.js',
|
||
|
|
'./manifest.json',
|
||
|
|
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
|
||
|
|
'./fa/all.min.css',
|
||
|
|
'./fa/webfonts/fa-solid-900.woff2',
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify the file parses**
|
||
|
|
|
||
|
|
Run: `node --check public/pos/sw.js`
|
||
|
|
Expected: no output, exit code 0.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Manual check**
|
||
|
|
|
||
|
|
Open `receipt.html` in a browser with DevTools open to the Application/Service Worker panel, force-reload, and confirm the new service worker (`pos-v16`) activates and `epos-print.js` appears in the Cache Storage listing for `pos-v16`.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add public/pos/sw.js
|
||
|
|
git commit -m "chore(pos): cache epos-print.js in the PWA app shell"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 10: Full end-to-end verification against the physical printer
|
||
|
|
|
||
|
|
**Files:** none (verification only)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Confirm the certificate-trust step on a fresh device**
|
||
|
|
|
||
|
|
On an iPhone or iPad that has never visited the printer before, open Safari, navigate to `https://192.168.1.33`, and confirm you get a certificate warning; tap through "visit this website" (or equivalent). Confirm subsequent `fetch()` calls from the PWA's origin succeed without a further prompt.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Ring up a real (or test) sale with items and a discount applied**
|
||
|
|
|
||
|
|
Use the till to create a sale with at least 2 line items and a discount, so the "gross" block in `buildEposXml` is exercised, not just the plain-total path.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Set Printer type to Network with IP `192.168.1.33` and print the slip**
|
||
|
|
|
||
|
|
Confirm the physical printout shows: WFDASH logo, "WFSALES POS" header, date/slip number, item lines, gross/discount/net totals, VAT, TOTAL in bold, payment method, and footer text — matching what the on-screen receipt shows.
|
||
|
|
|
||
|
|
- [ ] **Step 4: If it's an account-payment sale, print the delivery note over network too**
|
||
|
|
|
||
|
|
Confirm the physical printout matches the on-screen delivery note (company/customer, item list respecting the "Include prices" toggle, received-by/driver line).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Repeat Steps 2-4 on an Android device**
|
||
|
|
|
||
|
|
Confirms parity between iOS Safari and Android Chrome for this transport (Bluetooth already only works on Android, per Task 6-8 branching).
|
||
|
|
|
||
|
|
- [ ] **Step 6: Note any printer status codes seen during testing**
|
||
|
|
|
||
|
|
If any print job returns `success="true"` but nothing physically prints (as was observed once during initial protocol testing), capture the full XML response (including the `status` attribute) and check it against the printer's technical reference guide before shipping this to the till — do not treat `success="true"` alone as proof of a physical printout without confirming paper actually came out.
|