An account sale at the till used to produce a delivery-note-shaped slip that
was rendered entirely on the device and printed on the 80mm thermal printer.
Nothing reached the server, so the paperwork the client signed had no record
behind it. Cash / card / EFT are untouched and still get the thermal slip.
An account sale now creates a real `delivery_notes` record. The DN is raised
server-side INSIDE `checkoutSale` rather than by a follow-up call from the
till, because the offline queue (`flushQueue`) re-posts `/checkout` and
nothing else — a client-side second call would be silently dropped for every
sale taken while the network was down, which is exactly when a till is
flakiest.
`pos_sales.delivery_note` is the idempotency anchor that makes it
exactly-once: on a replay it hands back the existing DN instead of making a
second one, and a sale whose first attempt left the field blank retries. It
self-heals.
Deliberately non-fatal: if the DN can't be created the sale still succeeds.
The money and the stock movement are real by then; failing the checkout would
leave the drawer disagreeing with the stock ledger. The till is told instead.
Also in here:
- DN number collision retry. The generator reads the highest existing number
and adds one, which is not atomic, while PB holds a unique index on it. The
POS adds a second concurrent writer, so two creates could collide and 500
on a benign condition.
- GET /api/v1/pos/sales now expands customer, customer.company and
delivery_note so history.html can rebuild the DN card on a reprint. The
nested customer.company matters — customer.company is itself a relation,
so without it the card printed a raw PB id where the name belongs.
- "Print slip" is hidden for account sales; that tender produces a delivery
note, not a slip.
New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind
assertPosAuth, so the till's bearer key stays its only credential. It does not
point at the CRM's unauthenticated invoicing route.
Verified end to end against a live note: DN26100401, correct company, both
lines with pricing, PDF attached, stock decremented once, and a replay of the
same client_ref returned the same DN without a duplicate.
90 lines
3 KiB
JavaScript
90 lines
3 KiB
JavaScript
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
|
|
// v28 — account sales can now reprint their delivery note from
|
|
// history.html (the sale's linked DN is now carried into the reprint
|
|
// screen), and the "Print slip" button is hidden for account sales —
|
|
// the delivery note is the only document that tender produces.
|
|
// Bumped so tills evict the stale app shell.
|
|
const CACHE = "pos-v28";
|
|
const APP_SHELL = [
|
|
'./',
|
|
'./index.html',
|
|
'./pos.html',
|
|
'./receipt.html',
|
|
'./history.html',
|
|
'./cashup.html',
|
|
'./settings.html',
|
|
'./slip-layout.html',
|
|
'./pos-config.js',
|
|
'./pos-app.js',
|
|
'./letterhead-config.js',
|
|
'./slip-layout.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',
|
|
];
|
|
|
|
// Files that must always be fresh (network-first, fallback to cache)
|
|
const NETWORK_FIRST = new Set([
|
|
'theme.js'
|
|
]);
|
|
|
|
self.addEventListener('install', e => {
|
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll(APP_SHELL)).then(()=>self.skipWaiting()));
|
|
});
|
|
|
|
self.addEventListener('activate', e => {
|
|
e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(()=>self.clients.claim()));
|
|
});
|
|
|
|
self.addEventListener('fetch', e => {
|
|
const url = new URL(e.request.url);
|
|
// Never cache API responses — only the static app shell. Serving a
|
|
// stale /catalog or, worse, a cached /checkout would be actively wrong.
|
|
if(url.pathname.startsWith('/api/')) return;
|
|
// HTML navigations: always network-first so the user gets the latest page
|
|
if(e.request.mode === 'navigate'){
|
|
e.respondWith(
|
|
fetch(e.request).then(res => {
|
|
if(res && res.ok) {
|
|
const clone = res.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
}
|
|
return res;
|
|
}).catch(() => caches.match(e.request).then(r => r || new Response('<h1>Offline</h1>', {status:503, headers:{'Content-Type':'text/html'}})))
|
|
);
|
|
return;
|
|
}
|
|
if(e.request.method !== 'GET') return;
|
|
|
|
// theme.js (and other network-first assets) always go to network
|
|
const fname = url.pathname.split('/').pop();
|
|
if(NETWORK_FIRST.has(fname)){
|
|
e.respondWith(
|
|
fetch(e.request).then(res => {
|
|
if(res && res.ok){
|
|
const clone = res.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
}
|
|
return res;
|
|
}).catch(() => caches.match(e.request).then(r => r || new Response('', {status:504})))
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Default: cache-first for static assets
|
|
e.respondWith(
|
|
caches.match(e.request).then(cached => {
|
|
const fetchP = fetch(e.request).then(res => {
|
|
if(res && res.ok && (e.request.url.startsWith('http'))) {
|
|
const clone = res.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
}
|
|
return res;
|
|
}).catch(() => cached);
|
|
return cached || fetchP;
|
|
})
|
|
);
|
|
});
|