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:
parent
47f4c73f18
commit
e8e65aabb7
1 changed files with 46 additions and 72 deletions
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue