diff --git a/app/core/config.py b/app/core/config.py index 7f4cc13..795d144 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -181,6 +181,8 @@ class Settings(BaseSettings): VTIGER_URL: str = "" VTIGER_USERNAME: str = "" VTIGER_API_KEY: str = "" + # VTiger Cases custom checkbox/flag used after a case has been imported into Hub. + VTIGER_CASE_HUBBED_FIELD: str = "cf_cases_hubbet" # Data Consistency Settings VTIGER_SYNC_ENABLED: bool = True diff --git a/app/economy/backend/router.py b/app/economy/backend/router.py index a83d513..4dd89a9 100644 --- a/app/economy/backend/router.py +++ b/app/economy/backend/router.py @@ -110,6 +110,7 @@ def _hours_for_prepaid_card(row: Dict[str, Any], rounding_minutes: int) -> float async def list_hub_time_queue( customer_id: Optional[int] = Query(None, gt=0), status: Optional[str] = Query(None), + case_state: Optional[str] = Query(None), billable: Optional[bool] = Query(None), q: Optional[str] = Query(None), limit: int = Query(500, ge=1, le=2000), @@ -134,6 +135,15 @@ async def list_hub_time_queue( conditions.append("t.status = %s") params.append(status) + if case_state == "closed": + conditions.append("LOWER(TRIM(COALESCE(s.status, ''))) IN ('lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done')") + elif case_state == "open": + conditions.append("s.id IS NOT NULL AND LOWER(TRIM(COALESCE(s.status, ''))) NOT IN ('lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done')") + elif case_state == "no_case": + conditions.append("s.id IS NULL") + elif case_state: + raise HTTPException(status_code=400, detail="Ugyldigt sagsfilter") + if billable is not None: conditions.append("COALESCE(t.billable, true) = %s") params.append(billable) @@ -779,6 +789,7 @@ def _selected_time_entries(ids: List[int]) -> List[Dict[str, Any]]: t.original_hours, t.approved_hours, t.faktisk_tid_min, t.fakturerbar_tid_min, t.round_block_min, t.worked_date, t.description, COALESCE(c.title, s.titel, 'Tidsregistrering') AS case_title, + s.status AS case_status, COALESCE(case_customer.name, effective_customer.name) AS customer_name, COALESCE(s.customer_id, effective_customer.hub_customer_id) AS hub_customer_id, COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_customer_id @@ -856,8 +867,10 @@ def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]: item = { "id": int(row["id"]), "title": row.get("case_title") or "Tidsregistrering", + "case_status": row.get("case_status"), "customer_name": row.get("customer_name") or "Ukendt kunde", "hours": hours, + "actual_hours": float(row.get("faktisk_tid_min") or round(float(row.get("original_hours") or 0) * 60)) / 60.0, "method": method, "method_label": _settlement_label(method), "valid": True, @@ -908,8 +921,10 @@ def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]: "SELECT name, COALESCE(standard_hourly_rate, %s) AS hourly_rate FROM customers WHERE id = %s", (settings.TIMETRACKING_DEFAULT_HOURLY_RATE, customer_id), ) or {} + # Invoice quantities must match the draft that will be created below. + # Per-entry rounding only applies when debiting a prepaid card. hours = sum( - _hours_for_prepaid_card(row, int(card.get("rounding_minutes") or 30)) + float(row.get("approved_hours") or row.get("original_hours") or 0) for row in group_rows ) rate = float(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE) diff --git a/app/economy/frontend/time_queue.html b/app/economy/frontend/time_queue.html index f2bf720..d1156a0 100644 --- a/app/economy/frontend/time_queue.html +++ b/app/economy/frontend/time_queue.html @@ -19,14 +19,15 @@ @media (max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.35rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 30px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}} .queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem} .case-detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.case-detail-meta div{padding:.65rem .75rem;background:#f6f8fa;border-radius:.55rem}.case-detail-meta small{display:block;color:#6c757d;margin-bottom:.12rem}@media(max-width:576px){.case-detail-meta{grid-template-columns:1fr}} +.case-closed-badge{background:#e8f5ec;color:#176b36;border:1px solid #bee3c8}.case-open-badge{background:#fff5dc;color:#805b00;border:1px solid #f1dda0}.preview-items{max-height:320px;overflow:auto}.preview-item{border-bottom:1px solid #e7edf1;padding:.55rem 0}.preview-item:last-child{border-bottom:0}
Økonomi

Tidskø til afregning

Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.

-
Registreringer
–
Timer i alt
–
Klar til faktura
–
Udvalgt
0
Intet afregnes uden forhåndsvisning.
-
-

Afregning for valgte tidsregistreringer

Vælg type og eventuelt klippekort eller aftale.
0 valgt
Vælg tidsregistreringer for at se kundens klippekort.
-
Forhåndsvisning af afregning
Kontrollér beløb og fejl før afregningen gennemføres.
+
Registreringer
–
Timer i alt
–
Klar til faktura
–
Sager løst/lukket
–
Udvalgt
0
Forhåndsvisning kontrollerer; den ændrer ikke data.
+
+

Afregning for valgte tidsregistreringer

Vælg type og eventuelt klippekort eller aftale. Løst/lukket sagsstatus forhindrer ikke afregning.
0 valgt
Vælg tidsregistreringer for at se kundens klippekort.
+
Forhåndsvisning af afregning
Dette er en kontrol uden ændringer. Først når du trykker “Gennemfør”, gemmes afregningen.
0 tidsregistreringer valgtVælg forhåndsvisning før afregning.
@@ -41,25 +42,34 @@ async function api(url,options={}){const r=await fetch(url,{headers:{'Content-Type':'application/json'},...options});const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='object'?(d.detail.message||'Handlingen kunne ikke gennemføres'):(d.detail||'Handlingen kunne ikke gennemføres'));e.payload=d;throw e}return d} function payload(){const method=$('settlementMethod').value,card=Number($('prepaidCard').value),agreement=Number($('agreementId').value);return {ids:ids(),...(method?{billing_method:method}:{}),...(card?{prepaid_card_id:card}:{}),...(agreement?{fixed_price_agreement_id:agreement}:{})}} function status(v){return `${esc(labels[v]||v||'Ukendt')}`} function method(v){const m=norm(v),cls=m==='invoice'?'text-bg-primary':m==='prepaid'?'text-bg-purple':m==='subscription'?'text-bg-success':'text-bg-secondary';return `${esc(labels[m]||m)}`} - function renderStats(){const selected=state.items.filter(x=>state.selected.has(Number(x.id)));$('statEntries').textContent=state.items.length;$('statHours').textContent=`${h(state.items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0))} t`;$('statInvoice').textContent=state.items.filter(x=>norm(x.billing_method)==='invoice'&&x.billable!==false).length;$('statSelected').textContent=selected.length;$('selectedBadge').textContent=`${selected.length} valgt`;$('selectionText').textContent=`${selected.length} tidsregistrering${selected.length===1?'':'er'} valgt`;$('selectionActions').classList.toggle('d-none',!selected.length)} + function isClosedCase(value){return ['lukket','løst','closed','resolved','afsluttet','done'].includes(String(value||'').trim().toLocaleLowerCase('da-DK'))} function caseStatus(v){if(!v)return 'Uden sag';return `Sag: ${esc(v)}`} + function renderStats(){const selected=state.items.filter(x=>state.selected.has(Number(x.id)));$('statEntries').textContent=state.items.length;$('statHours').textContent=`${h(state.items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0))} t`;$('statInvoice').textContent=state.items.filter(x=>norm(x.billing_method)==='invoice'&&x.billable!==false).length;$('statClosedCases').textContent=state.items.filter(x=>isClosedCase(x.case_status)).length;$('statSelected').textContent=selected.length;$('selectedBadge').textContent=`${selected.length} valgt`;$('selectionText').textContent=`${selected.length} tidsregistrering${selected.length===1?'':'er'} valgt`;$('selectionActions').classList.toggle('d-none',!selected.length)} function selectedRows(){return state.items.filter(x=>state.selected.has(Number(x.id)))} function selectedCardCustomerIds(){return [...new Set(selectedRows().map(x=>Number(x.billing_customer_id||x.hub_customer_id||0)).filter(Boolean))]} function selectedHours(){return selectedRows().reduce((total,x)=>total+Number(x.approved_hours||x.original_hours||0),0)} function actualHours(rows){return rows.reduce((total,x)=>total+Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)/60,0)} function cardHours(rows,block){const safeBlock=Math.max(1,Number(block)||30);return rows.reduce((total,x)=>{const minutes=Math.max(0,Math.round(Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)));return total+(minutes?Math.ceil(minutes/safeBlock)*safeBlock/60:0)},0)} function renderPrepaidHelp(){const selected=selectedRows(),customers=selectedCardCustomerIds(),card=state.cards.find(x=>Number(x.id)===Number($('prepaidCard').value)),storedHours=selectedHours();if(!selected.length){$('prepaidHelp').textContent='Vælg tidsregistreringer for at se kundens klippekort.';return}if(customers.length!==1){$('prepaidHelp').innerHTML='Vælg kun tider fra ét firma for at bruge ét klippekort.';return}const company=selected[0].customer_name||'den valgte kunde';if(!card){$('prepaidHelp').textContent=`${esc(company)} · Fakturerbar tid: ${h(storedHours)} timer. Vælg et klippekort for at se kortets afrunding.`;return}const block=Number(card.rounding_minutes)||30,actual=actualHours(selected),hours=cardHours(selected,block),remaining=Number(card.remaining_hours||0),after=remaining-hours;$('prepaidHelp').innerHTML=`${esc(company)} · Faktisk tid: ${h(actual)} t → klippes som ${h(hours)} t (${block} min. afrunding pr. registrering). Kortet har ${h(remaining)} t tilbage → ${h(after)} t efter afregning.`} - function render(){renderStats();if(!state.items.length){$('queueContent').innerHTML='
Ingen tidsregistreringer matcher dine filtre.
';return}const groups={};state.items.forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `
${esc(f.customer_name||'Ukendt kunde')}${items.length} registreringer · ${h(total)} timer
${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `
${x.sag_id?``:esc(x.case_title||'Ingen sag')}
${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}
Dato og status
${esc(x.worked_date||'Ingen dato')} ${status(x.status)}
Tid
${h(x.approved_hours||x.original_hours)} t · ${esc(x.work_type||x.entry_type||'arbejde')}
Afregning
${method(m)} ${x.billable===false?'ikke fakturerbar':''}
${x.description?`
${esc(x.description)}
`:'
Mangler beskrivelse
'}
`}).join('')}
`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{e.dataset.ids.split(',').forEach(id=>state.selected.add(Number(id)));selectionChanged()}))} - function url(){const p=new URLSearchParams({limit:'500'});if($('filterCustomer').value)p.set('customer_id',$('filterCustomer').value);if($('filterStatus').value)p.set('status',$('filterStatus').value);if($('filterBillable').value)p.set('billable',$('filterBillable').value);if($('filterQuery').value.trim())p.set('q',$('filterQuery').value.trim());return `/api/v1/economy/time-queue?${p}`} + function render(){renderStats();if(!state.items.length){$('queueContent').innerHTML='
Ingen tidsregistreringer matcher dine filtre.
';return}const groups={};state.items.forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `
${esc(f.customer_name||'Ukendt kunde')}${items.length} registreringer · ${h(total)} timer
${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `
${x.sag_id?``:esc(x.case_title||'Ingen sag')}
${caseStatus(x.case_status)} ${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}
Dato og status
${esc(x.worked_date||'Ingen dato')} ${status(x.status)}
Tid
${h(x.approved_hours||x.original_hours)} t · ${esc(x.work_type||x.entry_type||'arbejde')}
Afregning
${method(m)} ${x.billable===false?'ikke fakturerbar':''}
${x.description?`
${esc(x.description)}
`:'
Mangler beskrivelse
'}
`}).join('')}
`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{const group=e.dataset.ids.split(',').map(Number),allSelected=group.every(id=>state.selected.has(id));group.forEach(id=>allSelected?state.selected.delete(id):state.selected.add(id));selectionChanged()}))} + function url(){const p=new URLSearchParams({limit:'500'});if($('filterCaseState').value)p.set('case_state',$('filterCaseState').value);if($('filterCustomer').value)p.set('customer_id',$('filterCustomer').value);if($('filterStatus').value)p.set('status',$('filterStatus').value);if($('filterBillable').value)p.set('billable',$('filterBillable').value);if($('filterQuery').value.trim())p.set('q',$('filterQuery').value.trim());return `/api/v1/economy/time-queue?${p}`} async function loadCustomers(){try{const d=await api('/api/v1/economy/time-queue/customers'),v=$('filterCustomer').value;$('filterCustomer').innerHTML=`${(d.items||[]).map(x=>``).join('')}`;$('filterCustomer').value=v}catch(_){message('warning','Firmafilteret kunne ikke hentes.') }} async function loadCards(){const select=$('prepaidCard'),customers=selectedCardCustomerIds(),current=Number(select.value);state.cards=[];select.disabled=true;if(customers.length!==1){select.innerHTML=``;renderPrepaidHelp();return}try{const d=await api(`/api/v1/economy/time-queue/prepaid-cards?customer_id=${customers[0]}`);state.cards=(d.items||[]).filter(x=>x.status==='active'&&Number(x.remaining_hours)>0);select.innerHTML=`${state.cards.map(x=>``).join('')}`;select.disabled=!state.cards.length;if(state.cards.some(x=>Number(x.id)===current))select.value=String(current);renderPrepaidHelp()}catch(_){select.innerHTML='';renderPrepaidHelp()}} async function load(){if(state.loading)return;state.loading=true;$('queueContent').innerHTML='
Henter tidsregistreringer…
';try{const d=await api(url());state.items=d.items||[];state.selected=new Set(ids().filter(id=>state.items.some(x=>Number(x.id)===id)));state.preview=null;$('previewPanel').classList.add('d-none');render();await loadCards()}catch(e){$('queueContent').innerHTML=`
${esc(e.message)}
`}finally{state.loading=false}} async function saveMethod(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');const m=$('settlementMethod').value;if(!m)return message('warning','Vælg en afregningstype, der skal gemmes.');try{await api('/api/v1/economy/time-queue/bulk-update',{method:'PATCH',body:JSON.stringify({ids:ids(),billing_method:m,billable:['invoice','prepaid'].includes(m)})});message('success','Afregningstypen er gemt.');await load()}catch(e){message('danger',e.message)}} async function openCaseDetails(sagId){const row=state.items.find(x=>Number(x.sag_id)===sagId)||{},modal=bootstrap.Modal.getOrCreateInstance($('caseDetailModal'));$('caseDetailTitle').textContent=`#${sagId} · ${row.case_title||'Sag'}`;$('openCaseNewTab').href=`/sag/${sagId}/v3`;$('caseDetailBody').innerHTML='
Henter sagsdetaljer…
';modal.show();try{const s=await api(`/api/v1/sag/${sagId}`),description=s.beskrivelse||row.description||'Ingen beskrivelse på sagen.';$('caseDetailBody').innerHTML=`
${status(s.status)}${esc(s.type||'Sag')}${esc(s.prioritet||'Normal')}
Kunde${esc(row.customer_name||'Ikke tilknyttet')}
Kontakt${esc(row.contact_name||'Ikke tilknyttet')}
Ansvarlig${esc(s.ansvarlig_navn||'Ikke tildelt')}
Senest opdateret${esc(s.updated_at||s.opdateret_at||'Ikke oplyst')}

Beskrivelse

${esc(description).replace(/\n/g,'
')}
`}catch(e){$('caseDetailBody').innerHTML=`
${esc(e.message)}
`}} - function previewView(d){state.preview=d;$('previewPanel').classList.remove('d-none');const errors=d.errors||[],invoice=d.invoice_groups||[],prepaid=d.prepaid_groups||[],sub=d.subscription_groups||[];$('previewBody').innerHTML=`${errors.length?`
Afregningen kan ikke gennemføres endnu.
`:'
Alle valgte tider er klar til afregning.
'}
${invoice.map(x=>`
Faktura · ${esc(x.customer_name)}
${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t
${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms
Gebyr og evt. fragt vises i ordrekladden.
`).join('')}${prepaid.map(x=>`
Klippekort · ${esc(x.card_number||'#'+x.card_id)}
Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t
`).join('')}${sub.map(x=>`
Abonnement / fast pris · aftale #${Number(x.agreement_id)}
${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden fakturalinje.
`).join('')}
`;$('settleBtn').disabled=!d.valid;$('previewPanel').scrollIntoView({behavior:'smooth',block:'center'})} + function previewView(d){ + state.preview=d; + $('previewPanel').classList.remove('d-none'); + const errors=d.errors||[],invoice=d.invoice_groups||[],prepaid=d.prepaid_groups||[],sub=d.subscription_groups||[],items=d.items||[]; + const itemRows=items.map(x=>`
${esc(x.title||`Tid #${x.id}`)} ${esc(x.customer_name||'Ukendt kunde')}
${caseStatus(x.case_status)} Sagstatus er kun information; løst/lukket sag må gerne afregnes.
${h(x.hours)} t
Faktisk ${h(x.actual_hours)} t · ${esc(labels[x.method]||x.method_label||x.method)}
${x.message?`
${esc(x.message)}
`:''}
`).join(''); + $('previewBody').innerHTML=`
Hvad gør forhåndsvisning? Den kontrollerer valgte registreringer, kunde, klippekort/aftale, timer og beløb. Den gemmer ikke noget og trækker ikke timer. Det sker først efter “Gennemfør afregning”.
${errors.length?`
Afregningen kan ikke gennemføres endnu.
`:'
Alle valgte tider kan afregnes. Løst/lukket sagsstatus blokerer ikke.
'}
${invoice.map(x=>`
Faktura · ${esc(x.customer_name)}
${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t
${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms
Gebyr og evt. fragt vises i ordrekladde.
`).join('')}${prepaid.map(x=>`
Klippekort · ${esc(x.card_number||'#'+x.card_id)}
Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t · efter: ${h(Number(x.remaining_hours)-Number(x.hours))} t
Afrunding: ${Number(x.rounding_minutes)||30} min. pr. registrering
`).join('')}${sub.map(x=>`
Abonnement / fast pris · aftale #${Number(x.agreement_id)}
${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden særskilt fakturalinje.
`).join('')}

Valgte tidsregistreringer (${items.length})

${itemRows||'
Ingen registreringer.
'}
`; + $('settleBtn').disabled=!d.valid; + $('previewPanel').scrollIntoView({behavior:'smooth',block:'center'}); + } async function preview(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');try{previewView(await api('/api/v1/economy/time-queue/preview-settlement',{method:'POST',body:JSON.stringify(payload())}))}catch(e){message('danger',e.message)}} async function settle(){if(!state.preview?.valid)return;$('settleBtn').disabled=true;$('settleBtn').innerHTML='Afregner…';try{const d=await api('/api/v1/economy/time-queue/settle',{method:'POST',body:JSON.stringify(payload())});message('success',`Afregningen er gennemført.${d.created_drafts?.length?' Ordrekladder er oprettet.':''}`);state.selected.clear();$('previewPanel').classList.add('d-none');if(d.orders_url)$('messageArea').insertAdjacentHTML('beforeend',`

Åbn ordrekladde

`);await loadCustomers();await load()}catch(e){message('danger',e.message);if(e.payload?.detail?.preview)previewView(e.payload.detail.preview)}finally{$('settleBtn').disabled=false;$('settleBtn').innerHTML='Gennemfør afregning'}} function selectionChanged(){state.preview=null;$('previewPanel').classList.add('d-none');render();loadCards()} - $('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=renderPrepaidHelp;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=()=>{$('previewPanel').classList.add('d-none');state.preview=null};$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load(); + $('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterCaseState').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterCaseState'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=renderPrepaidHelp;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=()=>{$('previewPanel').classList.add('d-none');state.preview=null};$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load(); })(); {% endblock %} diff --git a/app/modules/migration_center/backend/router.py b/app/modules/migration_center/backend/router.py index a00fbe9..b0cb50f 100644 --- a/app/modules/migration_center/backend/router.py +++ b/app/modules/migration_center/backend/router.py @@ -2,13 +2,17 @@ from __future__ import annotations import json -from datetime import date +import re +import hashlib +import math +from html.parser import HTMLParser +from datetime import date, datetime, time as datetime_time, timedelta from decimal import Decimal from typing import Any, Dict, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import Response -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from psycopg2.extras import Json, RealDictCursor from app.core.auth_dependencies import require_any_permission, require_permission @@ -85,6 +89,397 @@ class CreatePayload(BaseModel): idempotency_key: str = Field(min_length=8, max_length=120) +class VtigerCaseImportRequest(BaseModel): + ticket_number: str = Field(min_length=3, max_length=80) + + @field_validator("ticket_number") + @classmethod + def validate_ticket_number(cls, value: str) -> str: + normalized = str(value or "").strip().upper() + if not re.fullmatch(r"CC[0-9]{1,12}", normalized): + raise ValueError("Indtast et sagsnummer i formatet CC12345") + return normalized + + +class HubCaseExportRequest(BaseModel): + sag_id: int = Field(gt=0) + + +class HubCaseExportConfirmRequest(HubCaseExportRequest): + source_hash: str = Field(min_length=64, max_length=64) + + +def _vtiger_reference_id(value: Any) -> Optional[str]: + if isinstance(value, dict): + value = value.get("value") or value.get("id") + value = str(value or "").strip() + return value if re.fullmatch(r"[A-Za-z0-9_-]+x[0-9]+", value) else None + + +def _vtiger_case_number(raw: Dict[str, Any]) -> str: + for key in ("ticket_no", "ticketnumber", "ticket_number", "case_no", "casenumber"): + value = str(raw.get(key) or "").strip() + if value: + return value.upper() + return "" + + +def _case_status_from_vtiger(value: Any) -> str: + status = _vtiger_text(value).lower() + if status in {"closed", "lukket", "afsluttet", "done", "cancelled", "canceled"}: + return "Lukket" + if status in {"resolved", "løst", "lost"}: + return "Løst" + if status in {"assigned", "tildelt"}: + return "Tildelt" + if status in {"in progress", "in_progress", "igangværende", "open", "åben", "reopened"}: + return "Igangværende" if status not in {"open", "åben"} else "Ny" + if status in {"waiting", "waiting_customer", "waiting_internal", "on_hold", "afventer"}: + return "Afventer" + return "Ny" + + +def _case_priority_from_vtiger(value: Any) -> str: + priority = _vtiger_text(value).lower() + if priority in {"urgent", "critical", "akut"}: + return "urgent" + if priority in {"high", "høj"}: + return "high" + if priority in {"low", "lav"}: + return "low" + return "normal" + + +def _vtiger_text(value: Any) -> str: + if isinstance(value, dict): + value = value.get("label") or value.get("value") or "" + return str(value or "").strip() + + +class _VTigerPlainTextParser(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.parts = [] + + def handle_starttag(self, tag, attrs): + if tag.lower() in {"br", "p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6"}: + self.parts.append("\n") + + def handle_endtag(self, tag): + if tag.lower() in {"p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6"}: + self.parts.append("\n") + + def handle_data(self, data): + self.parts.append(data) + + +def _vtiger_plain_text(value: Any) -> str: + raw = _vtiger_text(value) + if not raw: + return "" + parser = _VTigerPlainTextParser() + parser.feed(raw) + text = re.sub(r"\n[ \t]*\n+", "\n\n", "".join(parser.parts)) + return text.strip() + + +def _vtiger_timestamp(value: Any) -> Optional[datetime]: + if not value: + return None + text = str(value).strip().replace("T", " ") + try: + return datetime.fromisoformat(text[:19]) + except ValueError: + return None + + +def _timelog_source_id(row: Dict[str, Any]) -> str: + return str(row.get("id") or "").strip() + + +def _timelog_description(row: Dict[str, Any], *, legacy: bool = False) -> str: + # Older imports preferred the generic record name (often "migration"). + keys = ("name", "cf_timelog_description", "description") if legacy else ( + "description", "cf_timelog_description", "name" + ) + for key in keys: + value = _vtiger_plain_text(row.get(key)) + if value: + return value + return "Importerede vTiger-timer" + + +async def _resolve_vtiger_assignee_names(timelogs: List[Dict[str, Any]]) -> Dict[str, str]: + """Resolve time-entry owners in two batch queries instead of one call per row.""" + refs = sorted({ + ref for row in timelogs + if (ref := _vtiger_reference_id(row.get("assigned_user_id"))) + }) + if not refs: + return {} + id_list = "', '".join(refs) + vtiger = get_vtiger_service() + names: Dict[str, str] = {} + users = await vtiger.query( + "SELECT id, first_name, last_name, user_name FROM Users " + f"WHERE id IN ('{id_list}');" + ) + for row in users or []: + ref = _vtiger_reference_id(row.get("id")) + if not ref: + continue + full_name = " ".join( + part for part in (_vtiger_text(row.get("first_name")), _vtiger_text(row.get("last_name"))) if part + ).strip() + names[ref] = full_name or _vtiger_text(row.get("user_name")) or ref + groups = await vtiger.query( + "SELECT id, groupname FROM Groups " + f"WHERE id IN ('{id_list}');" + ) + for row in groups or []: + ref = _vtiger_reference_id(row.get("id")) + if ref and ref not in names: + names[ref] = _vtiger_text(row.get("groupname")) or ref + return names + + +async def _load_vtiger_case_bundle(ticket_number: str) -> Dict[str, Any]: + normalized = str(ticket_number or "").strip().upper() + if not re.fullmatch(r"CC[0-9]{1,12}", normalized): + raise HTTPException(status_code=400, detail="Indtast et sagsnummer i formatet CC12345") + vtiger = get_vtiger_service() + case = None + for field in ("ticket_no", "ticketnumber", "ticket_number", "case_no"): + safe_value = normalized.replace("'", "''") + records = await vtiger.query(f"SELECT * FROM Cases WHERE {field}='{safe_value}' LIMIT 2;") + if records: + if len(records) > 1: + raise HTTPException(status_code=409, detail=f"Flere vTiger-sager matcher {normalized}; overførslen er stoppet") + case = records[0] + break + if not case: + raise HTTPException(status_code=404, detail=f"Sagen {normalized} blev ikke fundet i vTiger") + + vtiger_case_id = _vtiger_reference_id(case.get("id")) + if not vtiger_case_id: + raise HTTPException(status_code=502, detail="vTiger-sagen mangler et gyldigt post-id") + actual_number = _vtiger_case_number(case) or normalized + if actual_number != normalized: + raise HTTPException(status_code=409, detail=f"vTiger returnerede {actual_number} i stedet for {normalized}; overførslen er stoppet") + account_id = _vtiger_reference_id(case.get("parent_id") or case.get("account_id")) + if not account_id: + raise HTTPException(status_code=409, detail="Sagen har ingen entydig vTiger-kundetilknytning") + + customer = execute_query_single( + "SELECT id, name, vtiger_id FROM customers WHERE vtiger_id = %s AND deleted_at IS NULL", + (account_id,), + ) + if not customer: + account_rows = await vtiger.query(f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;") + account = account_rows[0] if account_rows else {} + account_name = str(account.get("accountname") or "").strip() + if account_name: + matches = execute_query( + "SELECT id, name, vtiger_id FROM customers WHERE LOWER(TRIM(name)) = LOWER(TRIM(%s)) AND deleted_at IS NULL ORDER BY id LIMIT 2", + (account_name,), + ) or [] + if len(matches) == 1: + customer = matches[0] + if not customer: + raise HTTPException( + status_code=409, + detail=f"Kunden {account_id} er ikke entydigt tilknyttet en Hub-kunde. Link kunden først.", + ) + + timelogs = await vtiger.query( + f"SELECT * FROM Timelog WHERE relatedto='{vtiger_case_id}' ORDER BY startedon LIMIT 1001;" + ) + comments = await vtiger.query( + f"SELECT * FROM ModComments WHERE related_to='{vtiger_case_id}' ORDER BY createdtime LIMIT 1000;" + ) + # vTiger installations can ignore filters on reference fields; enforce the + # relationship locally before any source records are shown or imported. + timelogs = [row for row in (timelogs or []) if _vtiger_reference_id(row.get("relatedto")) == vtiger_case_id] + comments = [row for row in (comments or []) if _vtiger_reference_id(row.get("related_to")) == vtiger_case_id] + if len(timelogs) > 1000: + raise HTTPException(status_code=409, detail="Sagen har mere end 1.000 tidsregistreringer. Overførslen er stoppet for at undgå en ufuldstændig kopi.") + assignee_names = await _resolve_vtiger_assignee_names(timelogs) + contact_id = _vtiger_reference_id(case.get("contact_id") or case.get("contactid")) + contact = execute_query_single( + "SELECT id, first_name, last_name FROM contacts WHERE vtiger_id = %s", + (contact_id,), + ) if contact_id else None + imported = execute_query_single( + "SELECT hub_sag_id, vtiger_marked_at FROM vtiger_case_hub_imports WHERE vtiger_case_id = %s", + (vtiger_case_id,), + ) + return { + "case": case, + "ticket_number": actual_number, + "vtiger_case_id": vtiger_case_id, + "account_id": account_id, + "customer": customer, + "contact": contact, + "timelogs": timelogs or [], + "timelog_assignee_names": assignee_names, + "comments": comments or [], + "existing_import": imported, + } + + +def _import_vtiger_case_bundle(bundle: Dict[str, Any], current_user: dict) -> Dict[str, Any]: + """Create a native Hub case and link source time entries atomically.""" + conn = get_db_connection() + imported_now = False + vtiger_case_id = bundle["vtiger_case_id"] + case = bundle["case"] + customer = bundle["customer"] + current_user_id = user_id(current_user) + imported_time_entries = 0 + try: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute( + "SELECT hub_sag_id, vtiger_marked_at FROM vtiger_case_hub_imports WHERE vtiger_case_id=%s FOR UPDATE", + (vtiger_case_id,), + ) + mapped = cursor.fetchone() + if mapped: + sag_id = mapped["hub_sag_id"] + # Repair legacy imports without changing approval or billing state. + assignee_names = bundle.get("timelog_assignee_names", {}) + for timelog in bundle.get("timelogs", []): + source_id = _timelog_source_id(timelog) + assignee_ref = _vtiger_reference_id(timelog.get("assigned_user_id")) + old_user_name = _vtiger_text(timelog.get("assigned_user_id")) + resolved_user_name = assignee_names.get(assignee_ref) + if not source_id: + continue + if assignee_ref and resolved_user_name and resolved_user_name != old_user_name: + cursor.execute( + """UPDATE tmodule_times SET user_name=%s + WHERE vtiger_id=%s AND sag_id=%s AND user_name=%s""", + (resolved_user_name, source_id, sag_id, old_user_name), + ) + old_description = _timelog_description(timelog, legacy=True) + new_description = _timelog_description(timelog) + if old_description != new_description: + cursor.execute( + """UPDATE tmodule_times SET description=%s + WHERE vtiger_id=%s AND sag_id=%s AND description=%s""", + (new_description, source_id, sag_id, old_description), + ) + else: + cursor.execute( + """INSERT INTO sag_sager + (titel, beskrivelse, template_key, type, status, priority, customer_id, created_by_user_id) + VALUES (%s,%s,'ticket','ticket',%s,%s,%s,%s) RETURNING id""", + ( + _vtiger_text(case.get("ticket_title") or case.get("subject")) or bundle["ticket_number"], + _vtiger_text(case.get("description")), + _case_status_from_vtiger(case.get("status")), + _case_priority_from_vtiger(case.get("priority")), + customer["id"], current_user_id, + ), + ) + sag_id = cursor.fetchone()["id"] + + contact = bundle.get("contact") + if contact: + cursor.execute( + "INSERT INTO sag_kontakter (sag_id, contact_id, role) VALUES (%s,%s,'Primær kontakt')", + (sag_id, contact["id"]), + ) + + cursor.execute( + """INSERT INTO tmodule_customers (vtiger_id, name, hub_customer_id, vtiger_data) + VALUES (%s,%s,%s,%s) ON CONFLICT (vtiger_id) DO UPDATE + SET hub_customer_id=COALESCE(tmodule_customers.hub_customer_id, EXCLUDED.hub_customer_id) + RETURNING id""", + (bundle["account_id"], customer["name"], customer["id"], Json({"id": bundle["account_id"]})), + ) + tcustomer_id = cursor.fetchone()["id"] + cursor.execute( + """INSERT INTO tmodule_cases + (vtiger_id, customer_id, title, description, status, priority, module_type, vtiger_data, sync_hash) + VALUES (%s,%s,%s,%s,%s,%s,'Cases',%s,%s) + ON CONFLICT (vtiger_id) DO UPDATE SET + customer_id=EXCLUDED.customer_id, title=EXCLUDED.title, + description=EXCLUDED.description, status=EXCLUDED.status, + priority=EXCLUDED.priority, vtiger_data=EXCLUDED.vtiger_data, + sync_hash=EXCLUDED.sync_hash, last_synced_at=CURRENT_TIMESTAMP + RETURNING id""", + ( + vtiger_case_id, tcustomer_id, + _vtiger_text(case.get("ticket_title") or case.get("subject")) or bundle["ticket_number"], + _vtiger_text(case.get("description")), _vtiger_text(case.get("status")), + _vtiger_text(case.get("priority")), Json(case), + hashlib.sha256(json.dumps(case, sort_keys=True, default=str).encode()).hexdigest(), + ), + ) + tcase_id = cursor.fetchone()["id"] + + for timelog in bundle.get("timelogs", []): + source_id = _timelog_source_id(timelog) + seconds = _amount(timelog.get("duration")) + if not source_id or seconds <= 0: + continue + hours = (seconds / Decimal("3600")).quantize(Decimal("0.01")) + if hours <= 0: + continue + billable = str(timelog.get("isbillable") or "0") == "1" + already_billed = any(str(timelog.get(key) or "0") not in {"", "0"} for key in ( + "cf_timelog_invoiced", "billed_via_thehub_id", "cf_timelog_billedviathehubid", + "cf_timelog_rounduptimespent", + )) + start = _vtiger_timestamp(timelog.get("startedon") or timelog.get("createdtime")) + data_hash = hashlib.sha256(json.dumps(timelog, sort_keys=True, default=str).encode()).hexdigest() + cursor.execute( + """INSERT INTO tmodule_times + (vtiger_id, case_id, customer_id, sag_id, description, original_hours, + worked_date, user_name, billable, vtiger_data, sync_hash, status, + approved_hours, billing_method, faktisk_tid_min, fakturerbar_tid_min, + start_tid, entry_status, kilde, entry_type, work_type) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'api','ukendt','support') + ON CONFLICT (vtiger_id) DO UPDATE SET sag_id=EXCLUDED.sag_id + WHERE tmodule_times.case_id=EXCLUDED.case_id + AND (tmodule_times.sag_id IS NULL OR tmodule_times.sag_id=EXCLUDED.sag_id) + RETURNING id""", + ( + source_id, tcase_id, tcustomer_id, sag_id, + _timelog_description(timelog), + hours, start.date() if start else _safe_date(timelog.get("startedon")), + bundle.get("timelog_assignee_names", {}).get( + _vtiger_reference_id(timelog.get("assigned_user_id")), + _vtiger_text(timelog.get("assigned_user_id")), + ), billable, Json(timelog), data_hash, + "billed" if already_billed else "pending", hours if already_billed else None, + "invoice" if billable else "internal", int((seconds / Decimal("60")).to_integral_value()), + int((seconds / Decimal("60")).to_integral_value()) if billable else 0, + start, "godkendt" if already_billed else "afventer", + ), + ) + if cursor.fetchone(): + imported_time_entries += 1 + + cursor.execute( + """INSERT INTO vtiger_case_hub_imports + (vtiger_case_id,ticket_number,hub_sag_id,hub_customer_id,imported_by_user_id,source_snapshot) + VALUES (%s,%s,%s,%s,%s,%s)""", + (vtiger_case_id, bundle["ticket_number"], sag_id, customer["id"], current_user_id, + Json({"case": case, "comments_not_imported": len(bundle.get("comments", []))})), + ) + imported_now = True + conn.commit() + return {"hub_sag_id": sag_id, "imported": imported_now, + "time_entries": imported_time_entries, + "comments_available_not_imported": len(bundle.get("comments", [])) if imported_now else 0} + except Exception: + conn.rollback() + raise + finally: + release_db_connection(conn) + + def _safe_date(value: Any) -> Optional[date]: if not value: return None @@ -804,6 +1199,434 @@ async def customer_overview( } +@router.post("/vtiger-cases/preview") +async def preview_vtiger_case_import( + payload: VtigerCaseImportRequest, + current_user: dict = Depends(require_permission("migration_center.import")), +): + bundle = await _load_vtiger_case_bundle(payload.ticket_number) + raw = bundle["case"] + already_imported = bundle.get("existing_import") + eligible_timelogs = [row for row in bundle.get("timelogs", []) if _amount(row.get("duration")) > 0] + return { + "ticket_number": bundle["ticket_number"], + "vtiger_case_id": bundle["vtiger_case_id"], + "title": _vtiger_text(raw.get("ticket_title") or raw.get("subject")) or bundle["ticket_number"], + "description": _vtiger_plain_text(raw.get("description")), + "status": _case_status_from_vtiger(raw.get("status")), + "priority": _case_priority_from_vtiger(raw.get("priority")), + "customer": bundle["customer"], + "contact": bundle.get("contact"), + "time_entries": len(eligible_timelogs), + "time_hours": str(sum((_amount(row.get("duration")) for row in eligible_timelogs), Decimal("0")) / Decimal("3600")), + "comments_available_not_imported": len(bundle.get("comments", [])), + "already_imported": bool(already_imported), + "hub_sag_id": already_imported.get("hub_sag_id") if already_imported else None, + "vtiger_marked": bool(already_imported and already_imported.get("vtiger_marked_at")), + } + + +@router.post("/vtiger-cases/import") +async def import_vtiger_case( + payload: VtigerCaseImportRequest, + request: Request, + current_user: dict = Depends(require_permission("migration_center.import")), +): + bundle = await _load_vtiger_case_bundle(payload.ticket_number) + result = _import_vtiger_case_bundle(bundle, current_user) + marked = bool(bundle.get("existing_import") and bundle["existing_import"].get("vtiger_marked_at")) + mark_error = None + if not marked: + configured_field = getattr(settings, "VTIGER_CASE_HUBBED_FIELD", "cf_cases_hubbet") or "cf_cases_hubbet" + if not re.fullmatch(r"cf_[A-Za-z0-9_]+", configured_field): + mark_error = "VTIGER_CASE_HUBBED_FIELD er ikke konfigureret med et gyldigt VTiger-feltnavn" + else: + try: + await get_vtiger_service().update_record_fields("Cases", bundle["vtiger_case_id"], {configured_field: "1"}) + marked = True + execute_query( + "UPDATE vtiger_case_hub_imports SET vtiger_marked_at=CURRENT_TIMESTAMP WHERE vtiger_case_id=%s", + (bundle["vtiger_case_id"],), fetch=False, + ) + except Exception as exc: + mark_error = f"Sagen blev overført, men vTiger kunne ikke markeres Hubbet: {str(exc)[:300]}" + audit( + request=request, current_user=current_user, action="vtiger_case_imported", + entity_type="case", entity_id=result["hub_sag_id"], + new_value={"vtiger_case_id": bundle["vtiger_case_id"], "ticket_number": bundle["ticket_number"], + "imported": result["imported"], "vtiger_marked": marked}, + ) + return {"success": True, **result, "vtiger_marked": marked, "warning": mark_error} + + +def _hub_status_to_vtiger(value: Any, allowed: set[str]) -> str: + key = _vtiger_text(value).strip().lower() + candidates = { + "ny": "New", "tildelt": "Assigned", "igangværende": "In Progress", + "afventer": "Wait for customer", "løst": "Resolved", "lukket": "Closed", + "kunde svaret": "Open", "åben": "Open", + } + candidate = candidates.get(key, "New") + return candidate if candidate in allowed else ("Open" if "Open" in allowed else next(iter(allowed), "New")) + + +def _hub_priority_to_vtiger(value: Any, allowed: set[str]) -> str: + key = _vtiger_text(value).strip().lower() + candidates = {"urgent": "Urgent", "high": "High", "normal": "Medium", "low": "Low"} + candidate = candidates.get(key, "Medium") + return candidate if candidate in allowed else ("Medium" if "Medium" in allowed else next(iter(allowed), candidate)) + + +def _time_export_minutes(row: Dict[str, Any]) -> int: + actual = row.get("faktisk_tid_min") + if actual not in (None, "") and int(actual or 0) > 0: + return int(actual) + hours = row.get("original_hours") or row.get("approved_hours") or 0 + return max(0, int((_amount(hours) * Decimal("60")).to_integral_value())) + + +def _time_export_billable(row: Dict[str, Any]) -> bool: + method = _vtiger_text(row.get("billing_method")).lower() + return bool(row.get("billable")) and method in {"", "invoice", "faktura"} + + +def _normalized_person(value: Any) -> str: + return " ".join(_vtiger_text(value).casefold().split()) + + +async def _load_hub_case_export_bundle(sag_id: int) -> Dict[str, Any]: + case = execute_query_single( + """SELECT s.id, s.titel, s.beskrivelse, s.status, s.priority::text AS priority, + s.customer_id, c.name AS customer_name, c.vtiger_id AS account_vtiger_id, + s.ansvarlig_bruger_id, owner.full_name AS owner_name, owner.email AS owner_email + FROM sag_sager s + JOIN customers c ON c.id=s.customer_id + LEFT JOIN users owner ON owner.user_id=s.ansvarlig_bruger_id + WHERE s.id=%s AND s.deleted_at IS NULL""", + (sag_id,), + ) + if not case: + raise HTTPException(status_code=404, detail=f"Hub-sag #{sag_id} blev ikke fundet") + contact = execute_query_single( + """SELECT c.id, c.first_name, c.last_name, c.email, c.vtiger_id + FROM sag_kontakter sc JOIN contacts c ON c.id=sc.contact_id + WHERE sc.sag_id=%s AND sc.deleted_at IS NULL AND c.vtiger_id IS NOT NULL + ORDER BY sc.is_primary DESC, sc.id LIMIT 1""", + (sag_id,), + ) + if not contact: + contact = execute_query_single( + """SELECT c.id, c.first_name, c.last_name, c.email, c.vtiger_id + FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id + WHERE cc.customer_id=%s AND cc.is_primary IS TRUE AND c.vtiger_id IS NOT NULL + ORDER BY cc.id LIMIT 1""", + (case["customer_id"],), + ) + times = execute_query( + """SELECT t.id, t.description, t.worked_date, t.start_tid, t.slut_tid, + t.original_hours, t.approved_hours, t.faktisk_tid_min, t.fakturerbar_tid_min, + t.billable, t.billing_method, t.status, t.entry_status, t.vtiger_id, + t.user_name, t.medarbejder_id, u.full_name AS technician_name, + u.email AS technician_email + FROM tmodule_times t + LEFT JOIN users u ON u.user_id=t.medarbejder_id + WHERE t.sag_id=%s AND t.vtiger_id IS NULL + AND COALESCE(t.entry_status, 'godkendt')='godkendt' + AND COALESCE(t.status, '') NOT IN ('billed', 'rejected') + AND t.billed_via_thehub_id IS NULL AND t.economy_settled_at IS NULL + AND t.economy_order_draft_id IS NULL + AND NOT EXISTS (SELECT 1 FROM hub_time_vtiger_exports x WHERE x.hub_time_id=t.id) + ORDER BY COALESCE(t.start_tid, t.worked_date::timestamp, t.created_at), t.id""", + (sag_id,), + ) or [] + all_times = execute_query_single( + """SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE COALESCE(entry_status, 'godkendt') <> 'godkendt') AS pending + FROM tmodule_times WHERE sag_id=%s""", + (sag_id,), + ) or {"total": 0, "pending": 0} + export_mapping = execute_query_single( + "SELECT vtiger_case_id, ticket_number, status FROM hub_case_vtiger_exports WHERE hub_sag_id=%s", + (sag_id,), + ) + imported_mapping = execute_query_single( + "SELECT vtiger_case_id, ticket_number FROM vtiger_case_hub_imports WHERE hub_sag_id=%s", + (sag_id,), + ) + return { + "case": case, "contact": contact, "times": times, + "pending_time_count": int(all_times.get("pending") or 0), + "export_mapping": export_mapping, "imported_mapping": imported_mapping, + } + + +async def _vtiger_user_records(vtiger) -> list[Dict[str, Any]]: + records: list[Dict[str, Any]] = [] + for offset in range(0, 500, 100): + batch = await vtiger.query( + "SELECT id, first_name, last_name, user_name, email1 FROM Users " + f"LIMIT {offset}, 100;" + ) + records.extend(batch or []) + if len(batch or []) < 100: + break + return records + + +def _match_vtiger_user(row: Dict[str, Any], users: list[Dict[str, Any]]) -> Optional[str]: + email = _normalized_person(row.get("technician_email")) + name = _normalized_person(row.get("technician_name") or row.get("user_name")) + matches = [] + for user in users: + user_id = _vtiger_reference_id(user.get("id")) + user_name = " ".join( + part for part in (_vtiger_text(user.get("first_name")), _vtiger_text(user.get("last_name"))) if part + ).strip() + if not user_id: + continue + if email and email in {_normalized_person(user.get("email1")), _normalized_person(user.get("user_name"))}: + return user_id + if name and name in {_normalized_person(user_name), _normalized_person(user.get("user_name"))}: + matches.append(user_id) + return matches[0] if len(set(matches)) == 1 else None + + +def _export_time_payload(row: Dict[str, Any], sag_id: int, vtiger_case_id: str, vtiger_user_id: str) -> Dict[str, Any]: + minutes = _time_export_minutes(row) + start = row.get("start_tid") + if not isinstance(start, datetime): + work_date = row.get("worked_date") + if isinstance(work_date, datetime): + work_date = work_date.date() + if not isinstance(work_date, date): + raise ValueError(f"Tidsregistrering #{row['id']} mangler en gyldig dato") + start = datetime.combine(work_date, datetime_time(9, 0)) + duration_seconds = minutes * 60 + description = _vtiger_text(row.get("description")) + name = description[:250] or f"Hub sag #{sag_id} · {start:%Y-%m-%d}" + method = _vtiger_text(row.get("billing_method")).lower() + return { + "name": name, + "description": description, + "relatedto": vtiger_case_id, + "startedon": start.strftime("%Y-%m-%d %H:%M:%S"), + "endedon": (start + timedelta(seconds=duration_seconds)).strftime("%Y-%m-%d %H:%M:%S"), + "duration": str(duration_seconds), + "assigned_user_id": vtiger_user_id, + "isbillable": "1" if _time_export_billable(row) else "0", + "timelog_status": "Completed", + "cf_timelog_invoiced": "0", + } + + +async def _prepare_hub_case_export(sag_id: int) -> Dict[str, Any]: + bundle = await _load_hub_case_export_bundle(sag_id) + case = bundle["case"] + blockers = [] + if bundle.get("imported_mapping"): + blockers.append("Sagen stammer allerede fra vTiger og kan ikke oprettes dér igen.") + if not case.get("account_vtiger_id"): + blockers.append("Kunden mangler en VTiger-kontakt/linkning.") + if not bundle.get("contact") or not bundle["contact"].get("vtiger_id"): + blockers.append("Sagen skal have en primær kontakt, der er linket til VTiger.") + vtiger = get_vtiger_service() + case_meta = await vtiger.describe_module("Cases") + timelog_meta = await vtiger.describe_module("Timelog") + fields = {f.get("name"): f for f in case_meta.get("fields", [])} + time_fields = {f.get("name"): f for f in timelog_meta.get("fields", [])} + required_case = [name for name, field in fields.items() if field.get("mandatory") and field.get("editable")] + required_time = [name for name, field in time_fields.items() if field.get("mandatory") and field.get("editable")] + if set(required_case) - {"title", "description", "casestatus", "casepriority", "parent_id", "contact_id", "cf_cases_hubbet"}: + blockers.append("VTiger har nye obligatoriske sagsfelter, som endnu ikke er mappet.") + if set(required_time) - {"name", "description", "relatedto", "startedon", "endedon", "assigned_user_id", "duration", "isbillable", "timelog_status", "cf_timelog_invoiced"}: + blockers.append("VTiger har nye obligatoriske tidsfelter, som endnu ikke er mappet.") + if any(name not in fields for name in ("title", "description", "casestatus", "casepriority", "parent_id", "contact_id")): + blockers.append("VTigers sagsfelter svarer ikke til den understøttede overførsel.") + if any(name not in time_fields for name in ("name", "description", "relatedto", "startedon", "endedon", "assigned_user_id", "duration", "isbillable", "timelog_status")): + blockers.append("VTigers tidsregistreringsfelter svarer ikke til den understøttede overførsel.") + allowed_statuses = {str(p.get("value")) for p in fields.get("casestatus", {}).get("type", {}).get("picklistValues", []) if p.get("value")} + allowed_priorities = {str(p.get("value")) for p in fields.get("casepriority", {}).get("type", {}).get("picklistValues", []) if p.get("value")} + users = await _vtiger_user_records(vtiger) if bundle["times"] else [] + enriched_times = [] + for row in bundle["times"]: + technician_name = _vtiger_text(row.get("technician_name") or row.get("user_name")) + vtiger_user_id = _match_vtiger_user(row, users) + if not vtiger_user_id: + blockers.append(f"Teknikeren på tidsregistrering #{row['id']} matcher ikke en VTiger-bruger.") + enriched_times.append({ + **row, + "technician_name": technician_name or "Ukendt tekniker", + "vtiger_user_id": vtiger_user_id, + "minutes": _time_export_minutes(row), + "export_billable": _time_export_billable(row), + }) + case_hash_data = { + "case": {key: str(case.get(key) or "") for key in ("id", "titel", "beskrivelse", "status", "priority", "account_vtiger_id")}, + "contact": {key: str(bundle["contact"].get(key) or "") for key in ("vtiger_id", "email")} if bundle.get("contact") else {}, + "times": [ + {key: str(row.get(key) or "") for key in ("id", "description", "worked_date", "start_tid", "original_hours", "faktisk_tid_min", "fakturerbar_tid_min", "billable", "billing_method", "technician_email", "technician_name")} + for row in enriched_times + ], + } + source_hash = hashlib.sha256(json.dumps(case_hash_data, ensure_ascii=False, sort_keys=True).encode()).hexdigest() + return { + **bundle, + "times": enriched_times, + "blockers": sorted(set(blockers)), + "source_hash": source_hash, + "case_meta": case_meta, + "allowed_statuses": allowed_statuses, + "allowed_priorities": allowed_priorities, + "required_case_fields": required_case, + "required_time_fields": required_time, + "status_value": _hub_status_to_vtiger(case.get("status"), allowed_statuses), + "priority_value": _hub_priority_to_vtiger(case.get("priority"), allowed_priorities), + } + + +@router.post("/hub-cases/preview") +async def preview_hub_case_export( + payload: HubCaseExportRequest, + current_user: dict = Depends(require_permission("migration_center.import")), +): + data = await _prepare_hub_case_export(payload.sag_id) + case = data["case"] + mapping = data.get("export_mapping") + return { + "sag_id": case["id"], "title": case["titel"], + "description": _vtiger_plain_text(case.get("beskrivelse")), + "customer_name": case.get("customer_name"), + "contact_name": " ".join(filter(None, [data.get("contact", {}).get("first_name"), data.get("contact", {}).get("last_name")])) if data.get("contact") else None, + "status": case.get("status"), "vtiger_status": data["status_value"], + "priority": case.get("priority"), "vtiger_priority": data["priority_value"], + "time_entries": len(data["times"]), + "time_hours": str(sum((Decimal(row["minutes"]) / Decimal("60") for row in data["times"]), Decimal("0"))), + "time_rows": [{"id": row["id"], "date": str(row.get("worked_date") or ""), "description": _vtiger_plain_text(row.get("description")), "technician": row["technician_name"], "hours": str(Decimal(row["minutes"]) / Decimal("60")), "billable": row["export_billable"]} for row in data["times"]], + "pending_time_count": data["pending_time_count"], + "already_exported": bool(mapping), + "vtiger_case_id": mapping.get("vtiger_case_id") if mapping else None, + "ticket_number": mapping.get("ticket_number") if mapping else None, + "blockers": data["blockers"], "source_hash": data["source_hash"], + } + + +@router.post("/hub-cases/export") +async def export_hub_case_to_vtiger( + payload: HubCaseExportConfirmRequest, + request: Request, + current_user: dict = Depends(require_permission("migration_center.import")), +): + data = await _prepare_hub_case_export(payload.sag_id) + if data["source_hash"] != payload.source_hash: + raise HTTPException(status_code=409, detail="Sagen eller tidsregistreringer er ændret siden forhåndsvisningen. Hent en ny forhåndsvisning.") + if data["blockers"]: + raise HTTPException(status_code=409, detail={"message": "Sagen kan ikke overføres endnu.", "blockers": data["blockers"]}) + + conn = get_db_connection() + vtiger = get_vtiger_service() + created_case = False + created_times = 0 + warnings = [] + mapping = None + try: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute("SELECT pg_advisory_lock(1046, %s)", (payload.sag_id,)) + cursor.execute("SELECT vtiger_case_id, ticket_number FROM hub_case_vtiger_exports WHERE hub_sag_id=%s FOR UPDATE", (payload.sag_id,)) + mapping = cursor.fetchone() + if not mapping: + cursor.execute("SELECT 1 FROM vtiger_case_hub_imports WHERE hub_sag_id=%s", (payload.sag_id,)) + if cursor.fetchone(): + raise HTTPException(status_code=409, detail="Sagen findes allerede i VTiger fra en tidligere VTiger-overførsel.") + case = data["case"] + case_payload = { + "title": _vtiger_text(case.get("titel"))[:250], + "description": _vtiger_plain_text(case.get("beskrivelse")) or f"Hub sag #{payload.sag_id}", + "casestatus": data["status_value"], + "casepriority": data["priority_value"], + "parent_id": _vtiger_reference_id(case.get("account_vtiger_id")), + "contact_id": _vtiger_reference_id(data["contact"].get("vtiger_id")), + "cf_cases_hubbet": "1", + } + result = await vtiger.create_record("Cases", case_payload) + vtiger_case_id = _vtiger_reference_id(result.get("id")) + if not vtiger_case_id: + raise RuntimeError("VTiger oprettede sagen, men returnerede ikke et gyldigt sags-id.") + ticket_number = _vtiger_text(result.get("case_no") or result.get("ticket_no")) or None + cursor.execute( + """INSERT INTO hub_case_vtiger_exports (hub_sag_id, vtiger_case_id, ticket_number, status) + VALUES (%s,%s,%s,'partial')""", + (payload.sag_id, vtiger_case_id, ticket_number), + ) + conn.commit() + mapping = {"vtiger_case_id": vtiger_case_id, "ticket_number": ticket_number} + created_case = True + vtiger_case_id = mapping["vtiger_case_id"] + conn.commit() + + for row in data["times"]: + cursor.execute("SELECT vtiger_timelog_id FROM hub_time_vtiger_exports WHERE hub_time_id=%s", (row["id"],)) + if cursor.fetchone(): + continue + try: + time_payload = _export_time_payload(row, payload.sag_id, vtiger_case_id, row["vtiger_user_id"]) + time_result = await vtiger.create_record("Timelog", time_payload) + vtiger_timelog_id = _vtiger_reference_id(time_result.get("id")) + if not vtiger_timelog_id: + raise RuntimeError("VTiger oprettede tiden, men returnerede ikke et gyldigt tids-id.") + cursor.execute( + """INSERT INTO hub_time_vtiger_exports (hub_time_id, hub_sag_id, vtiger_timelog_id) + VALUES (%s,%s,%s)""", + (row["id"], payload.sag_id, vtiger_timelog_id), + ) + conn.commit() + created_times += 1 + except Exception as exc: + warnings.append(f"Tidsregistrering #{row['id']}: {str(exc)[:250]}") + break + cursor.execute( + """UPDATE hub_case_vtiger_exports SET status=%s, ticket_number=COALESCE(ticket_number,%s), + last_error=%s, updated_at=CURRENT_TIMESTAMP WHERE hub_sag_id=%s""", + ("complete" if not warnings else "partial", mapping.get("ticket_number"), "; ".join(warnings) or None, payload.sag_id), + ) + conn.commit() + except HTTPException: + conn.rollback() + raise + except Exception as exc: + conn.rollback() + warnings.append(str(exc)[:300]) + execute_query( + "UPDATE hub_case_vtiger_exports SET status='partial', last_error=%s, updated_at=CURRENT_TIMESTAMP WHERE hub_sag_id=%s", + ("; ".join(warnings), payload.sag_id), fetch=False, + ) + if not mapping: + raise HTTPException(status_code=502, detail=f"VTiger-overførslen fejlede: {str(exc)[:300]}") from exc + finally: + try: + with conn.cursor() as cursor: + cursor.execute("SELECT pg_advisory_unlock(1046, %s)", (payload.sag_id,)) + conn.commit() + except Exception: + conn.rollback() + release_db_connection(conn) + + result = { + "success": True, "sag_id": payload.sag_id, + "vtiger_case_id": vtiger_case_id, + "ticket_number": mapping.get("ticket_number"), + "created_case": created_case, + "created_time_entries": created_times, + "complete": not warnings, + "warning": "; ".join(warnings) or None, + } + audit( + request=request, current_user=current_user, action="hub_case_exported_to_vtiger", + entity_type="case", entity_id=payload.sag_id, + new_value={key: value for key, value in result.items() if key not in {"warning"}}, + ) + return result + + @router.post("/items/{item_id}/rematch") async def rematch( item_id: int, request: Request, diff --git a/app/modules/migration_center/templates/index.html b/app/modules/migration_center/templates/index.html index b972612..0b6ea70 100644 --- a/app/modules/migration_center/templates/index.html +++ b/app/modules/migration_center/templates/index.html @@ -64,6 +64,8 @@ + +
@@ -104,6 +106,81 @@

Fakturahistorik

Grupperet på kunde og vare for de seneste 13 måneder. Forslag opretter aldrig noget automatisk.

KundeVareFakturaerMånederPeriodeBeløbFrekvensHub-matchHandling
+
+
+
+
+
Enkeltvis, kontrolleret overførsel
+

Overfør vTiger-sag

+

Indtast sagsnummeret fra vTiger, fx CC12345. Først vises en forhåndsvisning; intet ændres, før du bekræfter overførslen.

+ +
+ + +
+
Sagen skal allerede være knyttet til en kunde, som findes og er linket i Hub.
+
+
+
+
Sagens kunde, status og registrerede tid vises her, inden du overfører.
+
+
+
Forhåndsvisning

+
+
+

+
+
Kunde
+
Kontakt
+
Tidsregistreringer
+
Prioritet
+
+
Overførslen opretter Hub-sagen og knytter tidsregistreringerne. vTiger-sagen markeres først som “Hubbet”, når Hub-overførslen er gemt. Kommentarer vises kun som antal og kopieres ikke, da vTiger ikke fortæller sikkert, om de er interne.
+ +
+
+
+
+
+
+
+
+
+
Hub → VTiger
+

Overfør Hub-sag til VTiger

+

Sagen og godkendte Hub-timer overføres efter forhåndsvisning. VTiger forbliver stedet, hvor faktureringen håndteres.

+ +
+ # + +
+
Kunden og en VTiger-linket primær kontakt skal være knyttet til sagen.
+
+
+
+
Sagsfelter og godkendte tidsregistreringer vises her, inden du overfører.
+
+
+
Forhåndsvisning

+
+
+

+
+
Kunde
+
Kontakt
+
VTiger-status
+
Prioritet
+
+
Tider til VTiger
+
DatoTeknikerBeskrivelseTimerFakturerbar
+
+
Kun godkendte, ikke fakturerede Hub-timer overføres. Timer markeret som faktura-afregning oprettes fakturerbare i VTiger; øvrige timer oprettes ikke-fakturerbare. Eksisterende VTiger-timer og faktureringsflag ændres ikke.
+ +
+
+
+
+
@@ -118,6 +195,8 @@ @@ -13385,7 +13514,7 @@ let actions = ''; if (own && running) { actions = ` - `; + `; } else if (own && paused) { actions = ``; } else if (own) { diff --git a/app/services/vtiger_service.py b/app/services/vtiger_service.py index 77273c6..96c236c 100644 --- a/app/services/vtiger_service.py +++ b/app/services/vtiger_service.py @@ -166,6 +166,58 @@ class VTigerService: logger.error(f"❌ vTiger query error: {e}") return [] + async def describe_module(self, module_name: str) -> Dict[str, Any]: + """Return vTiger module metadata (field names, editability and requirements).""" + module = str(module_name or "").strip() + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module): + raise ValueError("Ugyldigt vTiger-modul") + if not self.rest_endpoint: + raise ValueError("VTIGER_URL not configured") + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.rest_endpoint}/describe", + params={"elementType": module}, + auth=self._get_auth(), + timeout=aiohttp.ClientTimeout(total=30), + ) as response: + text = await response.text() + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Ugyldigt svar fra vTiger describe ({response.status})") from exc + if response.status != 200 or not payload.get("success"): + error = payload.get("error", {}).get("message") or f"HTTP {response.status}" + raise RuntimeError(f"vTiger kunne ikke hente modulbeskrivelse: {error}") + return payload.get("result") or {} + + async def create_record(self, module_name: str, fields: Dict[str, Any]) -> Dict[str, Any]: + """Create one record using vTiger's REST API create contract.""" + module = str(module_name or "").strip() + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module): + raise ValueError("Ugyldigt vTiger-modul") + if not isinstance(fields, dict) or not fields: + raise ValueError("Tom vTiger-post") + if not self.rest_endpoint: + raise ValueError("VTIGER_URL not configured") + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.rest_endpoint}/create", + params={"elementType": module, "element": json.dumps(fields, ensure_ascii=False)}, + auth=self._get_auth(), + timeout=aiohttp.ClientTimeout(total=30), + ) as response: + text = await response.text() + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + excerpt = re.sub(r"\s+", " ", text).strip()[:300] + suffix = f": {excerpt}" if excerpt else " (tomt svar)" + raise RuntimeError(f"Ugyldigt svar fra vTiger ({response.status}){suffix}") from exc + if response.status != 200 or not payload.get("success"): + error = payload.get("error", {}).get("message") or f"HTTP {response.status}" + raise RuntimeError(f"vTiger kunne ikke oprette posten: {error}") + return payload.get("result") or {} + async def retrieve_file(self, resource_id: str) -> Optional[Dict[str, Any]]: """Retrieve the original bytes for a vTiger file resource.""" safe_id = self._sanitize_vtiger_id(resource_id) @@ -605,6 +657,49 @@ class VTigerService: except Exception as e: logger.error(f"❌ Error updating subscription: {e}") raise + + async def update_record_fields(self, module_name: str, record_id: str, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update a small allow-listed field set on one vTiger record. + + This generic variant is used for explicitly requested single-record + migrations. Callers must validate both the module and field names. + """ + module = str(module_name or "").strip() + safe_id = self._sanitize_vtiger_id(record_id) + if not module or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module): + raise ValueError("Ugyldigt vTiger-modul") + if not safe_id or not isinstance(updates, dict) or not updates: + raise ValueError("Ugyldigt vTiger-id eller tom ændring") + if not self.rest_endpoint: + raise ValueError("VTIGER_URL not configured") + + current_rows = await self.query(f"SELECT * FROM {module} WHERE id='{safe_id}' LIMIT 1;") + if not current_rows: + raise ValueError(f"vTiger-posten {safe_id} blev ikke fundet") + # The Vtiger REST API's revise method is intended for partial changes: + # POST /revise?element=. The update endpoint has a + # different contract and may return an empty HTTP 400 when called with + # the wrapped {elementType, element} payload used by other adapters. + element = {"id": safe_id, **updates} + + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.rest_endpoint}/revise", + params={"element": json.dumps(element)}, + auth=self._get_auth(), + timeout=aiohttp.ClientTimeout(total=30), + ) as response: + text = await response.text() + try: + result = json.loads(text) + except json.JSONDecodeError as exc: + excerpt = re.sub(r"\s+", " ", text).strip()[:300] + suffix = f": {excerpt}" if excerpt else " (tomt svar)" + raise RuntimeError(f"Ugyldigt svar fra vTiger ({response.status}){suffix}") from exc + if response.status != 200 or not result.get("success"): + error = result.get("error", {}).get("message") or f"HTTP {response.status}" + raise RuntimeError(f"vTiger kunne ikke opdatere posten: {error}") + return result.get("result") or {} async def get_service_contracts(self, account_id: Optional[str] = None, active_only: bool = True) -> List[Dict]: """ diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py index b8839fa..3a839b7 100644 --- a/app/timetracking/backend/router.py +++ b/app/timetracking/backend/router.py @@ -244,6 +244,55 @@ def _settlement_options_for_customer(customer_id: Any) -> Dict[str, Any]: return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements} +def _settlement_options_for_case_entry(entry: Dict[str, Any]) -> Dict[str, Any]: + """Use the case's Hub customer as the authority for billing choices. + + Older time rows may contain a stale or differently-scoped customer_id. The + case link is also what the economy queue uses when determining ownership. + """ + case_id = entry.get("sag_id") + if case_id is not None: + case = execute_query_single( + "SELECT customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", + (case_id,), + ) or {} + hub_customer_id = case.get("customer_id") + if hub_customer_id is None: + return _settlement_options_for_customer(None) + + cards = execute_query( + """ + SELECT id, card_number, remaining_hours, rounding_minutes, expires_at + FROM tticket_prepaid_cards + WHERE customer_id = %s AND status = 'active' AND remaining_hours > 0 + AND (expires_at IS NULL OR expires_at >= CURRENT_DATE) + ORDER BY expires_at ASC NULLS LAST, created_at ASC + """, + (hub_customer_id,), + ) or [] + agreements = execute_query( + """ + SELECT id, agreement_number, monthly_hours + FROM customer_fixed_price_agreements + WHERE customer_id = %s AND status = 'active' + AND (start_date IS NULL OR start_date <= CURRENT_DATE) + AND (end_date IS NULL OR end_date >= CURRENT_DATE) + ORDER BY created_at ASC + """, + (hub_customer_id,), + ) or [] + if cards: + recommendation = {"method": "prepaid", "prepaid_card_id": cards[0]["id"], "reason": "Kunden har et aktivt klippekort"} + elif agreements: + recommendation = {"method": "subscription", "fixed_price_agreement_id": agreements[0]["id"], "reason": "Kunden har en aktiv aftale"} + else: + recommendation = {"method": "invoice", "reason": "Der er ingen aktiv inkluderet aftale"} + return {"recommended": recommendation, "prepaid_cards": cards, "agreements": agreements} + + # Entries not linked to a case retain the old customer-cache lookup. + return _settlement_options_for_customer(entry.get("customer_id")) + + def _resolve_case_customer_id(sag_id: Any, payload_customer_id: Any = None) -> Optional[int]: """Resolve tmodule customer_id for a case (tmodule_times FK target).""" try: @@ -3003,7 +3052,8 @@ async def create_manual_time_v1( @router.patch("/time/{time_id}", tags=["Internal"]) async def patch_time_entry_v1( time_id: int, - payload: Dict[str, Any] = Body(...) + payload: Dict[str, Any] = Body(...), + current_user: Optional[dict] = Depends(get_optional_user) ): """Patch udvalgte felter på tidsentry. Faktisk tid ændres kun via eksplicit brugerinput, aldrig automatisk.""" try: @@ -3011,6 +3061,19 @@ async def patch_time_entry_v1( if not existing: raise HTTPException(status_code=404, detail="Time entry not found") + # Once a time entry has been settled, corrections must retain a trace + # instead of silently changing the amount behind an invoice/draft. + if ( + str(existing.get("status") or "").lower() == "billed" + or existing.get("economy_settled_at") + or existing.get("economy_order_draft_id") + or existing.get("billed_via_thehub_id") + ): + raise HTTPException( + status_code=409, + detail="Afregnet tid kan ikke redigeres direkte. Opret en sporbar korrektion.", + ) + _assert_prepaid_entry_editable(existing) updates: Dict[str, Any] = {} @@ -3071,6 +3134,24 @@ async def patch_time_entry_v1( query = f"UPDATE tmodule_times SET {', '.join(set_parts)} WHERE id = %s RETURNING *" updated = execute_query(query, tuple(values)) + if updated: + changed = { + field: {"before": existing.get(field), "after": updated[0].get(field)} + for field in updates + if existing.get(field) != updated[0].get(field) + } + if changed: + audit.log_event( + event_type="time_entry_updated", + entity_type="time_entry", + entity_id=time_id, + user_id=_resolve_current_user_id(current_user), + details={ + "changes": changed, + "reason": str(payload.get("reason") or "").strip() or None, + "timestamp": datetime.now().isoformat(), + }, + ) return updated[0] if updated else None except HTTPException: raise @@ -3082,10 +3163,10 @@ async def patch_time_entry_v1( @router.get("/time/{time_id}/settlement-options", tags=["Internal"]) async def get_time_settlement_options_v1(time_id: int): """Show valid invoice, prepaid and agreement choices before approval.""" - entry = execute_query_single("SELECT id, customer_id FROM tmodule_times WHERE id = %s", (time_id,)) + entry = execute_query_single("SELECT id, customer_id, sag_id FROM tmodule_times WHERE id = %s", (time_id,)) if not entry: raise HTTPException(status_code=404, detail="Time entry not found") - return _settlement_options_for_customer(entry.get("customer_id")) + return _settlement_options_for_case_entry(entry) @router.post("/time/{time_id}/approve", tags=["Internal"]) @@ -3118,7 +3199,7 @@ async def approve_time_entry_v1( raise HTTPException(status_code=400, detail="entry_type is required before approval") logger.warning("⚠️ Admin approved time entry with ukendt type (time_id=%s)", time_id) - options = _settlement_options_for_customer(entry.get("customer_id")) + options = _settlement_options_for_case_entry(entry) recommendation = options.get("recommended") or {"method": "invoice"} billing_method = str(payload.get("billing_method") or recommendation.get("method") or "invoice").lower() if billing_method == "fixed_price": diff --git a/fremtidige planer/eskaleringsplan-drift.md b/fremtidige planer/eskaleringsplan-drift.md new file mode 100644 index 0000000..2da8c81 --- /dev/null +++ b/fremtidige planer/eskaleringsplan-drift.md @@ -0,0 +1,1513 @@ +# PLAN – Eskaleringsplan / Drift i BMC Hub + +> **Status: Fremtidig plan – ikke implementeret.** +> +> Denne plan er en udvidelse af den eksisterende Drift-funktion. Den må ikke +> fjerne, omdøbe eller ændre eksisterende Drift-adfærd uden udtrykkelig, +> bagudkompatibel migrering. + +## 1. Formål + +Der skal bygges et nyt modul i **The Hub**, som styrer: + +- overvågning af kunders og BMC's driftsressourcer +- eskaleringsplaner +- fejlsituationer og driftsalarmer +- automatisk analyse af hvilke dele af infrastrukturen der sandsynligvis er berørt +- oprettelse af driftssager +- maintenance mode +- forslag til kundekommunikation +- intern eskalering +- SLA-regler +- historik og dokumentation + +Systemet skal ikke kun vise, **at noget er nede**. + +Det skal også kunne forklare: + +> Hvad er nede? +> Hvem er berørt? +> Hvor tror vi fejlen ligger? +> Hvorfor tror systemet det? +> Hvad skal vi gøre nu? +> Hvem skal kontaktes? +> Må vi selv fejlrette? +> Kræver noget godkendelse? +> Skal kunden informeres? + +--- + +# 2. Placering i Hub + +Under kundens stamdata oprettes et nyt, selvstændigt faneblad: + +**Eskaleringsplan** + +Fanen supplerer den eksisterende **Drift**-fane. Drift-fanen bevarer dens +nuværende alarmoversigt og historik. Den nye fane viser og administrerer kun +eskaleringsplanens ressourcer, flows, SLA, kontaktregler, godkendelser og +historik for eskaleringer. + +Herfra skal man kunne se kundens: + +- driftsressourcer +- overvågede devices +- services +- eskaleringsflows +- kontaktpersoner +- kommunikationsregler +- SLA-regler +- godkendelsesregler +- aktuelle og tidligere driftsproblemer + +Men selve en driftsressource må **ikke nødvendigvis eje én kunde**. + +En internetforbindelse, switch, firewall eller anden ressource kan være delt mellem flere kunder. + +--- + +# 3. Driftsressourcer + +Der skal oprettes et generelt objekt: + +`Driftsressource` + +Eksempler: + +- Internetforbindelse +- Gateway +- Firewall +- Switch +- Access Point +- Server +- VM +- Hosting cluster +- UPS +- WAN +- LAN +- Kundens router +- SaaS-service +- VPN +- DNS +- Microsoft 365 +- Datacenter +- Bygning +- Etage +- Netværkssegment + +En ressource skal kunne tilknyttes: + +- én eller flere kunder +- andre driftsressourcer +- en eller flere overvågningskilder +- kontaktpersoner +- services +- eskaleringsplaner + +--- + +# 4. Ressourcehierarki + +Driftsressourcer skal kunne forbindes hierarkisk. + +Eksempel: + +```text +Internetforbindelse + │ + ▼ + Gateway + / \ + ▼ ▼ +Switch 1 Switch 2 + │ │ + ▼ ▼ +Bygning 1 Bygning 2 + │ + ┌──┴─────┐ + ▼ ▼ +Kunde A Kunde B +``` + +Dette hierarki bruges til at analysere, **hvor fejlen sandsynligvis ligger**. + +--- + +# 5. Visuel eskalerings-/flow-builder + +Eskaleringsplanen skal bygges grafisk. + +Det skal ligne eksempelvis: + +- Power Automate +- Node-RED +- n8n +- workflow builder + +Brugeren skal kunne trække blokke ind på et canvas og forbinde dem. + +Eksempel: + +```text +[Internet nede] + | + v +[Gateway oppe?] + / \ + JA NEJ + | | + v v +[Check [Mistanke: +Switches] udbyder] + | + v +[Switch 1 oppe?] + / \ +NEJ JA +| | +v v +Bygning 1 Check næste gren +berørt +``` + +--- + +# 6. Typer af blokke i flow-builderen + +Systemet skal bl.a. have følgende node-typer: + +### Trigger + +Eksempel: + +```text +Device bliver DOWN +``` + +eller: + +```text +Internetforbindelse bliver DOWN +``` + +### Statuscheck + +Eksempel: + +```text +Er Gateway-01 UP? +``` + +### Kombination + +Eksempel: + +```text +Gateway = UP +OG +Switch-01 = DOWN +OG +Switch-02 = UP +``` + +### Timer + +Eksempel: + +```text +Har forbindelsen været nede i 15 minutter? +``` + +### Impact + +Eksempel: + +```text +Markér Bygning 1 som berørt +``` + +### Intern handling + +Eksempel: + +```text +Opret driftssag +``` + +### Intern notification + +Eksempel: + +```text +Send SMS til driftsteam +``` + +### Godkendelse + +Eksempel: + +```text +Kræver kundens godkendelse før hardwareudskiftning +``` + +### Kommunikation + +Eksempel: + +```text +Foreslå driftsbesked +``` + +**Må aldrig automatisk sende besked til kunden.** + +--- + +# 7. Overvågningsintegrationer + +Eksisterende integrationer, der allerede leverer Drift-events, skal fortsætte +uændret. Første nye integrationer eller udvidelser: + +### Uptime Kuma + +Bruges eksempelvis til: + +- ping +- HTTP +- TCP +- serverstatus +- internetforbindelser +- gateway-status + +### UniFi + +Bruges eksempelvis til: + +- gateways +- switches +- access points +- sites +- internetstatus +- device-status + +Den eksisterende UISP-integration bevares som en separat eksisterende kilde; +UniFi tilføjes uden at ændre UISP- eller Uptime Kuma-events, deres synkronisering +eller deres nuværende visning. + +Arkitekturen skal være generisk, så senere integrationer kan tilføjes, eksempelvis: + +- Prometheus +- UISP +- PRTG +- Zabbix +- SNMP +- Azure Monitor +- Microsoft Graph +- VMware +- Proxmox +- custom API + +Overvågningssystemerne skal primært levere **events og status**. + +Hubben skal selv afgøre, hvad eventet betyder. + +--- + +# 8. Eksempel – internetforbindelse + +Antag: + +```text +Internet + │ +Gateway + │ + ┌─┴─────────┐ + │ │ +Switch 1 Switch 2 + │ │ +Bygning 1 Bygning 2 +``` + +Hvis: + +```text +Internet monitor = DOWN +Gateway = UP +Switch 1 = DOWN +Switch 2 = UP +``` + +kan Hub konkludere: + +```text +Internetforbindelsen er sandsynligvis OK. + +Problemet ser ud til at være omkring: +Bygning 1 / Switch 1. +``` + +Det betyder ikke nødvendigvis: + +> Switch 1 er defekt. + +Systemet skal i stedet kunne skrive: + +> Fejlen er lokaliseret til området omkring Switch 1 / Bygning 1. + +Mulige årsager kunne være: + +- switch +- strøm +- fiber/kabel +- uplink +- lokalt netværk + +--- + +# 9. Eksempel – kun én kunde berørt + +Hvis: + +```text +Gateway = UP +Switch = UP +Kunde A router = DOWN +Kunde B router = UP +Kunde C router = UP +``` + +skal Hub kunne konkludere: + +```text +Fejlen ser ud til kun at påvirke Kunde A. +``` + +Systemet må ikke automatisk konkludere, at BMC's udstyr er defekt. + +--- + +# 10. Hvorfor-visning + +Dette er en vigtig funktion. + +Når Hub har vurderet et problem, skal teknikeren kunne trykke: + +**Hvorfor?** + +Så vises hele beslutningsstien. + +Eksempel: + +```text +07:42 Internet monitor: DOWN + +07:42 Gateway-01: UP + +07:42 Switch-B1: DOWN + +07:42 Switch-B2: UP + +07:43 Router Kunde A: DOWN + +07:43 Router Kunde B: DOWN + +-------------------------------- + +Regel: +Gateway UP ++ +Switch B1 DOWN ++ +Switch B2 UP + +Resultat: +Sandsynligt problem i Bygning 1. +``` + +Den aktive vej gennem diagrammet skal visuelt markeres. + +Eksempel: + +```text +Internet DOWN + ↓ +Gateway UP ✓ + ↓ +Switch B1 DOWN ✕ + ↓ +Bygning 1 berørt +``` + +--- + +# 11. Systemets sikkerhed omkring konklusioner + +Hub må gerne lave en teknisk vurdering. + +Men der skal skelnes mellem: + +**Observeret** + +og + +**Vurderet** + +Eksempel: + +```text +Observeret: +Switch B1 svarer ikke. + +Vurdering: +Fejlen ser ud til at være lokaliseret til Bygning 1. +``` + +Ikke: + +```text +Switch B1 er defekt. +``` + +medmindre det faktisk kan konstateres. + +--- + +# 12. Driftssag / Eskaleringssag + +Når et relevant flow trigges, skal Hub kunne: + +```text +Opret driftssag +``` + +Driftssagen kan være: + +- automatisk oprettet +- manuelt oprettet + +Den skal indeholde: + +- starttidspunkt +- berørte ressourcer +- berørte kunder +- berørte services +- nuværende status +- severity +- eskaleringsplan +- ansvarlig +- events +- timeline +- vurderet årsag +- hvorfor-visning +- SLA +- notifications +- kundekommunikation +- maintenance status + +--- + +# 13. Severity + +Forslag: + +```text +INFO +WARNING +MAJOR +CRITICAL +``` + +Eksempel: + +BMC Hostingcenter nede: + +```text +CRITICAL +``` + +En enkelt AP hos en kunde: + +```text +WARNING +``` + +Severity skal kunne defineres i flow-builderen. + +--- + +# 14. BMC's egen hosting + +BMC's egen infrastruktur skal kunne have meget aggressive eskaleringsplaner. + +Eksempel: + +```text +Hosting cluster DOWN + ↓ +CRITICAL + ↓ +Opret driftssag + ↓ +SMS til alle relevante medarbejdere + ↓ +Push notification + ↓ +Start eskaleringsplan straks +``` + +Det må godt ske automatisk internt. + +Begrænsningen omkring manuel godkendelse gælder **kundekommunikation**. + +--- + +# 15. SLA + +Eskaleringsplaner skal understøtte SLA. + +Eksempel: + +```text +0 min: +Registrér event + +5 min: +Verificér fejlen + +15 min: +Opret driftssag + +30 min: +Kontakt ansvarlig + +60 min: +Eskalér til senior tekniker + +4 timer: +SLA-grænse +``` + +Der skal kunne være: + +- responstid +- løsningstid +- tidsfrister +- advarsler +- escalation deadlines + +--- + +# 16. Godkendelsesregler + +Under kunden skal det være muligt at definere: + +```text +Må BMC fejlrette uden godkendelse? +JA / NEJ +``` + +Eksempel: + +```text +Fejlsøgning: +Tilladt + +Genstart: +Tilladt + +Firmware-opdatering: +Kræver godkendelse + +Hardwareudskiftning: +Kræver godkendelse + +Omkostninger over 2.500 kr: +Kræver godkendelse +``` + +Der skal kunne angives hvem der må godkende. + +--- + +# 17. Kontaktpersoner og driftstilmelding + +Kontaktpersoner skal kunne abonnere på drift. + +Ikke kun: + +```text +Kunde A +``` + +men også eksempelvis: + +```text +Internetforbindelse 047 +``` + +eller: + +```text +Bygning 1 +``` + +eller: + +```text +Office 365 +``` + +eller: + +```text +Hovedkontorets netværk +``` + +En kontakt kan dermed være tilmeldt flere driftsressourcer. + +--- + +# 18. Delte forbindelser + +En internetforbindelse kan være delt mellem flere kunder. + +Eksempel: + +```text +Internetforbindelse #1028 +``` + +benyttes af: + +```text +Kunde A +Kunde B +Kunde C +Kunde D +``` + +Hub skal derfor kunne beregne: + +```text +Berørte kunder: 4 + +Potentielle modtagere: 12 +``` + +--- + +# 19. Impact engine + +Systemet skal kunne beregne påvirkningen ud fra diagrammet. + +Eksempel: + +```text +Internetforbindelsen + ↓ +Gateway + ↓ +Switch Bygning 1 + ↓ +Kunde A +Kunde B +Kunde C +``` + +Hvis Switch Bygning 1 går ned: + +```text +Berørt: +Kunde A +Kunde B +Kunde C + +Ikke berørt: +Kunde D +Kunde E +``` + +--- + +# 20. Driftsbeskeder + +Fra en driftssag skal der være: + +**Generér driftsbesked** + +Hub kan automatisk foreslå: + +- modtagere +- emne +- tekst +- hvilke kunder der er berørt +- hvilken bygning/service der er berørt +- kendt status + +Men: + +# Kundekommunikation må ALDRIG sendes automatisk. + +Der skal altid være et menneskeligt klik: + +```text +GODKEND OG SEND +``` + +--- + +# 21. AI / automatisk tekstforslag + +Hub kan eksempelvis foreslå: + +```text +Driftsforstyrrelse – Bygning 1 + +Vi oplever i øjeblikket problemer med netværksforbindelsen +i Bygning 1. + +Vores overvågning viser, at øvrige dele af forbindelsen +fungerer normalt, og problemet ser derfor ud til at være +lokaliseret til netværket i Bygning 1. + +Vi undersøger fejlen og sender en ny status, når der er +yderligere information. +``` + +Brugeren kan redigere teksten før udsendelse. + +--- + +# 22. Beskedtyper + +Systemet bør have mindst: + +```text +Driftsforstyrrelse + +Opdatering + +Planlagt vedligehold + +Forlænget nedetid + +Drift normaliseret +``` + +--- + +# 23. Foreslå kommunikation + +Flow-builderen skal kunne have en node: + +```text +Foreslå kundekommunikation +``` + +Eksempel: + +```text +Hvis nedetid > 15 minutter + ↓ +Foreslå driftbesked +``` + +Dette åbner eksempelvis: + +```text +Der er 17 relevante modtagere. + +Vil du oprette en driftsbesked? +``` + +Ikke automatisk sende den. + +--- + +# 24. Maintenance mode + +Der skal være en sagstype: + +```text +Drift / Vedligehold +``` + +I sagen kan man vælge: + +```text +Sæt devices i maintenance +``` + +Eksempel: + +```text +Gateway-01 +Switch-01 +Switch-02 +AP-01 +``` + +Mens sagen er aktiv: + +```text +Maintenance = aktiv +``` + +Når sagen lukkes: + +```text +Maintenance = automatisk fjernet +``` + +--- + +# 25. Maintenance slutdato + +Ved maintenance kan man sætte: + +```text +Start: +24-09-2026 22:00 + +Forventet slut: +25-09-2026 01:00 +``` + +Hvis maintenance stadig er aktiv efter sluttidspunktet: + +```text +ADVARSEL + +Maintenance skulle være afsluttet for 38 minutter siden. +``` + +--- + +# 26. Events under maintenance + +Events skal ikke slettes. + +Hvis et device går ned under maintenance: + +```text +Event registreret +``` + +men eksempelvis: + +```text +Alarm undertrykt: +Device er i maintenance via sag #1842 +``` + +Så historikken stadig er komplet. + +--- + +# 27. Kritiske undtagelser + +Nogle alarmer må aldrig ignoreres under maintenance. + +Eksempel: + +```text +UPS batterifejl + +Brandalarm + +Temperatur kritisk + +Hosting cluster helt nede +``` + +De kan markeres: + +```text +Ignore maintenance = YES +``` + +--- + +# 28. Timeline + +Driftssagen skal have én samlet timeline. + +Eksempel: + +```text +07:41 Uptime Kuma meldte Internet DOWN + +07:42 Hub kontrollerede Gateway-01 + +07:42 Gateway-01 svarede + +07:42 Switch-B1 svarede ikke + +07:42 Switch-B2 svarede + +07:43 Hub vurderede: +Problem sandsynligvis Bygning 1 + +07:44 Driftssag #1932 oprettet + +07:46 Christian åbnede sagen + +07:48 Driftsbesked genereret + +07:49 Driftsbesked godkendt af Christian + +07:50 14 modtagere informeret +``` + +--- + +# 29. Audit log + +Alle automatiske beslutninger skal kunne spores. + +Gem eksempelvis: + +```text +regel-id +regel-version +input-data +status på devices +resultat +beslutningssti +timestamp +handlinger +brugerhandlinger +``` + +Det skal være muligt bagefter at forstå præcis: + +> Hvorfor gjorde Hub dette? + +--- + +# 30. Overstyring + +Teknikeren skal kunne overstyre systemets vurdering. + +Eksempel: + +Hub siger: + +```text +Mistanke: Netværk Bygning 1 +``` + +Tekniker kan ændre til: + +```text +Bekræftet årsag: +Fiberbrud mellem hovedbygning og Bygning 1 +``` + +Den oprindelige automatiske vurdering skal dog stadig gemmes i historikken. + +--- + +# 31. Skabeloner + +Der skal kunne bygges templates. + +Eksempel: + +```text +Standard internetkunde + +Standard UniFi-site + +Delt erhvervsforbindelse + +Hostingkunde + +Microsoft 365 kunde +``` + +Ved oprettelse af ny kunde kan man eksempelvis vælge: + +```text +Anvend: +Standard internetkunde +``` + +og derefter blot tilknytte de rigtige devices. + +--- + +# 32. Regelversionering + +Hvis en eskaleringsplan ændres, må historiske driftssager ikke ændre sig. + +Eksempel: + +```text +Eskaleringsplan v3 +``` + +blev brugt på hændelsen. + +Selv hvis planen senere bliver v4, skal sagen stadig kunne vise den regelversion, der faktisk blev brugt. + +--- + +# 33. Systemarkitektur + +Overvågningskilder bør sende normaliserede events til Hub. + +Eksempel: + +```json +{ + "source": "uptime_kuma", + "resource_id": 812, + "event": "down", + "timestamp": "...", + "metadata": {} +} +``` + +Hub behandler eventet gennem: + +```text +Monitoring Event + ↓ +Resource Mapping + ↓ +Escalation Engine + ↓ +Impact Engine + ↓ +Rule Flow + ↓ +Actions +``` + +--- + +# 34. Central event-model + +Hub skal ikke være afhængig af et bestemt overvågningssystem. + +Normaliser eksempelvis status til: + +```text +UP +DOWN +DEGRADED +UNKNOWN +MAINTENANCE +``` + +Så samme flow kan bruge: + +```text +Uptime Kuma +UniFi +Prometheus +UISP +andet +``` + +--- + +# 35. UI – kundens stamdata + +Fanen: + +```text +Eskaleringsplan +``` + +kunne have undermenuerne: + +```text +OVERSIGT +RESSOURCER +FLOW +KONTAKTER +SLA +GODKENDELSER +HISTORIK +``` + +--- + +# 36. UI – live flow + +Når en driftssag er aktiv, skal diagrammet kunne vises live. + +Eksempel: + +```text + [Internet DOWN] + ↓ + [Gateway] + UP ✓ + ↓ + ┌────────┴────────┐ + ↓ ↓ + [Switch B1] [Switch B2] + DOWN ✕ UP ✓ + ↓ + [BYGNING 1] + BERØRT +``` + +Aktive nodes skal fremhæves. + +Teknikeren skal kunne klikke på en node og se: + +```text +Status +Sidste check +Datakilde +Device +Regel +Resultat +``` + +--- + +# 37. Drift dashboard + +På Hub's eksisterende **Drift**-side bør aktive eskaleringssager vises. + +Eksempel: + +```text +CRITICAL 1 +MAJOR 2 +WARNING 4 +``` + +Kort kan eksempelvis vise: + +```text +Norva24 + +Netværk – Bygning 1 + +Start: 07:42 +Varighed: 28 min + +3 kunder berørt +14 kontaktpersoner + +Mistanke: +Lokalt netværk Bygning 1 +``` + +--- + +# 38. MVP – første version + +Første version bør fokusere på: + +```text +Driftsressourcer + +Kunde ↔ ressource relationer + +Delte ressourcer + +Uptime Kuma integration + +UniFi integration + +Visuel flow-builder + +UP/DOWN conditions + +AND / OR conditions + +Tidsbetingelser + +Opret driftssag + +Impact analyse + +Hvorfor-visning + +Timeline + +Maintenance mode + +Kontaktperson ↔ driftsressource + +Generér driftsbesked + +MANUEL godkendelse og send + +Audit log +``` + +--- + +# 39. Version 2 + +Efter MVP: + +```text +Avancerede SLA-regler + +SMS + +Push + +Telefoneskalering + +Webhook actions + +Flere overvågningssystemer + +AI-genereret statusbesked + +Automatisk root-cause forslag + +Dependencies mellem services + +Service maps + +Statistik + +Rapportering + +SLA-performance +``` + +--- + +# 40. Absolutte regler + +Disse regler skal overholdes: + +**1. Kundebeskeder må aldrig sendes automatisk.** + +Hub må: + +```text +foreslå besked +foreslå modtagere +generere tekst +``` + +men et menneske skal altid godkende udsendelsen. + +**2. Automatiske vurderinger skal kunne forklares.** + +Man skal altid kunne se: + +```text +Hvorfor nåede systemet frem til dette resultat? +``` + +**3. Observation og konklusion skal adskilles.** + +Eksempel: + +```text +Observation: +Switch svarer ikke. + +Vurdering: +Problemet ser ud til at være lokaliseret omkring switchen. +``` + +**4. En driftsressource kan deles af flere kunder.** + +**5. Alle beslutninger og ændringer skal logges.** + +**6. Arkitekturen må ikke være låst til Uptime Kuma eller UniFi.** + +--- + +# 41. Eksempel på komplet hændelse + +```text +07:40 +Internet monitor melder DOWN. + +07:41 +Gateway responderer stadig. + +07:41 +Hub følger eskaleringsdiagrammet. + +07:41 +Switch Bygning 1 = DOWN. + +07:41 +Switch Bygning 2 = UP. + +07:42 +Hub beregner impact. + +Berørt: +Kunde A +Kunde B +Kunde C + +Ikke berørt: +Kunde D +Kunde E + +07:42 +Hub vurderer: + +"Problemet ser ud til at være lokaliseret til +netværket i Bygning 1." + +07:42 +Driftssag oprettes. + +07:43 +Teknikeren kan åbne "Hvorfor?" + +Internet DOWN +→ Gateway UP +→ Switch B1 DOWN +→ Switch B2 UP +→ Bygning 1 berørt + +07:45 +Hub foreslår: + +"Der er 8 kontaktpersoner tilmeldt de berørte +driftsressourcer. + +Opret driftsbesked?" + +Teknikeren trykker JA. + +Hub genererer teksten. + +Teknikeren retter evt. teksten. + +Teknikeren trykker: + +GODKEND OG SEND +``` + +--- + +# 42. Målet + +Det færdige system skal gøre, at en tekniker ved en alarm ikke kun ser: + +> Device DOWN + +men i stedet får: + +```text +HVAD SKER DER? + +Netværksproblem Bygning 1 + + +HVEM ER BERØRT? + +3 kunder +8 kontaktpersoner + + +HVORFOR TROR HUB DET? + +Gateway = UP +Switch B1 = DOWN +Switch B2 = UP + + +HVAD SKAL VI GØRE? + +1. Kontrollér Switch B1 +2. Kontrollér uplink +3. Kontakt onsite person hvis nødvendigt + + +KRÆVER DET GODKENDELSE? + +Fejlsøgning: NEJ +Hardwareudskiftning: JA + + +KUNDEN INFORMERET? + +NEJ + +[ GENERÉR DRIFTSBESKED ] +``` + +Det er den retning, jeg ville bygge hele **Eskaleringsplan / Drift**-funktionen efter. + +--- + +# 43. Sameksistens og bagudkompatibilitet – bindende krav + +## 43.1 Eksisterende Drift-funktioner + +Følgende skal bevares under hele implementeringen: + +- Drift-siden med aktive alarmer, filtre, tællere og alarmhistorik +- kundens eksisterende fane **Drift** +- Uptime Kuma- og UISP-events og deres synkronisering +- eksisterende monitor-til-kunde-koblinger +- blacklist af driftsenheder +- godkendelse af alarmer +- eksisterende oprettelse og sammenkædning af sager fra driftsevents +- eksisterende API-endpoints og deres svar til nuværende klienter + +Den nye fane **Eskaleringsplan** må ikke erstatte eller omdøbe Drift-fanen. + +## 43.2 Data og migrering + +Nye driftsressourcer, ressourceforbindelser, eskaleringsflows og abonnements- +relationer oprettes som nye tabeller eller additive felter. De eksisterende +`drift_events`, `drift_devices`, `drift_customer_mappings` og links til sager +må ikke slettes, nulstilles eller have betydning ændret. + +En eksisterende monitor-kobling til én kunde skal fortsat virke. Delte +ressourcer modelleres i den nye ressource-relation, så én ressource kan have +flere kunder uden at ændre betydningen af den eksisterende monitor-kobling. + +Alle migreringer skal være additive og reversible. Før produktionsmigrering +skal der være en læseprøve, som dokumenterer antal eksisterende events, +mappings, blacklists og sagslinks før og efter migreringen. + +## 43.3 Status og API-kompatibilitet + +Den eksisterende Drift-statusmodel fortsætter uændret for eksisterende +endpoints og skærme. Den nye eskaleringsmotor kan arbejde med normaliserede +observationer som `UP`, `DOWN`, `DEGRADED`, `UNKNOWN` og `MAINTENANCE`, men +skal oversætte dem ved grænsen til den eksisterende Drift-model. + +Eksisterende filtrering, tællere og historik må derfor fortsat forstå aktive, +løste og godkendte alarmer. Nye API’er lægges under nye endpoints; eksisterende +Drift-endpoints ændres ikke i første version. + +## 43.4 Sager og maintenance + +Når et flow opretter en driftssag, skal det genbruge den eksisterende +sag-oprettelse og bevare linket mellem driftsevent og sag. En ny eskaleringssag +må have ekstra metadata om ressourcer, impact, flow-version og beslutningssti, +men må ikke miste sagens kunde, status, historik eller eksisterende sagslinks. + +Maintenance på ressourcer er en ny, sagstyret undertrykkelse af eskalerings- +handlinger. Den holdes helt adskilt fra Hubs eksisterende system-maintenance. +Ressource-maintenance må ikke ændre systemets maintenance-status, og system- +maintenance må ikke automatisk ændre en ressources maintenance. + +## 43.5 Implementeringsrækkefølge + +1. Kortlæg og test eksisterende Drift-data, endpoints og visninger. +2. Tilføj den additive ressource- og relationsmodel uden at ændre eksisterende + Drift-data. +3. Tilføj fanen **Eskaleringsplan** under kunden. +4. Tilføj læsning, impact, audit og hvorfor-visning. +5. Tilføj flow-handlinger og sag-oprettelse med dubletbeskyttelse. +6. Tilføj resource-maintenance og kundekommunikationsforslag. + +Hver fase frigives først, når eksisterende Drift-visning, events, +monitor-koblinger og sagslinks er verificeret uændrede. diff --git a/migrations/1043_prepaid_card_usage_alerts.sql b/migrations/1043_prepaid_card_usage_alerts.sql new file mode 100644 index 0000000..c6d66c7 --- /dev/null +++ b/migrations/1043_prepaid_card_usage_alerts.sql @@ -0,0 +1,120 @@ +-- Migration 1043: Idempotent accounting follow-up when prepaid cards reach 80% and 100% usage. +-- Existing card balances and transactions are left untouched. + +CREATE TABLE IF NOT EXISTS tticket_prepaid_card_alerts ( + id SERIAL PRIMARY KEY, + -- Keep the audit trail if an old card is later deleted. + card_id INTEGER NOT NULL, + alert_type VARCHAR(24) NOT NULL CHECK (alert_type IN ('usage_80_percent', 'usage_100_percent')), + case_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (card_id, alert_type) +); + +CREATE INDEX IF NOT EXISTS idx_prepaid_card_alerts_case + ON tticket_prepaid_card_alerts(case_id) WHERE case_id IS NOT NULL; + +CREATE OR REPLACE FUNCTION notify_prepaid_card_usage_threshold() +RETURNS TRIGGER AS $$ +DECLARE + v_alert_id INTEGER; + v_case_id INTEGER; + v_group_id INTEGER; + v_customer_name TEXT; +BEGIN + IF COALESCE(NEW.purchased_hours, 0) <= 0 THEN + RETURN NEW; + END IF; + + IF NEW.used_hours >= NEW.purchased_hours * 0.80 THEN + INSERT INTO tticket_prepaid_card_alerts (card_id, alert_type) + VALUES (NEW.id, 'usage_80_percent') + ON CONFLICT (card_id, alert_type) DO NOTHING + RETURNING id INTO v_alert_id; + + IF v_alert_id IS NOT NULL THEN + SELECT id INTO v_group_id + FROM groups + WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting') + ORDER BY CASE WHEN LOWER(TRIM(name)) = 'bogholderi' THEN 0 ELSE 1 END, id + LIMIT 1; + + SELECT name INTO v_customer_name FROM customers WHERE id = NEW.customer_id; + + INSERT INTO sag_sager ( + titel, beskrivelse, type, template_key, status, customer_id, + assigned_group_id, created_by_user_id + ) VALUES ( + 'Klippekort ' || NEW.card_number || ' har nået 80 %', + format('Klippekort %s for %s har brugt %.2f af %.2f timer (%.1f%%). Vurder om kunden skal tilbydes et nyt klippekort. Der er ikke oprettet en ordre eller et nyt klippekort automatisk.', + NEW.card_number, COALESCE(v_customer_name, 'kunden'), NEW.used_hours, + NEW.purchased_hours, NEW.used_hours / NEW.purchased_hours * 100), + 'bogholderi', 'bogholderi', 'åben', NEW.customer_id, + v_group_id, (SELECT user_id FROM users ORDER BY user_id LIMIT 1) + ) RETURNING id INTO v_case_id; + + UPDATE tticket_prepaid_card_alerts SET case_id = v_case_id WHERE id = v_alert_id; + END IF; + END IF; + + IF NEW.used_hours >= NEW.purchased_hours THEN + SELECT case_id INTO v_case_id + FROM tticket_prepaid_card_alerts + WHERE card_id = NEW.id AND alert_type = 'usage_80_percent'; + + IF v_case_id IS NULL THEN + -- Covers cards that were already above 80% when this migration was installed. + INSERT INTO tticket_prepaid_card_alerts (card_id, alert_type) + VALUES (NEW.id, 'usage_80_percent') + ON CONFLICT (card_id, alert_type) DO NOTHING + RETURNING id INTO v_alert_id; + + IF v_alert_id IS NOT NULL THEN + SELECT id INTO v_group_id + FROM groups + WHERE LOWER(TRIM(name)) IN ('bogholderi', 'økonomi', 'okonomi', 'accounting') + ORDER BY CASE WHEN LOWER(TRIM(name)) = 'bogholderi' THEN 0 ELSE 1 END, id + LIMIT 1; + SELECT name INTO v_customer_name FROM customers WHERE id = NEW.customer_id; + INSERT INTO sag_sager ( + titel, beskrivelse, type, template_key, status, customer_id, + assigned_group_id, created_by_user_id + ) VALUES ( + 'Klippekort ' || NEW.card_number || ' har nået 80 %', + format('Klippekort %s for %s har nået fuldt forbrug. Vurder eventuelt nyt klippekort.', + NEW.card_number, COALESCE(v_customer_name, 'kunden')), + 'bogholderi', 'bogholderi', 'åben', NEW.customer_id, v_group_id, + (SELECT user_id FROM users ORDER BY user_id LIMIT 1) + ) RETURNING id INTO v_case_id; + UPDATE tticket_prepaid_card_alerts SET case_id = v_case_id WHERE id = v_alert_id; + END IF; + END IF; + + INSERT INTO tticket_prepaid_card_alerts (card_id, alert_type, case_id) + VALUES (NEW.id, 'usage_100_percent', v_case_id) + ON CONFLICT (card_id, alert_type) DO NOTHING + RETURNING id INTO v_alert_id; + + IF v_alert_id IS NOT NULL AND v_case_id IS NOT NULL THEN + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (v_case_id, 'System', + format('Klippekort %s er nu fuldt brugt og er lukket for nyt forbrug.', NEW.card_number), TRUE); + END IF; + + UPDATE tticket_prepaid_cards SET status = 'depleted', updated_at = CURRENT_TIMESTAMP + WHERE id = NEW.id AND status = 'active'; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_prepaid_card_usage_thresholds ON tticket_prepaid_cards; +CREATE TRIGGER trg_prepaid_card_usage_thresholds +AFTER UPDATE OF used_hours ON tticket_prepaid_cards +FOR EACH ROW +WHEN (NEW.used_hours IS DISTINCT FROM OLD.used_hours) +EXECUTE FUNCTION notify_prepaid_card_usage_threshold(); + +COMMENT ON TABLE tticket_prepaid_card_alerts IS + 'One-time bookkeeping follow-up case references for prepaid card usage thresholds.'; diff --git a/migrations/1044_time_entry_audit_event.sql b/migrations/1044_time_entry_audit_event.sql new file mode 100644 index 0000000..87b0fa8 --- /dev/null +++ b/migrations/1044_time_entry_audit_event.sql @@ -0,0 +1,11 @@ +-- Add time-entry edits to the existing audit stream without changing older rows. +ALTER TABLE tmodule_sync_log DROP CONSTRAINT IF EXISTS check_event_type; + +ALTER TABLE tmodule_sync_log ADD CONSTRAINT check_event_type CHECK (event_type IN ( + 'sync_started', 'sync_completed', 'sync_failed', + 'approval', 'rejection', 'bulk_approval', + 'order_created', 'order_updated', 'order_cancelled', + 'export_started', 'export_completed', 'export_failed', + 'module_installed', 'module_uninstalled', + 'hourly_rate_updated', 'time_card_toggled', 'time_entry_updated' +)); diff --git a/migrations/1045_vtiger_case_hub_imports.sql b/migrations/1045_vtiger_case_hub_imports.sql new file mode 100644 index 0000000..637e400 --- /dev/null +++ b/migrations/1045_vtiger_case_hub_imports.sql @@ -0,0 +1,15 @@ +-- Track individual Cases copied from vTiger into native Hub cases. +-- Unique source IDs make retries safe; this does not modify existing records. +CREATE TABLE IF NOT EXISTS vtiger_case_hub_imports ( + vtiger_case_id VARCHAR(80) PRIMARY KEY, + ticket_number VARCHAR(80) NOT NULL, + hub_sag_id INTEGER NOT NULL UNIQUE REFERENCES sag_sager(id) ON DELETE RESTRICT, + hub_customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, + imported_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL, + imported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + vtiger_marked_at TIMESTAMP, + source_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_vtiger_case_hub_imports_customer + ON vtiger_case_hub_imports(hub_customer_id, imported_at DESC); diff --git a/migrations/1046_hub_case_vtiger_exports.sql b/migrations/1046_hub_case_vtiger_exports.sql new file mode 100644 index 0000000..0b07031 --- /dev/null +++ b/migrations/1046_hub_case_vtiger_exports.sql @@ -0,0 +1,20 @@ +-- Track explicit Hub -> vTiger case and time exports so retries stay idempotent. +CREATE TABLE IF NOT EXISTS hub_case_vtiger_exports ( + hub_sag_id INTEGER PRIMARY KEY REFERENCES sag_sager(id) ON DELETE RESTRICT, + vtiger_case_id VARCHAR(80) NOT NULL UNIQUE, + ticket_number VARCHAR(80), + status VARCHAR(20) NOT NULL DEFAULT 'partial' CHECK (status IN ('partial', 'complete')), + exported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_error TEXT +); + +CREATE TABLE IF NOT EXISTS hub_time_vtiger_exports ( + hub_time_id INTEGER PRIMARY KEY REFERENCES tmodule_times(id) ON DELETE RESTRICT, + hub_sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE RESTRICT, + vtiger_timelog_id VARCHAR(80) NOT NULL UNIQUE, + exported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_hub_time_vtiger_exports_case + ON hub_time_vtiger_exports(hub_sag_id, exported_at DESC); diff --git a/tests/test_economy_time_queue.py b/tests/test_economy_time_queue.py index eb03c29..94c4f9a 100644 --- a/tests/test_economy_time_queue.py +++ b/tests/test_economy_time_queue.py @@ -1,6 +1,12 @@ from pathlib import Path +import asyncio -from app.economy.backend.router import _normalise_billing_method, _settlement_label +from app.economy.backend.router import ( + SettlementRequest, + _build_settlement_preview, + _normalise_billing_method, + _settlement_label, +) ROOT = Path(__file__).resolve().parents[1] @@ -33,3 +39,74 @@ def test_case_customer_is_authoritative_for_queue_and_settlement(): # stored on a time entry. assert "COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id" in router_source assert "The case company is authoritative" in router_source + + +def test_time_queue_includes_solved_and_closed_cases_by_default(monkeypatch): + from app.economy.backend import router as economy_router + + captured = {} + + def fake_query(query, params): + captured["query"] = query + captured["params"] = params + return [{"id": 8, "case_status": "Løst"}] + + monkeypatch.setattr(economy_router, "execute_query", fake_query) + result = asyncio.run(economy_router.list_hub_time_queue( + customer_id=None, status=None, case_state=None, billable=None, q=None, limit=500 + )) + + assert result["items"][0]["case_status"] == "Løst" + assert "s.status" not in captured["query"].split("WHERE", 1)[1].split("ORDER BY", 1)[0] + + +def test_time_queue_has_explicit_closed_case_filter(monkeypatch): + from app.economy.backend import router as economy_router + + captured = {} + monkeypatch.setattr(economy_router, "execute_query", lambda query, params: captured.update(query=query, params=params) or []) + asyncio.run(economy_router.list_hub_time_queue( + customer_id=None, status=None, case_state="closed", billable=None, q=None, limit=500 + )) + + assert "'lukket', 'løst', 'closed', 'resolved', 'afsluttet', 'done'" in captured["query"] + assert "s.status" not in captured["params"] + + +def test_invoice_preview_uses_unrounded_draft_hours(monkeypatch): + from app.economy.backend import router as economy_router + + monkeypatch.setattr(economy_router, "_selected_time_entries", lambda ids: [{ + "id": 12, + "customer_id": 44, + "sag_id": 7, + "billing_method": "invoice", + "billable": True, + "approved_hours": 0.5, + "original_hours": 0.5, + "faktisk_tid_min": 18, + "case_title": "Support", + "customer_name": "Eksempel ApS", + }]) + monkeypatch.setattr( + economy_router, + "_resolve_tmodule_customer_id", + lambda customer_id, sag_id: 44, + ) + monkeypatch.setattr( + economy_router, + "execute_query_single", + lambda query, params: {"name": "Eksempel ApS", "hourly_rate": 1000}, + ) + + preview = _build_settlement_preview(SettlementRequest(ids=[12], billing_method="invoice")) + + assert preview["valid"] is True + assert preview["invoice_groups"] == [{ + "customer_id": 44, + "customer_name": "Eksempel ApS", + "entries": [12], + "hours": 0.5, + "hourly_rate": 1000.0, + "amount_ex_vat": 500.0, + }] diff --git a/tests/test_migration_center.py b/tests/test_migration_center.py index 2746236..19ac0be 100644 --- a/tests/test_migration_center.py +++ b/tests/test_migration_center.py @@ -1,3 +1,4 @@ +import asyncio from datetime import datetime, timezone from decimal import Decimal @@ -43,6 +44,116 @@ def test_normalizes_vtiger_subscription(): assert len(item["source_hash"]) == 64 +@pytest.mark.parametrize( + ("source", "expected"), + [("Open", "Ny"), ("In Progress", "Igangværende"), ("Resolved", "Løst"), ("Closed", "Lukket")], +) +def test_vtiger_case_status_maps_to_hub_status(source, expected): + assert router._case_status_from_vtiger(source) == expected + + +def test_vtiger_case_import_requires_cc_ticket_number(): + assert router.VtigerCaseImportRequest(ticket_number="cc12345").ticket_number == "CC12345" + with pytest.raises(ValueError): + router.VtigerCaseImportRequest(ticket_number="12345") + + +def test_vtiger_case_reference_and_text_fields_are_normalized(): + assert router._vtiger_reference_id({"value": "11x123"}) == "11x123" + assert router._vtiger_reference_id("not-an-id") is None + assert router._vtiger_text({"value": "72x222", "label": "Jane Doe"}) == "Jane Doe" + + +def test_vtiger_case_preview_text_removes_html_without_losing_paragraphs(): + source = "

