feat(pos): checkout via /api/v1/pos/checkout, drop orders/order_items/delivery_notes writes

Sends client_ref (the till's localId) so the offline queue can retry a
sale that already landed without recording it twice. Customer search
moves to /api/v1/pos/customers and now carries the company name for the
delivery-note slip.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-31 01:44:31 +02:00
parent 47f4c73f18
commit e8e65aabb7

View file

@ -1,9 +1,9 @@
// POS app — catalog, cart, payment, offline queue, slip generation.
// All routes: /api/collections/{name}/records
// All server access goes through /api/v1/pos/* via window.posFetch
// (see pos-config.js) — this app never talks to PocketBase directly.
(function(){
'use strict';
const cfg = window.PB_CONFIG;
const CACHE = 'pos-v1';
const LS_CART = 'pos.cart';
const LS_QUEUE = 'pos.queue';
@ -417,16 +417,15 @@ async function searchCustomers(q){
const box = document.getElementById('custResults');
if(!q){ box.innerHTML=''; return; }
try{
const f = `(customer~'${q}' || email~'${q}' || number~'${q}')`;
const r = await window.pbFetch(`/api/collections/${cfg.collections.customers}/records?perPage=8&filter=${encodeURIComponent(f)}`);
const r = await window.posFetch(`/customers?q=${encodeURIComponent(q)}`);
const j = await r.json();
box.innerHTML = j.items.map(c=>`<div data-id="${c.id}" data-name="${escapeAttr(c.customer||c.email||c.number||'?')}">
<span>${escapeHTML(c.customer||c.email||'—')}</span>
<small>${escapeHTML(c.number||c.email||'')}</small>
box.innerHTML = (j.items||[]).map(c=>`<div data-id="${c.id}" data-name="${escapeAttr(c.name)}" data-company="${escapeAttr(c.company||'')}">
<span>${escapeHTML(c.name)}</span>
<small>${escapeHTML(c.company || '')}</small>
</div>`).join('');
box.querySelectorAll('div').forEach(d=>{
d.addEventListener('click',()=>{
state.customer = {id:d.dataset.id, name:d.dataset.name};
state.customer = {id:d.dataset.id, name:d.dataset.name, company:d.dataset.company};
const pk = document.getElementById('custPicked');
pk.classList.add('show');
pk.querySelector('b').textContent = d.dataset.name;
@ -448,84 +447,60 @@ async function confirmSale(){
const btn = document.getElementById('confirmBtn');
if(btn.disabled) return;
btn.disabled = true; btn.innerHTML = 'Saving…';
const items = Array.from(state.cart.values()).map(({p,qty})=>({
sku: p.sku, description: p.description, unit: p.unit, brand: p.brand,
category: p.category, subcategory: p.subcategory, supplier: p.supplier,
cost_price: p.cost_price, selling_price: p.selling_price, qty, product_id: p.id
}));
const totals = cartTotals();
const sub = totals.sub, vat = totals.vat, total = totals.total;
// localId doubles as the server-side idempotency key (client_ref), so
// a queued sale that actually landed can be retried without being
// recorded — and stock decremented — a second time.
const localId = uid();
const sale = {
localId: uid(),
localId,
createdAt: new Date().toISOString(),
payment: state.paymentMethod,
cashGiven: state.cashGiven || null,
change: state.paymentMethod==='cash' ? Math.max(0, state.cashGiven - total) : 0,
sub, vat, total, items,
sub, vat, total,
items: Array.from(state.cart.values()).map(({p,qty})=>({
sku: p.sku, description: p.description, unit: p.unit,
cost_price: p.cost_price, selling_price: p.selling_price, qty, product_id: p.id,
})),
customer: state.customer || null,
letterhead: state.letterhead ? {name: state.letterhead.display_name, address: state.letterhead.address, phone: state.letterhead.phone, email: state.letterhead.email, vat_number: state.letterhead.vat_number, registration_number: state.letterhead.registration_number, footer_text: state.letterhead.footer_text, logo_url: state.letterhead.logo} : null,
synced: false
synced: false,
};
const checkoutBody = {
items: Array.from(state.cart.values()).map(({p,qty})=>({
stock_item_id: p.id, qty, unit_price: p.selling_price ?? p.cost_price ?? 0,
sku: p.sku, description: p.description,
})),
payment_method: state.paymentMethod,
cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined,
customer_id: state.customer ? state.customer.id : undefined,
client_ref: localId,
};
// Try to write to PocketBase now; if offline or fails, queue.
let serverRef = null;
let serverError = null;
try{
const orderBody = {
order_number: 'S-' + Date.now().toString(36).toUpperCase(),
notes: 'POS sale · ' + sale.payment + (sale.customer?` · ${sale.customer.name}`:''),
invoiced: sale.payment==='account' ? false : true,
};
if(sale.customer) orderBody.customer = sale.customer.id;
const r = await window.pbFetch('/api/collections/'+cfg.collections.orders+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(orderBody)});
const r = await window.posFetch('/checkout', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(checkoutBody),
});
if(r.ok){
const j = await r.json();
serverRef = j.id;
// Line items
for(const it of sale.items){
try{
await window.pbFetch('/api/collections/'+cfg.collections.orderItems+'/records',{
method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({order: j.id, description: it.description, quantity: it.qty, cost_price: it.cost_price, selling_price: it.selling_price, item_status:'invoiced'})
});
}catch(liErr){ console.warn('line item write failed', liErr); }
}
// If account sale, create delivery note
if(sale.payment==='account'){
try{
const dn = await window.pbFetch('/api/collections/'+cfg.collections.deliveryNotes+'/records',{
method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({delivery_note_number: 'DN-'+Date.now().toString(36).toUpperCase(), order: j.id, customer: sale.customer?.id, status:'pending', notes:'Account sale — deliver to customer.'})
});
if(dn.ok){
const dnJ = await dn.json();
for(const it of sale.items){
try{
await window.pbFetch('/api/collections/'+cfg.collections.deliveryNoteItems+'/records',{
method:'POST',headers:{'Content-Type':'application/json'},
body: JSON.stringify({delivery_note: dnJ.id, description: it.description, quantity: it.qty})
});
}catch(_){}
}
}
}catch(dnErr){ console.warn('delivery note write failed', dnErr); }
}
sale.synced = true;
sale.serverRef = serverRef;
sale.serverRef = j.sale && j.sale.id;
} else {
serverError = 'server returned ' + r.status;
throw new Error(serverError);
throw new Error('server returned ' + r.status);
}
}catch(e){
console.warn('Sale queued for sync', e);
// Queue locally — never let this throw escape
try{
await dbPut(STORE_QUEUE, sale);
await dbPut(STORE_QUEUE, {...sale, checkoutBody});
}catch(qErr){
console.error('IndexedDB queue failed too — saving to localStorage as fallback', qErr);
try{
const arr = JSON.parse(localStorage.getItem('wfsales.queue') || '[]');
arr.push(sale);
arr.push({...sale, checkoutBody});
localStorage.setItem('wfsales.queue', JSON.stringify(arr));
}catch(_){}
}
@ -553,17 +528,16 @@ async function flushQueue(){
const q = await dbAll(STORE_QUEUE);
for(const s of q){
try{
const r = await window.pbFetch('/api/collections/'+cfg.collections.orders+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order_number:'S-'+Date.now().toString(36).toUpperCase(), notes:'POS sale (offline) · '+s.payment, invoiced: s.payment!=='account'})});
const r = await window.posFetch('/checkout', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s.checkoutBody),
});
if(r.ok){
const j = await r.json();
for(const it of s.items){
await window.pbFetch('/api/collections/'+cfg.collections.orderItems+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order:j.id, description:it.description, quantity:it.qty, cost_price:it.cost_price, selling_price:it.selling_price, item_status:'invoiced'})});
}
if(s.payment==='account' && s.customer){
const dn = await window.pbFetch('/api/collections/'+cfg.collections.deliveryNotes+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delivery_note_number:'DN-'+Date.now().toString(36).toUpperCase(), order:j.id, customer:s.customer.id, status:'pending'})});
if(dn.ok){ const dnJ = await dn.json(); for(const it of s.items){ await window.pbFetch('/api/collections/'+cfg.collections.deliveryNoteItems+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delivery_note:dnJ.id, description:it.description, quantity:it.qty})}); } }
}
// Safe even if this sale already landed before — the server
// matches on client_ref and replays the original instead of
// decrementing stock again.
await dbDel(STORE_QUEUE, s.localId);
} else {
break; // stop on first failure, retry next time we come online
}
}catch(e){ console.warn('queue item failed', e); break; }
}