bmc_hub/static/js/economic-catalog.js
Christian 631fc6cdca Implement four-eyes approval process for e-conomic changes
- Added migration to create economic_change_requests table for tracking change requests for customers and products.
- Introduced new permissions for requesting and approving changes in e-conomic.
- Developed frontend JavaScript functionality for managing economic catalog, including handling change requests and displaying their statuses.
- Created browser tests to validate UI interactions related to product management and change requests.
- Added unit tests for backend logic to ensure proper handling of product numbers, price rules, and economic write policies.
2026-09-13 02:30:52 +02:00

106 lines
18 KiB
JavaScript

(() => {
'use strict';
const $ = id => document.getElementById(id);
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let state = {}, products = [], refs = [], offset = 0, saveForm;
const labels = {pending:'Afventer',applying:'Udfører',approved:'Godkendt og udført',rejected:'Afvist',cancelled:'Annulleret',running:'Synkroniserer',retry:'Prøver igen',done:'Gennemført',failed:'Fejl',uncertain:'Uafklaret',synced:'Synkroniseret',local_only:'Kun i Hub',unlinked:'Ikke koblet',conflict:'Konflikt'};
async function api(path, method='GET', body) {
const res = await fetch('/api/v1' + path, {method,headers:{'Content-Type':'application/json'},body:body === undefined ? undefined : JSON.stringify(body)});
const data = await res.json();
if (!res.ok) throw Error(typeof data.detail === 'string' ? data.detail : `Kaldet fejlede (${res.status})`);
return data;
}
function feedback(message,error=false) { $('ecoFeedback').textContent=message; $('ecoFeedback').className='alert '+(error?'alert-danger':'alert-success'); }
const connection = () => Number($('ecoConnection').value);
function field(name,label,type='text',value='') { return `<label class="form-label d-block mt-2">${esc(label)}<input class="form-control" name="${name}" type="${type}" ${type==='number'?'step="any"':''} value="${esc(value)}"></label>`; }
function select(name,label,items) { return `<label class="form-label d-block mt-2">${esc(label)}<select name="${name}" class="form-select"><option value="">Vælg…</option>${items.map(x=>`<option value="${x.number}">${esc(x.number+' · '+x.name)}</option>`).join('')}</select></label>`; }
function modal(title,fields,save) { $('ecoDialogTitle').textContent=title; $('ecoFields').innerHTML=fields; $('ecoFormError').textContent=''; saveForm=save; $('ecoDialog').showModal(); }
async function refresh() {
state=await api('/economic/catalog/status');
const chosen=$('ecoConnection').value;
$('ecoConnection').innerHTML=state.connections.map(c=>`<option value="${c.id}">${esc(c.name)} · ${esc(c.agreement_number)}</option>`).join('');
if(chosen) $('ecoConnection').value=chosen;
const active=state.connections.find(c=>c.id===connection())?.enabled;
$('ecoMode').textContent=state.read_only||state.dry_run?'Safety mode: kun læsning':!active?'Integration ikke aktiveret':`Live-skrivning aktiveret${state.order_read_only||state.order_dry_run?' · Ordreeksport er dog blokeret af modulindstilling':''}`;
$('ecoJobRows').innerHTML=state.jobs.map(j=>`<tr><td>${esc(j.created_at)}</td><td>${esc(j.kind)} ${esc(j.product_id||'')}</td><td>${esc(labels[j.status]||j.status)}</td><td>${esc(j.error||JSON.stringify(j.result))}<small class="d-block text-muted">${esc(j.id)}</small></td><td>${j.status==='failed'&&j.kind!=='create'?`<button class="btn btn-sm btn-outline-primary" data-retry="${esc(j.id)}">Prøv igen</button>`:''}</td></tr>`).join('');
for (const job of state.jobs.filter(j=>j.status==='uncertain'&&j.kind==='create')) {
const row=document.createElement('tr');row.innerHTML=`<td colspan="5">Uafklaret vare ${esc(job.product_id)} <button class="btn btn-warning btn-sm" data-reconcile-product="${esc(job.id)}">Afstem vare</button></td>`;$('ecoJobRows').append(row);
}
$('ecoExportRows').innerHTML=(state.exports||[]).map(x=>`<tr><td>${esc(x.created_at)}</td><td>${esc(x.kind)}</td><td>${esc(x.status)}</td><td>${esc(x.economic_number||x.error||'—')}<small class="d-block">${esc(x.id)}</small></td><td>${x.status==='uncertain'?`<button class="btn btn-warning btn-sm" data-reconcile-export="${esc(x.id)}">Afstem</button>`:x.status==='rejected'?`<button class="btn btn-outline-primary btn-sm" data-retry-export="${esc(x.id)}">Frigiv til rettet forsøg</button>`:''}</td></tr>`).join('');
$('ecoChangeRows').innerHTML=(state.change_requests||[]).map(x=>{
const fields=Object.keys(x.requested_changes||{}).map(key=>`${esc(key)}: ${esc(JSON.stringify((x.before_snapshot||{})[key]))} → ${esc(JSON.stringify((x.proposed_snapshot||{})[key]))}`).join('<br>');
const actions=x.status==='pending'&&state.permissions['economic.changes.approve']?`<button class="btn btn-success btn-sm" data-approve-change="${esc(x.id)}">Godkend</button> <button class="btn btn-outline-danger btn-sm" data-reject-change="${esc(x.id)}">Afvis</button>`:'';
return `<tr><td>${esc(x.requested_at)}</td><td>${esc(x.target_type==='product'?'Vare':'Kunde')} ${esc(x.target_number)}</td><td>${esc(x.requester_name||x.requested_by_user_id)}</td><td>${fields}</td><td>${esc(x.reason)}</td><td>${esc(labels[x.status]||x.status)}${x.error?`<small class="d-block text-danger">${esc(x.error)}</small>`:''}</td><td>${actions}</td></tr>`;
}).join('');
document.querySelectorAll('[data-action="connect"],[data-action="config"],[data-action="preview"],[data-action="import"],[data-retry],[data-reconcile-product],[data-reconcile-export]').forEach(b=>b.hidden=!state.permissions['economic.catalog.manage']);
document.querySelectorAll('[data-tab="rules"],[data-action="rule"]').forEach(b=>b.hidden=!state.permissions['economic.pricing.manage']);
document.querySelectorAll('[data-action="document"],[data-retry-export]').forEach(b=>b.hidden=!state.permissions['economic.documents.export']);
document.querySelectorAll('[data-action="change"]').forEach(b=>b.hidden=!state.permissions['economic.changes.request']);
}
async function loadProducts() {
products=await api('/economic/catalog/products?q='+encodeURIComponent($('ecoSearch').value)+'&offset='+offset+'&include_inactive='+$('ecoInactive').checked);
$('ecoProductRows').innerHTML=products.map(p=>`<tr><td>${p.id}</td><td>${esc(p.economic_product_number||'—')}</td><td><a href="/products/${p.id}">${esc(p.name)}</a></td><td>${esc(p.sales_price??'—')} ${esc(p.sales_currency)}</td><td>${esc(p.economic_sales_price_reference??'—')} ${esc(p.economic_currency)}</td><td>${esc(labels[p.economic_sync_status]||p.economic_sync_status)}${p.is_active_in_economic===false?' · Spærret':''}</td><td><button class="btn btn-sm btn-outline-primary" data-product="${p.id}">Åbn</button></td></tr>`).join('');
[...$('ecoProductRows').rows].forEach((row,index)=>{
const p=products[index], cell=row.lastElementChild;
row.querySelector('[data-product]').hidden=!state.permissions['economic.catalog.manage'];
if(state.permissions['economic.pricing.manage'])cell.insertAdjacentHTML('beforeend',` <button class="btn btn-sm btn-outline-primary" data-price="${p.id}">Pris</button>`);
if(state.permissions['economic.catalog.manage'])cell.insertAdjacentHTML('beforeend',` <button class="btn btn-sm btn-outline-secondary" data-state="${p.id}">Status</button>`);
if(p.economic_product_number&&state.permissions['economic.catalog.rebind'])cell.insertAdjacentHTML('beforeend',` <button class="btn btn-sm btn-outline-danger" data-rebind="${p.id}">Omkobl</button>`);
});
}
async function loadRefs() {
refs=connection()?await api(`/economic/catalog/connections/${connection()}/references`):[];
$('ecoGroupRows').innerHTML=refs.filter(r=>r.kind==='product-groups').map(r=>`<tr><td>${r.number}</td><td>${esc(r.name)}</td><td>${esc(r.category_id||'—')}</td><td>${r.phasing_out?'Udfases':r.allow_new?'Tilladt':'Blokeret'}</td><td><button class="btn btn-sm btn-outline-primary" data-group="${r.number}">Redigér</button></td></tr>`).join('');
}
async function loadRules() { const rules=await api('/economic/catalog/price-rules'); $('ecoRuleRows').innerHTML=rules.map(r=>`<tr><td>${esc(r.name)}</td><td>${esc(r.kind)}</td><td>${esc(r.value)} ${esc(r.currency)}</td><td>${r.priority}</td><td>${r.active?`<button class="btn btn-sm btn-outline-danger" data-rule="${r.id}">Deaktivér</button>`:'Inaktiv'}</td></tr>`).join(''); }
async function action(name) {
if(name==='search'||name==='previous'||name==='next') {offset=name==='next'?offset+100:name==='previous'?Math.max(0,offset-100):0;return loadProducts();}
if(name==='connect') {const identity=await api('/economic/catalog/identity');return modal('Bekræft e-conomic-aftalen',field('agreement_number','Aftalenummer','text',identity.agreement_number)+field('name','Navn','text',identity.name)+field('currency','Aftalens basisvaluta','text',identity.currency), d=>api('/economic/catalog/connections','POST',d));}
if(!connection()) throw Error('Tilknyt først jeres e-conomic-aftale');
if(name==='document') {
return modal('Kontrollér ordreeksport',field('draft_id','Gemt ordrekladde-ID','number')+'<p>Hub opretter kun en ordrekladde. Fakturakladder, bogføring og afsendelse ændres aldrig fra Hub.</p>',async d=>{
const draft=await api(`/ordre/drafts/${Number(d.draft_id)}`);
const body={document_key:`order-draft:${draft.id}`,customer_id:draft.customer_id,lines:draft.lines_json,layout_number:draft.layout_number,currency:'DKK',notes:draft.notes};
const preview=await api('/economic/documents/order/preview','POST',body);
const total=preview.lines.reduce((sum,l)=>sum+Number(l.net_total),0).toFixed(2);
if(!window.confirm(`${preview.lines.length} linjer · ${total} DKK ekskl. moms.\nOpret ordrekladde i e-conomic?`))throw Error('Eksport annulleret; intet sendt.');
await api('/economic/documents/order/export','POST',body);
});
}
if(name==='preview'||name==='import') { const j=await api(`/economic/catalog/connections/${connection()}/sync?preview=${name==='preview'}`,'POST');feedback('Job oprettet: '+j.id);return refresh(); }
if(name==='config') {
const c=state.connections.find(x=>x.id===connection());
await loadRefs();
modal('Automatisk synkronisering',`<label><input type="checkbox" name="enabled" ${c.enabled?'checked':''}> Integration aktiv (streng varekontrol ved eksport)</label><br><label><input type="checkbox" name="auto_sync" ${c.auto_sync?'checked':''}> Automatisk import</label>`+field('sync_interval_minutes','Interval i minutter','number',c.sync_interval_minutes)+field('number_prefix','Præfiks for nye varer','text',c.number_prefix)+select('time_unit_number','Enhed for tidsregistrering (vælg timer)',refs.filter(r=>r.kind==='units')),d=>api(`/economic/catalog/connections/${connection()}`,'PATCH',{...d,enabled:d.enabled==='on',auto_sync:d.auto_sync==='on',sync_interval_minutes:Number(d.sync_interval_minutes),time_unit_number:Number(d.time_unit_number)||null}));
$('ecoFields').querySelector('[name="time_unit_number"]').value=c.time_unit_number||'';
return;
}
if(name==='rule') return modal('Ny prisregel',field('name','Navn')+field('product_id','Produkt-ID (valgfrit)','number')+field('customer_id','Kunde-ID (valgfrit)','number')+field('category_id','Kategori-ID (valgfrit)','number')+'<label class="d-block">Type<select class="form-select" name="kind"><option value="fixed">Fast pris</option><option value="discount">Rabat i %</option><option value="cost_markup">Tillæg på kost i %</option></select></label>'+field('value','Værdi','number')+field('currency','Valuta','text','DKK')+field('priority','Prioritet','number','0'),d=>api('/economic/catalog/price-rules','POST',{...d,product_id:Number(d.product_id)||null,customer_id:Number(d.customer_id)||null,category_id:Number(d.category_id)||null,value:Number(d.value),priority:Number(d.priority)}));
if(name==='change') return modal('Foreslå ændring i e-conomic','<label class="form-label d-block">Type<select class="form-select" name="target_type"><option value="customer">Kunde</option><option value="product">Vare</option></select></label>'+field('target_number','Kunde-/varenummer')+'<label class="form-label d-block mt-2">Felt<select class="form-select" name="field"><option value="name">Navn</option><option value="address">Adresse (kunde)</option><option value="zip">Postnummer (kunde)</option><option value="city">By (kunde)</option><option value="email">E-mail (kunde)</option><option value="telephoneAndFaxNumber">Telefon (kunde)</option><option value="salesPrice">Standardpris (vare)</option><option value="barred">Spærret true/false (vare)</option></select></label>'+field('value','Ny værdi')+field('reason','Begrundelse (mindst 10 tegn)')+'<p class="alert alert-info mt-3">Forslaget ændrer intet nu. En anden godkender skal kontrollere før/efter og godkende.</p>',d=>{let value=d.value;if(d.field==='salesPrice')value=Number(value);if(d.field==='barred')value=String(value).toLowerCase()==='true';return api('/economic/change-requests','POST',{connection_id:connection(),target_type:d.target_type,target_number:d.target_number,changes:{[d.field]:value},reason:d.reason});});
}
$('economicCatalog').addEventListener('click',async event=>{try {
const b=event.target.closest('button');if(!b)return;
if(b.dataset.tab) {for(const t of ['products','groups','rules','changes','jobs']) $('eco'+t[0].toUpperCase()+t.slice(1)).hidden=t!==b.dataset.tab;document.querySelectorAll('[data-tab]').forEach(n=>n.classList.toggle('active',n===b));if(b.dataset.tab==='groups')await loadRefs();if(b.dataset.tab==='rules')await loadRules();}
if(b.dataset.action)await action(b.dataset.action);
if(b.dataset.retry){await api(`/economic/catalog/jobs/${b.dataset.retry}/retry`,'POST');await refresh();}
if(b.dataset.reconcileProduct){await api(`/economic/catalog/jobs/${b.dataset.reconcileProduct}/reconcile`,'POST');await refresh();await loadProducts();}
if(b.dataset.reconcileExport){await api(`/economic/documents/exports/${b.dataset.reconcileExport}/reconcile`,'POST');await refresh();}
if(b.dataset.retryExport){await api(`/economic/documents/exports/${b.dataset.retryExport}/retry`,'POST');await refresh();}
if(b.dataset.rebind){const p=products.find(x=>x.id===Number(b.dataset.rebind));modal('Administrativ omkobling: '+p.name,`<p class="text-danger">Dette er en særskilt ændring af varens permanente kobling. Historiske dokumenter ændres ikke. Nuværende nummer: ${esc(p.economic_product_number)}</p>`+field('confirmation_number','Skriv det nuværende nummer for at bekræfte')+field('number','Nyt eksisterende varenummer på den valgte aftale')+field('reason','Begrundelse (mindst 10 tegn)'),d=>api(`/products/${p.id}/economic-rebind`,'POST',{...d,connection_id:connection()}));}
if(b.dataset.price){const p=products.find(x=>x.id===Number(b.dataset.price));modal('Hub-pris: '+p.name,field('sales_price','Standardpris ekskl. moms','number',p.sales_price??'')+field('sales_currency','Valuta','text',p.sales_currency||'DKK')+'<p>Dette ændrer ikke referenceprisen i e-conomic eller eksisterende aftalepriser.</p>',d=>api(`/products/${p.id}/economic-pricing`,'PATCH',d));}
if(b.dataset.state){const p=products.find(x=>x.id===Number(b.dataset.state));modal('Intern status: '+p.name,'<label>Status<select class="form-select" name="lifecycle_status">'+[['draft','Kladde'],['active','Aktiv'],['phasing_out','Udfases'],['inactive','Inaktiv']].map(([value,label])=>`<option value="${value}" ${p.lifecycle_status===value?'selected':''}>${label}</option>`).join('')+'</select></label>'+field('category_id','Kategori-ID','number',p.category_id||'')+field('subcategory_id','Underkategori-ID','number',p.subcategory_id||''),d=>api(`/products/${p.id}/economic-state`,'PATCH',{...d,category_id:Number(d.category_id)||null,subcategory_id:Number(d.subcategory_id)||null}));}
if(b.dataset.rule){await api(`/economic/catalog/price-rules/${b.dataset.rule}`,'DELETE');await loadRules();}
if(b.dataset.group){const r=refs.find(x=>x.kind==='product-groups'&&x.number===Number(b.dataset.group));modal('Varegruppe '+r.name,field('category_id','Standardkategori-ID','number',r.category_id||'')+`<label><input type="checkbox" name="allow_new" ${r.allow_new?'checked':''}> Tillad nye varer</label><br><label><input type="checkbox" name="phasing_out" ${r.phasing_out?'checked':''}> Udfases</label>`,d=>api(`/economic/catalog/connections/${connection()}/groups/${r.number}`,'PATCH',{category_id:Number(d.category_id)||null,allow_new:d.allow_new==='on',phasing_out:d.phasing_out==='on'}));}
if(b.dataset.product){const p=products.find(x=>x.id===Number(b.dataset.product));await loadRefs();if(p.economic_product_number)modal(p.name,field('name','Produktnavn i Hub','text',p.name)+`<p>Låst e-conomic-varenummer: ${esc(p.economic_product_number)}</p><p class="text-muted">Navnet gemmes kun i Hub. Det ændrer aldrig den eksisterende e-conomic-vare.</p>`+(p.economic_sync_status==='conflict'?'<button type="button" class="btn btn-outline-primary" data-accept-economic="'+p.id+'">Brug e-conomic-navn i Hub</button>':''),d=>api(`/products/${p.id}`,'PATCH',{name:d.name}));else modal(p.name,'<label>Handling<select name="operation" class="form-select"><option value="link">Knyt eksisterende varenummer</option><option value="create">Opret ny vare i e-conomic</option></select></label>'+field('number','Eksisterende varenummer (ved tilknytning)')+select('group','Varegruppe (ved oprettelse)',refs.filter(r=>r.kind==='product-groups'&&r.allow_new&&!r.phasing_out))+select('unit','Enhed (ved oprettelse)',refs.filter(r=>r.kind==='units')),d=>api(`/products/${p.id}/economic-${d.operation}`,'POST',d.operation==='link'?{connection_id:connection(),number:d.number}:{connection_id:connection(),group_number:Number(d.group),unit_number:Number(d.unit)||null}));}
if(b.dataset.acceptEconomic){await api(`/products/${b.dataset.acceptEconomic}/economic-resolve`,'POST',{choice:'economic'});await loadProducts();}
if(b.dataset.approveChange){if(window.confirm('Godkend og udfør denne ændring i e-conomic?')){await api(`/economic/change-requests/${b.dataset.approveChange}/approve`,'POST',{note:'Godkendt i Hub'});await refresh();}}
if(b.dataset.rejectChange){const note=window.prompt('Hvorfor afvises ændringen?','Afvist efter kontrol');if(note!==null){await api(`/economic/change-requests/${b.dataset.rejectChange}/reject`,'POST',{note});await refresh();}}
}catch(e){feedback(e.message,true);}});
$('ecoForm').addEventListener('submit',async e=>{e.preventDefault();const button=e.submitter||e.target.querySelector('[type="submit"]');button.disabled=true;try{await saveForm(Object.fromEntries(new FormData(e.target)));$('ecoDialog').close();feedback('Gemt. Eventuel synkronisering kan følges under Sync og fejl.');await refresh();await loadProducts();await loadRefs();if(!$('ecoRules').hidden)await loadRules();}catch(error){$('ecoFormError').textContent=error.message;}finally{button.disabled=false;}});
$('ecoCancel').onclick=()=>$('ecoDialog').close();
$('ecoConnection').onchange=()=>loadRefs().catch(e=>feedback(e.message,true));
$('ecoInactive').onchange=()=>loadProducts().catch(e=>feedback(e.message,true));
refresh().then(loadProducts).catch(e=>feedback(e.message,true));
setInterval(()=>{if(!document.hidden&&!$('ecoDialog').open)refresh().catch(e=>feedback(e.message,true));},15000);
})();