Development application: records are stored only in this browser. There is no authentication, immutable audit trail, server synchronisation or tax calculation. All amounts are exclusive of tax. Do not use for live purchasing approvals or statutory accounts.
7. Control and reconciliation
For backup of retail quantities, also export a separate Retail "use strict";const PK='freeman-procurement-v17',RK='freeman-retail-v15';const $=id=>document.getElementById(id);const now=()=>new Date().toISOString();const uid=prefix=>prefix+'-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,7);const amt=n=>Math.round(Number(n)*100);const fmt=c=>(c/100).toLocaleString('en-GB',{minimumFractionDigits:2,maximumFractionDigits:2});const fresh=()=>({suppliers:[],pos:[],grns:[],invoices:[],payments:[]});function parse(key,fallback){try{return JSON.parse(localStorage.getItem(key))||fallback}catch{return fallback}}let db=parse(PK,fresh());if(!db||!['suppliers','pos','grns','invoices','payments'].every(k=>Array.isArray(db[k])))db=fresh();let retail=parse(RK,{products:[],sales:[],moves:[]});function reloadRetail(){retail=parse(RK,{products:[],sales:[],moves:[]});if(!Array.isArray(retail.products))retail={products:[],sales:[],moves:[]}}function persist(){localStorage.setItem(PK,JSON.stringify(db));render()}function error(e){alert(e.message||String(e))}function guard(fn){return e=>{e.preventDefault();try{fn(new FormData(e.currentTarget));e.currentTarget.reset();persist()}catch(ex){error(ex)}}}function required(cond,msg){if(!cond)throw Error(msg)}function select(id,arr,format){const s=$(id),v=s.value;s.replaceChildren();const z=document.createElement('option');z.value='';z.textContent='Select…';s.append(z);arr.forEach(x=>{const o=document.createElement('option');o.value=x.id||x.sku;o.textContent=format(x);s.append(o)});if([...s.options].some(o=>o.value===v))s.value=v}function row(vals){let tr=document.createElement('tr');vals.forEach(x=>{let td=document.createElement('td');td.textContent=String(x);tr.append(td)});$('register').append(tr)}function render(){reloadRetail();select('poSupplier',db.suppliers,s=>s.name);select('poSku',retail.products,p=>p.sku+' — '+p.name);select('grnPo',db.pos.filter(p=>p.received
p.id+' | '+p.sku+' | outstanding '+(p.qty-p.received));select('invoiceGrn',db.grns.filter(g=>!db.invoices.some(i=>i.grn===g.id)),g=>g.id+' | '+g.sku+' | '+fmt(g.total));select('payInvoice',db.invoices.filter(i=>!db.payments.some(p=>p.invoice===i.id)),i=>i.id+' | '+fmt(i.total));$('kpo').textContent=db.pos.length;$('kgrn').textContent=db.grns.reduce((s,g)=>s+g.qty,0);let total=db.invoices.reduce((s,i)=>s+i.total,0),paid=db.payments.reduce((s,p)=>s+p.total,0);$('kap').textContent=fmt(total-paid);$('kunpaid').textContent=db.invoices.filter(i=>!db.payments.some(p=>p.invoice===i.id)).length;$('register').replaceChildren();db.pos.forEach(p=>row(['PO',p.id,p.supplier+' / '+p.sku,p.qty,fmt(p.qty*p.unit),p.received===p.qty?'Received':'Outstanding '+(p.qty-p.received)]));db.grns.forEach(g=>row(['GRN',g.id,g.sku,g.qty,fmt(g.total),'Received']));db.invoices.forEach(i=>row(['INVOICE',i.ref,i.supplier,i.qty,fmt(i.total),db.payments.some(p=>p.invoice===i.id)?'Paid':'Unpaid']));db.payments.forEach(p=>row(['PV',p.ref,p.method,'—',fmt(p.total),'Recorded']));$('control').textContent='Invoice total: GHS '+fmt(total)+' | Payments: GHS '+fmt(paid)+' | Outstanding accounts payable: GHS '+fmt(total-paid)+' | GRNs awaiting invoice: '+db.grns.filter(g=>!db.invoices.some(i=>i.grn===g.id)).length+'.'}$('supplierForm').addEventListener('submit',guard(f=>{let name=String(f.get('name')).trim();required(name,'Supplier name required');db.suppliers.push({id:uid('SUP'),name,contact:String(f.get('contact')),date:now()})}));$('poForm').addEventListener('submit',guard(f=>{let supplier=db.suppliers.find(s=>s.id===f.get('supplier')),sku=String(f.get('sku')),qty=Number(f.get('qty')),unit=amt(f.get('unit'));required(supplier&&retail.products.some(p=>p.sku===sku),'Select a supplier and product');required(Number.isSafeInteger(qty)&&qty>0&&qty<=100000&&Number.isSafeInteger(unit)&&unit>0,'Invalid quantity or cost');db.pos.push({id:uid('PO'),supplier:supplier.name,supplierId:supplier.id,sku,qty,unit,received:0,date:now()})}));$('grnForm').addEventListener('submit',guard(f=>{let po=db.pos.find(p=>p.id===f.get('po')),qty=Number(f.get('qty'));required(po&&Number.isSafeInteger(qty)&&qty>0&&qty<=po.qty-po.received,'Invalid receipt quantity');reloadRetail();let product=retail.products.find(p=>p.sku===po.sku);required(product,'Product missing in Retail');required(Number.isSafeInteger(product.qty)&&product.qty+qty<=100000000,'Invalid stock balance');let grn={id:uid('GRN'),po:po.id,sku:po.sku,supplier:po.supplier,qty,unit:po.unit,total:qty*po.unit,date:now()};required(Number.isSafeInteger(grn.total),'Amount too large');product.qty+=qty;retail.moves=Array.isArray(retail.moves)?retail.moves:[];retail.moves.push({id:grn.id,date:grn.date,sku:po.sku,type:'Procurement GRN',qty,reason:'Receipt against '+po.id});localStorage.setItem(RK,JSON.stringify(retail));po.received+=qty;db.grns.push(grn)}));$('invoiceForm').addEventListener('submit',guard(f=>{let g=db.grns.find(x=>x.id===f.get('grn')),ref=String(f.get('ref')).trim();required(g&&!db.invoices.some(i=>i.grn===g.id),'Select uninvoiced GRN');required(ref&&!db.invoices.some(i=>i.ref.toLowerCase()===ref.toLowerCase()),'Invoice reference must be unique');db.invoices.push({id:uid('INV'),grn:g.id,ref,supplier:g.supplier,qty:g.qty,total:g.total,date:now()})}));$('payForm').addEventListener('submit',guard(f=>{let i=db.invoices.find(x=>x.id===f.get('invoice')),ref=String(f.get('ref')).trim(),method=String(f.get('method'));required(i&&!db.payments.some(p=>p.invoice===i.id),'Select unpaid invoice');required(ref&&!db.payments.some(p=>p.ref.toLowerCase()===ref.toLowerCase()),'Voucher reference must be unique');required(['Bank','Cash','Mobile Money'].includes(method),'Invalid method');db.payments.push({id:uid('PAY'),invoice:i.id,ref,method,total:i.total,date:now()})}));$('export').addEventListener('click',()=>{let blob=new Blob([JSON.stringify({exportedAt:now(),procurement:db},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='freeman-procurement-v17-backup.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),2000)});$('print').addEventListener('click',()=>window.print());render();ON backup. No server-side authorisation is implemented.