Hej & velkommen

" + result = router._vtiger_plain_text(source) + assert result.startswith("Hej & velkommen\n") + assert "Første\nAnden" in result + assert "<" not in result + + +def test_vtiger_timelog_prefers_description_over_generic_title(): + row = {"name": "migration", "description": "Konfigurerede firewall", "assigned_user_id": "19x1"} + assert router._timelog_description(row) == "Konfigurerede firewall" + assert router._timelog_description(row, legacy=True) == "migration" + + +def test_hub_case_export_maps_case_status_and_priority_to_vtiger_picklists(): + assert router._hub_status_to_vtiger("Igangværende", {"New", "Open", "In Progress"}) == "In Progress" + assert router._hub_status_to_vtiger("Lukket", {"New", "Closed"}) == "Closed" + assert router._hub_priority_to_vtiger("normal", {"Low", "Medium", "High"}) == "Medium" + assert router._hub_priority_to_vtiger("urgent", {"Urgent", "High"}) == "Urgent" + + +def test_hub_case_export_time_payload_preserves_text_and_technician_without_billing_side_effects(): + payload = router._export_time_payload( + { + "id": 44, + "description": "Konfigurerede firewall", + "worked_date": datetime(2026, 9, 23).date(), + "original_hours": Decimal("1.50"), + "faktisk_tid_min": 90, + "fakturerbar_tid_min": 60, + "billable": True, + "billing_method": "prepaid", + }, + 173, + "17x100", + "19x1", + ) + assert payload["name"] == "Konfigurerede firewall" + assert payload["description"] == "Konfigurerede firewall" + assert payload["assigned_user_id"] == "19x1" + assert payload["duration"] == "5400" + assert payload["isbillable"] == "0" + assert payload["cf_timelog_invoiced"] == "0" + assert payload["timelog_status"] == "Completed" + + +def test_hub_case_export_matches_vtiger_user_by_email_then_exact_name(): + users = [ + {"id": "19x1", "first_name": "Christian", "last_name": "Thomas", "user_name": "ct", "email1": "ct@example.com"}, + {"id": "19x2", "first_name": "Jane", "last_name": "Vinter", "user_name": "jv", "email1": "jv@example.com"}, + ] + assert router._match_vtiger_user({"technician_email": "ct@example.com", "technician_name": "Someone"}, users) == "19x1" + assert router._match_vtiger_user({"technician_name": "Jane Vinter"}, users) == "19x2" + assert router._match_vtiger_user({"technician_name": "Unknown"}, users) is None + + +def test_vtiger_case_bundle_filters_timelogs_to_requested_case(monkeypatch): + class FakeVTiger: + async def query(self, query): + if "FROM Cases" in query: + return [{"id": "17x100", "ticket_no": "CC12345", "parent_id": "11x20", "subject": "VPN"}] + if "FROM Timelog" in query: + return [ + {"id": "31x1", "relatedto": "17x100", "duration": "600", "assigned_user_id": "19x1"}, + {"id": "31x2", "relatedto": "17x999", "duration": "3600"}, + ] + if "FROM ModComments" in query: + return [{"id": "17x900", "related_to": "17x100"}, {"id": "17x901", "related_to": "17x999"}] + if "FROM Users" in query: + return [{"id": "19x1", "first_name": "Jane", "last_name": "Vinter", "user_name": "jvinter"}] + if "FROM Groups" in query: + return [] + raise AssertionError(query) + + monkeypatch.setattr(router, "get_vtiger_service", lambda: FakeVTiger()) + monkeypatch.setattr( + router, + "execute_query_single", + lambda sql, params: ( + {"id": 4, "name": "Kunde", "vtiger_id": "11x20"} + if "FROM customers" in sql + else None + ), + ) + bundle = asyncio.run(router._load_vtiger_case_bundle("CC12345")) + assert [row["id"] for row in bundle["timelogs"]] == ["31x1"] + assert [row["id"] for row in bundle["comments"]] == ["17x900"] + assert bundle["timelog_assignee_names"] == {"19x1": "Jane Vinter"} + + def test_source_frequency_is_normalized_to_hub_interval(): assert router._hub_interval("monthly_3_last_day") == "monthly" assert router._hub_interval("Quarterly") == "quarterly" diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py index bda9488..83fc88b 100644 --- a/tests/test_sag_module.py +++ b/tests/test_sag_module.py @@ -487,6 +487,59 @@ def test_case_route_access_allows_view_permission_for_get(): assert result is user +def test_v4_case_tree_contains_parent_and_all_child_branches_and_stops_cycles(monkeypatch): + calls = iter([ + [{"id": 1, "titel": "Aktuel"}, {"id": 2, "titel": "Parent"}, {"id": 3, "titel": "Barn A"}, {"id": 4, "titel": "Barn B"}], + [ + {"parent_id": 2, "child_id": 1, "relationstype": "undersag"}, + {"parent_id": 1, "child_id": 3, "relationstype": "undersag"}, + {"parent_id": 1, "child_id": 4, "relationstype": "undersag"}, + {"parent_id": 3, "child_id": 1, "relationstype": "undersag"}, + ], + ]) + monkeypatch.setattr(sag_router, "execute_query", lambda *_args, **_kwargs: next(calls)) + + tree, truncated = sag_router._load_case_tree(1) + + assert truncated is False + assert tree[0]["id"] == 2 + assert tree[0]["children"][0]["id"] == 1 + current = tree[0]["children"][0] + assert current["is_current"] is True + assert {child["id"] for child in current["children"]} == {3, 4} + + +def test_v4_timeline_ui_uses_tree_filters_and_cursor_paging(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + router_source = Path("app/modules/sag/backend/router.py").read_text() + + assert 'id="caseTreeContent"' in template + assert 'id="historyRelationFilter"' in template + assert 'id="historyEventFilter"' in template + assert 'id="historyTimelineMore"' in template + assert "whole_tree:'true'" in template + assert "relation_types" in router_source + assert "event_types" in router_source + assert "Invalid timeline cursor" in router_source + + +def test_v4_relation_tree_is_own_tab_with_search_filters_and_inline_module_content(): + template = Path("app/modules/sag/templates/detail_v4.html").read_text() + + assert 'data-bs-target="#relations-tree"' in template + assert 'id="relations-tree"' in template + assert 'id="history"' in template + assert template.index('id="relations-tree"') < template.index('id="history"') + assert 'id="relationTreeSearch"' in template + assert 'id="relationTreeType"' in template + assert 'id="relationTreePosition"' in template + assert 'id="relationTreeStatusFilter"' in template + assert 'data-relation-module="comments"' in template + assert 'data-relation-module="todos"' in template + assert 'data-relation-module="files"' in template + assert 'data-relation-module="relations"' in template + + def test_case_route_access_requires_edit_for_nested_post(): user = {"permissions": ["cases.view", "cases.create"]} diff --git a/tests/test_timetracking_manual_time.py b/tests/test_timetracking_manual_time.py index 465ecc8..008683d 100644 --- a/tests/test_timetracking_manual_time.py +++ b/tests/test_timetracking_manual_time.py @@ -113,6 +113,92 @@ def test_customerless_time_recommends_internal_settlement(): assert result["agreements"] == [] +def test_settlement_choices_use_authoritative_case_customer_for_prepaid_card(monkeypatch): + card_query = {} + monkeypatch.setattr( + timetracking_router, + "execute_query_single", + lambda query, params: {"customer_id": 904} if "FROM sag_sager" in query else None, + ) + + def fake_execute_query(query, params): + if "FROM tticket_prepaid_cards" in query: + card_query["customer_id"] = params[0] + return [{"id": 72, "card_number": "CARD-TEST", "remaining_hours": 4.0}] + if "FROM customer_fixed_price_agreements" in query: + return [] + raise AssertionError(query) + + monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query) + options = timetracking_router._settlement_options_for_case_entry( + {"id": 12, "sag_id": 55, "customer_id": 3} + ) + + assert card_query["customer_id"] == 904 + assert options["prepaid_cards"][0]["id"] == 72 + assert options["recommended"]["method"] == "prepaid" + + +def test_patch_time_entry_refuses_settled_rows_without_changing_them(monkeypatch): + monkeypatch.setattr( + timetracking_router, + "execute_query_single", + lambda *_args: { + "id": 41, + "status": "billed", + "description": "Original", + "prepaid_card_id": None, + "economy_settled_at": None, + "economy_order_draft_id": None, + "billed_via_thehub_id": None, + }, + ) + monkeypatch.setattr( + timetracking_router, + "execute_query", + lambda *_args: (_ for _ in ()).throw(AssertionError("settled rows must not be updated")), + ) + + try: + asyncio.run(timetracking_router.patch_time_entry_v1(41, {"description": "Changed"})) + except Exception as exc: + assert getattr(exc, "status_code", None) == 409 + assert "korrektion" in str(getattr(exc, "detail", "")).lower() + else: + raise AssertionError("settled time entry unexpectedly accepted an edit") + + +def test_patch_time_entry_logs_before_after_for_editable_rows(monkeypatch): + existing = { + "id": 42, + "status": "pending", + "description": "Before", + "prepaid_card_id": None, + "economy_settled_at": None, + "economy_order_draft_id": None, + "billed_via_thehub_id": None, + } + updated = {**existing, "description": "After"} + monkeypatch.setattr(timetracking_router, "execute_query_single", lambda *_args: existing) + monkeypatch.setattr(timetracking_router, "execute_query", lambda *_args: [updated]) + captured = {} + monkeypatch.setattr(timetracking_router.audit, "log_event", lambda **kwargs: captured.update(kwargs) or 5) + + result = asyncio.run( + timetracking_router.patch_time_entry_v1( + 42, + {"description": "After", "reason": "Rettelse"}, + current_user={"id": 9}, + ) + ) + + assert result["description"] == "After" + assert captured["event_type"] == "time_entry_updated" + assert captured["user_id"] == 9 + assert captured["details"]["changes"]["description"] == {"before": "Before", "after": "After"} + assert captured["details"]["reason"] == "Rettelse" + + def test_case_time_form_sends_local_time_and_multiple_employees(): template = Path("app/modules/sag/templates/detail_v3.html").read_text() manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split(