Add tests for case tree structure, timeline UI, and relation tree

- Implement tests to ensure the case tree contains parent and child branches while preventing cycles.
- Verify that the timeline UI utilizes tree filters and cursor paging.
- Confirm that the relation tree is displayed as a separate tab with appropriate search filters and inline module content.

Enhance time tracking with prepaid card usage alerts

- Create a migration to track alerts for prepaid card usage at 80% and 100%.
- Implement triggers to generate cases when usage thresholds are reached, ensuring proper notifications and case management.

Introduce audit logging for time entry edits

- Modify the sync log to include time entry updates in the audit stream without altering existing records.

Establish tracking for vTiger case imports and exports

- Create tables to track cases imported from vTiger into Hub and exports from Hub to vTiger, ensuring idempotency and proper reference management.
This commit is contained in:
Christian 2026-09-24 09:12:25 +02:00
parent 81b8229db1
commit 28a92eeb55
18 changed files with 3609 additions and 112 deletions

View File

@ -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

View File

@ -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)

View File

@ -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}
</style>
<main class="container-fluid py-4 time-queue">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="text-primary text-uppercase fw-semibold small">Økonomi</div><h1 class="h2 mb-1">Tidskø til afregning</h1><p class="text-muted mb-0">Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.</p></div><button class="btn btn-outline-secondary" id="reloadBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
<div id="messageArea" aria-live="polite"></div>
<section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries">–</strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours">–</strong></div><div class="queue-stat"><div class="small text-muted">Klar til faktura</div><strong id="statInvoice">–</strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Intet afregnes uden forhåndsvisning.</div></div></section>
<section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Status</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-4"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section>
<section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
<section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Kontrollér beløb og fejl før afregningen gennemføres.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></section>
<section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries">–</strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours">–</strong></div><div class="queue-stat"><div class="small text-muted">Klar til faktura</div><strong id="statInvoice">–</strong></div><div class="queue-stat"><div class="small text-muted">Sager løst/lukket</div><strong id="statClosedCases">–</strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Forhåndsvisning kontrollerer; den ændrer ikke data.</div></div></section>
<section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Tidsstatus</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Sagens status</label><select id="filterCaseState" class="form-select"><option value="">Alle sager (inkl. løst/lukket)</option><option value="closed">Løst eller lukket</option><option value="open">Åbne sager</option><option value="no_case">Uden sag</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-2"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section>
<section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale. Løst/lukket sagsstatus forhindrer ikke afregning.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
<section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Dette er en kontrol uden ændringer. Først når du trykker “Gennemfør”, gemmes afregningen.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer"><div class="small text-muted mb-2">Faktura opretter en lokal ordrekladde. Klippekort trækker timer. Fastpris knytter timer til aftalen. Intet sendes eller bogføres i e-conomic her.</div><div class="text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></div></section>
<div class="modal fade" id="caseDetailModal" tabindex="-1" aria-labelledby="caseDetailTitle" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><div class="small text-primary text-uppercase fw-semibold">Sag uden at forlade tidskøen</div><h2 class="modal-title h4 mb-0" id="caseDetailTitle">Sagsdetaljer</h2></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body" id="caseDetailBody"></div><div class="modal-footer"><a class="btn btn-outline-primary" id="openCaseNewTab" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag i ny fane</a><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div></div></div></div>
<section id="queueContent" aria-live="polite"></section>
<div class="queue-actions card d-none" id="selectionActions"><div class="card-body py-2 d-flex flex-wrap justify-content-between align-items-center gap-2"><div><strong id="selectionText">0 tidsregistreringer valgt</strong><span class="small text-muted ms-2">Vælg forhåndsvisning før afregning.</span></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" id="clearSelectionBtn">Fravælg</button><button class="btn btn-sm btn-primary" id="stickyPreviewBtn">Forhåndsvis afregning</button></div></div></div>
@ -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 `<span class="badge ${v==='approved'?'text-bg-success':v==='rejected'?'text-bg-danger':'text-bg-warning'}">${esc(labels[v]||v||'Ukendt')}</span>`} 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 `<span class="badge ${cls}">${esc(labels[m]||m)}</span>`}
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 '<span class="badge text-bg-light border">Uden sag</span>';return `<span class="badge ${isClosedCase(v)?'case-closed-badge':'case-open-badge'}">Sag: ${esc(v)}</span>`}
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='<span class="text-danger">Vælg kun tider fra ét firma for at bruge ét klippekort.</span>';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: <strong>${h(actual)} t</strong> → klippes som <strong>${h(hours)} t</strong> <span class="text-muted">(${block} min. afrunding pr. registrering)</span>. Kortet har ${h(remaining)} t tilbage → <strong class="${after<0?'text-danger':'text-success'}">${h(after)} t efter afregning</strong>.`}
function render(){renderStats();if(!state.items.length){$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';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 `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-link customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg kunde</button></header>${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(m)} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end"><button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}</div>`}).join('')}</article>`}).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='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';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 `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-outline-primary customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg alle ${items.length} tider</button></header>${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${caseStatus(x.case_status)} ${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(m)} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end"><button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}</div>`}).join('')}</article>`}).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=`<option value="">Alle firmaer</option>${(d.items||[]).map(x=>`<option value="${Number(x.customer_id)}">${esc(x.customer_name)} (${Number(x.open_count||0)})</option>`).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=`<option value="">${customers.length>1?'Vælg tider fra ét firma':'Vælg først tider fra ét firma'}</option>`;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=`<option value="">Vælg kundens klippekort</option>${state.cards.map(x=>`<option value="${Number(x.id)}">${esc(x.card_number)} · ${h(x.remaining_hours)} timer tilbage</option>`).join('')}`;select.disabled=!state.cards.length;if(state.cards.some(x=>Number(x.id)===current))select.value=String(current);renderPrepaidHelp()}catch(_){select.innerHTML='<option value="">Klippekort kunne ikke hentes</option>';renderPrepaidHelp()}}
async function load(){if(state.loading)return;state.loading=true;$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter tidsregistreringer…</div>';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=`<div class="alert alert-danger">${esc(e.message)}</div>`}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='<div class="py-4 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter sagsdetaljer…</div>';modal.show();try{const s=await api(`/api/v1/sag/${sagId}`),description=s.beskrivelse||row.description||'Ingen beskrivelse på sagen.';$('caseDetailBody').innerHTML=`<div class="d-flex flex-wrap gap-2 mb-3">${status(s.status)}<span class="badge text-bg-light border">${esc(s.type||'Sag')}</span><span class="badge text-bg-light border">${esc(s.prioritet||'Normal')}</span></div><div class="case-detail-meta mb-3"><div><small>Kunde</small><strong>${esc(row.customer_name||'Ikke tilknyttet')}</strong></div><div><small>Kontakt</small><strong>${esc(row.contact_name||'Ikke tilknyttet')}</strong></div><div><small>Ansvarlig</small><strong>${esc(s.ansvarlig_navn||'Ikke tildelt')}</strong></div><div><small>Senest opdateret</small><strong>${esc(s.updated_at||s.opdateret_at||'Ikke oplyst')}</strong></div></div><h3 class="h6">Beskrivelse</h3><div class="border rounded p-3 bg-light">${esc(description).replace(/\n/g,'<br>')}</div>`}catch(e){$('caseDetailBody').innerHTML=`<div class="alert alert-danger mb-0">${esc(e.message)}</div>`}}
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?`<div class="alert alert-danger"><strong>Afregningen kan ikke gennemføres endnu.</strong><ul class="mb-0 mt-2">${errors.map(x=>`<li>Tid #${Number(x.id)}: ${esc(x.message)}</li>`).join('')}</ul></div>`:'<div class="alert alert-success"><i class="bi bi-check-circle me-1"></i>Alle valgte tider er klar til afregning.</div>'}<div class="row g-3">${invoice.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light"><strong>Faktura · ${esc(x.customer_name)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t</div><div class="fs-5 fw-semibold">${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms</div><div class="small text-muted">Gebyr og evt. fragt vises i ordrekladden.</div></div></div>`).join('')}${prepaid.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#6f42c1"><strong>Klippekort · ${esc(x.card_number||'#'+x.card_id)}</strong><div class="small text-muted">Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t</div></div></div>`).join('')}${sub.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#198754"><strong>Abonnement / fast pris · aftale #${Number(x.agreement_id)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden fakturalinje.</div></div></div>`).join('')}</div>`;$('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=>`<div class="preview-item d-flex flex-wrap justify-content-between gap-2"><div><strong>${esc(x.title||`Tid #${x.id}`)}</strong> <span class="small text-muted">${esc(x.customer_name||'Ukendt kunde')}</span><div class="small mt-1">${caseStatus(x.case_status)} <span class="text-muted">Sagstatus er kun information; løst/lukket sag må gerne afregnes.</span></div></div><div class="text-end"><strong>${h(x.hours)} t</strong><div class="small text-muted">Faktisk ${h(x.actual_hours)} t · ${esc(labels[x.method]||x.method_label||x.method)}</div>${x.message?`<div class="small text-danger">${esc(x.message)}</div>`:''}</div></div>`).join('');
$('previewBody').innerHTML=`<div class="alert alert-info py-2"><strong>Hvad gør forhåndsvisning?</strong> 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”.</div>${errors.length?`<div class="alert alert-danger"><strong>Afregningen kan ikke gennemføres endnu.</strong><ul class="mb-0 mt-2">${errors.map(x=>`<li>Tid #${Number(x.id)}: ${esc(x.message)}</li>`).join('')}</ul></div>`:'<div class="alert alert-success"><i class="bi bi-check-circle me-1"></i>Alle valgte tider kan afregnes. Løst/lukket sagsstatus blokerer ikke.</div>'}<div class="row g-3">${invoice.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light"><strong>Faktura · ${esc(x.customer_name)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t</div><div class="fs-5 fw-semibold">${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms</div><div class="small text-muted">Gebyr og evt. fragt vises i ordrekladde.</div></div></div>`).join('')}${prepaid.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#6f42c1"><strong>Klippekort · ${esc(x.card_number||'#'+x.card_id)}</strong><div class="small text-muted">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</div><div class="small text-muted">Afrunding: ${Number(x.rounding_minutes)||30} min. pr. registrering</div></div></div>`).join('')}${sub.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#198754"><strong>Abonnement / fast pris · aftale #${Number(x.agreement_id)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden særskilt fakturalinje.</div></div></div>`).join('')}</div><h3 class="h6 mt-4">Valgte tidsregistreringer (${items.length})</h3><div class="preview-items">${itemRows||'<div class="text-muted">Ingen registreringer.</div>'}</div>`;
$('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='<span class="spinner-border spinner-border-sm me-1"></span>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',`<p><a class="btn btn-outline-primary" href="${esc(d.orders_url)}">Åbn ordrekladde</a></p>`);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='<i class="bi bi-check2-circle me-1"></i>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();
})();
</script>
{% endblock %}

View File

@ -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,

View File

@ -64,6 +64,8 @@
<li class="nav-item"><button class="nav-link" data-view="deviations">Afvigelser</button></li>
<li class="nav-item"><button class="nav-link" data-view="locked">Låste</button></li>
<li class="nav-item"><button class="nav-link" data-view="audit">Log / historik</button></li>
<li class="nav-item"><button class="nav-link" data-view="case_import"><i class="bi bi-arrow-left-right me-1"></i>Overfør VTiger-sag</button></li>
<li class="nav-item"><button class="nav-link" data-view="hub_export"><i class="bi bi-box-arrow-up-right me-1"></i>Overfør Hub-sag til VTiger</button></li>
</ul>
<section id="queuePanel" class="card mc-stat mc-queue mb-4 d-none">
@ -104,6 +106,81 @@
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between gap-3 align-items-end"><div><h2 class="h5 mb-1" id="historyTitle">Fakturahistorik</h2><p class="text-muted mb-0">Grupperet på kunde og vare for de seneste 13 måneder. Forslag opretter aldrig noget automatisk.</p></div><div style="min-width:300px"><label class="form-label mc-label">Filtrér på virksomhed</label><select id="historyCompanyFilter" class="form-select" onchange="loadInvoiceHistory(view==='subscription_candidates')"><option value="">Alle virksomheder</option></select></div></div>
<div class="table-responsive"><table class="table table-hover mc-table mb-0"><thead><tr><th>Kunde</th><th>Vare</th><th>Fakturaer</th><th>Måneder</th><th>Periode</th><th>Beløb</th><th>Frekvens</th><th>Hub-match</th><th>Handling</th></tr></thead><tbody id="historyBody"></tbody></table></div>
</section>
<section id="caseImportPanel" class="card mc-stat d-none">
<div class="card-body p-4 p-lg-5">
<div class="row g-4 align-items-start">
<div class="col-12 col-xl-5">
<div class="mc-label mb-2">Enkeltvis, kontrolleret overførsel</div>
<h2 class="h4">Overfør vTiger-sag</h2>
<p class="text-muted">Indtast sagsnummeret fra vTiger, fx <strong>CC12345</strong>. Først vises en forhåndsvisning; intet ændres, før du bekræfter overførslen.</p>
<label class="form-label fw-semibold" for="vtigerTicketNumber">vTiger-sagsnummer</label>
<div class="input-group input-group-lg">
<input id="vtigerTicketNumber" class="form-control" maxlength="80" placeholder="CC12345" autocomplete="off">
<button id="vtigerPreviewButton" class="btn btn-primary" type="button" onclick="previewVtigerCase()"><i class="bi bi-search me-1"></i>Find sag</button>
</div>
<div class="form-text">Sagen skal allerede være knyttet til en kunde, som findes og er linket i Hub.</div>
<div id="vtigerImportAlert" class="alert d-none mt-3 mb-0" role="status" aria-live="polite"></div>
</div>
<div class="col-12 col-xl-7">
<div id="vtigerPreviewEmpty" class="mc-source-card h-100 d-flex align-items-center justify-content-center text-center text-muted py-5">Sagens kunde, status og registrerede tid vises her, inden du overfører.</div>
<div id="vtigerPreviewCard" class="mc-source-card d-none">
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-start">
<div><div class="mc-label">Forhåndsvisning</div><h3 class="h5 mb-1" id="vtigerPreviewTitle"></h3><div class="text-muted" id="vtigerPreviewMeta"></div></div>
<div id="vtigerPreviewState"></div>
</div>
<p class="mt-3 mb-3" id="vtigerPreviewDescription"></p>
<div class="row g-2 mb-3">
<div class="col-6 col-md-3"><div class="mc-label">Kunde</div><div class="fw-semibold" id="vtigerPreviewCustomer"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Kontakt</div><div class="fw-semibold" id="vtigerPreviewContact"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Tidsregistreringer</div><div class="fw-semibold" id="vtigerPreviewTimes"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Prioritet</div><div class="fw-semibold" id="vtigerPreviewPriority"></div></div>
</div>
<div class="alert alert-info py-2 mb-3 small">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.</div>
<button id="vtigerImportButton" class="btn btn-success" type="button" onclick="importVtigerCase()"><i class="bi bi-check2-circle me-1"></i>Overfør sag til Hub</button>
</div>
</div>
</div>
</div>
</section>
<section id="hubExportPanel" class="card mc-stat d-none">
<div class="card-body p-4 p-lg-5">
<div class="row g-4 align-items-start">
<div class="col-12 col-xl-4">
<div class="mc-label mb-2">Hub → VTiger</div>
<h2 class="h4">Overfør Hub-sag til VTiger</h2>
<p class="text-muted">Sagen og godkendte Hub-timer overføres efter forhåndsvisning. VTiger forbliver stedet, hvor faktureringen håndteres.</p>
<label class="form-label fw-semibold" for="hubExportSagId">Hub-sagsnummer</label>
<div class="input-group input-group-lg">
<span class="input-group-text">#</span><input id="hubExportSagId" class="form-control" type="number" min="1" step="1" placeholder="173">
<button id="hubExportPreviewButton" class="btn btn-primary" type="button" onclick="previewHubCaseExport()"><i class="bi bi-search me-1"></i>Find sag</button>
</div>
<div class="form-text">Kunden og en VTiger-linket primær kontakt skal være knyttet til sagen.</div>
<div id="hubExportAlert" class="alert d-none mt-3 mb-0" role="status" aria-live="polite"></div>
</div>
<div class="col-12 col-xl-8">
<div id="hubExportPreviewEmpty" class="mc-source-card h-100 d-flex align-items-center justify-content-center text-center text-muted py-5">Sagsfelter og godkendte tidsregistreringer vises her, inden du overfører.</div>
<div id="hubExportPreviewCard" class="mc-source-card d-none">
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-start">
<div><div class="mc-label">Forhåndsvisning</div><h3 class="h5 mb-1" id="hubExportTitle"></h3><div class="text-muted" id="hubExportMeta"></div></div>
<div id="hubExportState"></div>
</div>
<p class="mt-3 mb-3" id="hubExportDescription"></p>
<div class="row g-2 mb-3">
<div class="col-6 col-md-3"><div class="mc-label">Kunde</div><div class="fw-semibold" id="hubExportCustomer"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Kontakt</div><div class="fw-semibold" id="hubExportContact"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">VTiger-status</div><div class="fw-semibold" id="hubExportStatus"></div></div>
<div class="col-6 col-md-3"><div class="mc-label">Prioritet</div><div class="fw-semibold" id="hubExportPriority"></div></div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2"><strong>Tider til VTiger</strong><span class="small text-muted" id="hubExportTimesSummary"></span></div>
<div class="table-responsive mb-3"><table class="table table-sm align-middle"><thead><tr><th>Dato</th><th>Tekniker</th><th>Beskrivelse</th><th class="text-end">Timer</th><th>Fakturerbar</th></tr></thead><tbody id="hubExportTimesBody"></tbody></table></div>
<div id="hubExportBlockers" class="alert alert-warning d-none py-2 small"></div>
<div class="alert alert-info py-2 mb-3 small">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.</div>
<button id="hubExportButton" class="btn btn-success" type="button" onclick="exportHubCaseToVtiger()"><i class="bi bi-check2-circle me-1"></i>Overfør sag til VTiger</button>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
@ -118,6 +195,8 @@
<script>
const API='/api/v1/migration-center';
let sessions=[], sessionId=null, page=1, total=0, view='all', currentItem=null, pendingPreflight=null;
let vtigerPreview=null;
let hubExportPreviewData=null;
const modal=()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('actionModal'));
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
async function api(path, options={}){
@ -125,6 +204,71 @@ async function api(path, options={}){
if(!res.ok){let d={};try{d=await res.json()}catch{};throw new Error(typeof d.detail==='object'?(d.detail.message||JSON.stringify(d.detail)):(d.detail||`HTTP ${res.status}`))}
return res.headers.get('content-type')?.includes('json')?res.json():res.text();
}
function setVtigerImportAlert(message, type='info'){
const el=document.getElementById('vtigerImportAlert');el.className=`alert alert-${type} mt-3 mb-0`;el.textContent=message;
}
async function previewVtigerCase(){
const input=document.getElementById('vtigerTicketNumber'),button=document.getElementById('vtigerPreviewButton'),number=input.value.trim().toUpperCase();
if(!/^CC[0-9]{1,12}$/.test(number)){setVtigerImportAlert('Indtast sagsnummer i formatet CC12345.','warning');input.focus();return}
button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Henter…';vtigerPreview=null;
document.getElementById('vtigerPreviewCard').classList.add('d-none');document.getElementById('vtigerPreviewEmpty').classList.remove('d-none');
try{
const d=await api('/vtiger-cases/preview',{method:'POST',body:JSON.stringify({ticket_number:number})});vtigerPreview=d;
document.getElementById('vtigerPreviewEmpty').classList.add('d-none');document.getElementById('vtigerPreviewCard').classList.remove('d-none');
document.getElementById('vtigerPreviewTitle').textContent=d.title;document.getElementById('vtigerPreviewMeta').textContent=`${d.ticket_number} · ${d.status}`;
document.getElementById('vtigerPreviewDescription').textContent=d.description||'Ingen beskrivelse';document.getElementById('vtigerPreviewCustomer').textContent=d.customer?.name||'—';
document.getElementById('vtigerPreviewContact').textContent=d.contact?[d.contact.first_name,d.contact.last_name].filter(Boolean).join(' ')||'—':'—';
document.getElementById('vtigerPreviewTimes').textContent=`${d.time_entries} · ${Number(d.time_hours).toFixed(2)} timer`;document.getElementById('vtigerPreviewPriority').textContent=d.priority;
document.getElementById('vtigerPreviewState').innerHTML=d.already_imported?`<span class="mc-badge mc-green">Allerede overført · Hub-sag #${esc(d.hub_sag_id)}</span>`:`<span class="mc-badge mc-blue">Klar til gennemgang</span>`;
const importButton=document.getElementById('vtigerImportButton');importButton.disabled=d.already_imported&&d.vtiger_marked;importButton.innerHTML=d.already_imported?(d.vtiger_marked?'Allerede overført og markeret':'Prøv VTiger-markering igen'):'Overfør sag til Hub';
setVtigerImportAlert(d.comments_available_not_imported?`${d.comments_available_not_imported} vTiger-kommentarer kopieres ikke, fordi deres synlighedstype ikke kan fastslås sikkert.`:'Forhåndsvisning klar. Ingen data er ændret.','info');
}catch(e){setVtigerImportAlert(e.message,'danger')}
finally{button.disabled=false;button.innerHTML='<i class="bi bi-search me-1"></i>Find sag'}
}
async function importVtigerCase(){
if(!vtigerPreview)return;const button=document.getElementById('vtigerImportButton');
const promptText=vtigerPreview.already_imported?'Forsøg at markere den eksisterende Hub-sag som Hubbet i vTiger?':'Overfør '+vtigerPreview.ticket_number+' til Hub med de viste tidsregistreringer og markér derefter vTiger-sagen som Hubbet?';
if(!window.confirm(promptText))return;
button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Overfører…';
try{
const d=await api('/vtiger-cases/import',{method:'POST',body:JSON.stringify({ticket_number:vtigerPreview.ticket_number})});
if(d.warning){await previewVtigerCase();setVtigerImportAlert(`${d.warning} Du kan trykke “Prøv VTiger-markering igen”; Hub-sagen bliver ikke oprettet på ny.`,'warning')}
else{await previewVtigerCase();setVtigerImportAlert(d.imported?`Sagen er overført til Hub som sag #${d.hub_sag_id}; ${d.time_entries} tidsregistreringer blev tilknyttet. vTiger er markeret Hubbet.`:`Hub-sag #${d.hub_sag_id} var allerede oprettet. vTiger er nu markeret Hubbet.`,'success')}
}catch(e){setVtigerImportAlert(e.message,'danger')}
finally{button.disabled=Boolean(vtigerPreview?.already_imported&&vtigerPreview?.vtiger_marked);if(!button.disabled)button.innerHTML=vtigerPreview?.already_imported?'Prøv VTiger-markering igen':'<i class="bi bi-check2-circle me-1"></i>Overfør sag til Hub'}
}
function setHubExportAlert(message,type='info'){
const el=document.getElementById('hubExportAlert');el.className=`alert alert-${type} mt-3 mb-0`;el.textContent=message;
}
async function previewHubCaseExport(){
const input=document.getElementById('hubExportSagId'),button=document.getElementById('hubExportPreviewButton'),sagId=Number(input.value);
if(!Number.isInteger(sagId)||sagId<1){setHubExportAlert('Indtast et gyldigt Hub-sagsnummer.','warning');input.focus();return}
button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Henter…';hubExportPreviewData=null;
document.getElementById('hubExportPreviewCard').classList.add('d-none');document.getElementById('hubExportPreviewEmpty').classList.remove('d-none');
try{
const d=await api('/hub-cases/preview',{method:'POST',body:JSON.stringify({sag_id:sagId})});hubExportPreviewData=d;
document.getElementById('hubExportPreviewEmpty').classList.add('d-none');document.getElementById('hubExportPreviewCard').classList.remove('d-none');
document.getElementById('hubExportTitle').textContent=d.title;document.getElementById('hubExportMeta').textContent=`Hub sag #${d.sag_id} · ${d.status||'—'} · VTiger: ${d.vtiger_case_id?d.ticket_number||d.vtiger_case_id:'ny sag'}`;
document.getElementById('hubExportDescription').textContent=d.description||'Ingen beskrivelse';document.getElementById('hubExportCustomer').textContent=d.customer_name||'—';document.getElementById('hubExportContact').textContent=d.contact_name||'—';document.getElementById('hubExportStatus').textContent=d.vtiger_status||'—';document.getElementById('hubExportPriority').textContent=d.vtiger_priority||'—';
document.getElementById('hubExportTimesSummary').textContent=`${d.time_entries} godkendte · ${Number(d.time_hours).toFixed(2)} timer${d.pending_time_count?` · ${d.pending_time_count} ikke-godkendte udeladt`:''}`;
document.getElementById('hubExportTimesBody').innerHTML=d.time_rows.length?d.time_rows.map(t=>`<tr><td>${esc(t.date||'—')}</td><td>${esc(t.technician)}</td><td>${esc(t.description||'Ingen beskrivelse')}</td><td class="text-end">${Number(t.hours).toFixed(2)}</td><td>${t.billable?'Ja':'Nej'}</td></tr>`).join(''):'<tr><td colspan="5" class="text-muted">Ingen nye, godkendte Hub-timer at overføre.</td></tr>';
const blockers=document.getElementById('hubExportBlockers');blockers.classList.toggle('d-none',!d.blockers.length);blockers.textContent=d.blockers.join(' ');
const buttonEl=document.getElementById('hubExportButton');buttonEl.disabled=d.blockers.length>0||d.already_exported&&d.time_entries===0;buttonEl.innerHTML=d.already_exported?(d.time_entries?'Fortsæt overførsel af manglende timer':'Allerede overført'):'<i class="bi bi-check2-circle me-1"></i>Overfør sag til VTiger';
setHubExportAlert(d.blockers.length?'Ret punkterne ovenfor før overførslen.':d.already_exported?'Sagen findes allerede i VTiger. Kun eventuelle manglende timer kan fortsættes.':'Forhåndsvisning klar. Ingen data er ændret.','info');
}catch(e){setHubExportAlert(e.message,'danger')}
finally{button.disabled=false;button.innerHTML='<i class="bi bi-search me-1"></i>Find sag'}
}
async function exportHubCaseToVtiger(){
const d=hubExportPreviewData;if(!d)return;
if(!window.confirm(`Overfør Hub-sag #${d.sag_id} og ${d.time_entries} godkendte tidsregistreringer til VTiger? Fakturering fortsætter i VTiger.`))return;
const button=document.getElementById('hubExportButton');button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Overfører…';
try{
const result=await api('/hub-cases/export',{method:'POST',body:JSON.stringify({sag_id:d.sag_id,source_hash:d.source_hash})});
await previewHubCaseExport();
setHubExportAlert(result.warning?`Sagen er ${result.created_case?'oprettet':'allerede linket'} i VTiger, men nogle timer mangler: ${result.warning} Du kan fortsætte uden at oprette sagen igen.`:`Sagen er ${result.created_case?'oprettet':'allerede linket'} i VTiger${result.ticket_number?` som ${result.ticket_number}`:''}; ${result.created_time_entries} tidsregistreringer overført.` ,result.warning?'warning':'success');
}catch(e){setHubExportAlert(e.message,'danger')}
finally{if(hubExportPreviewData)button.disabled=Boolean(hubExportPreviewData.blockers.length)||hubExportPreviewData.time_entries===0;button.innerHTML='Overfør sag til VTiger'}
}
function badge(value){
const green=['created_in_hub','linked_to_existing','verified','approved','locked','match_found'];
const red=['conflict','source_changed','lock_failed','no_match','rejected'];
@ -137,7 +281,12 @@ async function init(){
select.innerHTML=sessions.map(s=>`<option value="${s.id}">${esc(s.name)} · ${s.treated_count}/${s.item_count}</option>`).join('');
const remembered=Number(localStorage.getItem('migrationCenterSession'));
sessionId=(sessions.find(s=>s.id===remembered)||sessions[0])?.id||null;
if(!sessionId){document.getElementById('emptyState').classList.remove('d-none');return}
if(!sessionId){
document.getElementById('workspace').classList.remove('d-none');document.getElementById('stats').classList.add('d-none');
document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>{if(!['case_import','hub_export'].includes(btn.dataset.view))btn.closest('.nav-item').classList.add('d-none')});
document.querySelector('#sessionSelect').closest('.d-flex').classList.add('d-none');
document.querySelector('[data-view="case_import"]').click();return
}
select.value=sessionId; select.onchange=()=>{sessionId=Number(select.value);localStorage.setItem('migrationCenterSession',sessionId);refresh()};
document.getElementById('workspace').classList.remove('d-none');
await refresh();
@ -339,9 +488,14 @@ document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>btn.onclick=async()=
document.getElementById('queuePanel').classList.toggle('d-none',view!=='queue');
document.getElementById('auditPanel').classList.toggle('d-none',view!=='audit');
document.getElementById('historyPanel').classList.toggle('d-none',!['invoice_history','subscription_candidates'].includes(view));
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates'].includes(view));
document.getElementById('caseImportPanel').classList.toggle('d-none',view!=='case_import');
document.getElementById('hubExportPanel').classList.toggle('d-none',view!=='hub_export');
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates','case_import','hub_export'].includes(view));
if(['case_import','hub_export'].includes(view)){document.getElementById('queuePanel').classList.add('d-none');document.getElementById('auditPanel').classList.add('d-none');document.getElementById('historyPanel').classList.add('d-none');return}
await loadItems(1);
});
document.getElementById('vtigerTicketNumber').addEventListener('keydown',e=>{if(e.key==='Enter')previewVtigerCase()});
document.getElementById('hubExportSagId').addEventListener('keydown',e=>{if(e.key==='Enter')previewHubCaseExport()});
document.getElementById('prevPage').onclick=()=>loadItems(page-1);document.getElementById('nextPage').onclick=()=>loadItems(page+1);
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')loadItems(1)});
init().catch(e=>{document.getElementById('emptyState').classList.remove('d-none');document.getElementById('emptyState').querySelector('.card-body').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`});

View File

@ -4072,15 +4072,143 @@ async def get_case_linked_activities(sag_id: int):
return {"activities": activities, "total": len(activities)}
def _normalized_case_relation_sql() -> str:
return """SELECT
CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id
ELSE kilde_sag_id END AS parent_id,
CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id
WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id
ELSE målsag_id END AS child_id,
relationstype
FROM sag_relationer WHERE deleted_at IS NULL"""
def _load_case_tree(sag_id: int, include_all: bool = True) -> tuple[list[dict], bool]:
"""Return the focused case's whole connected parent/child tree, cycle safe."""
relations = execute_query(
f"""WITH RECURSIVE normalized AS ({_normalized_case_relation_sql()}),
connected(case_id) AS (
SELECT %s
UNION
SELECT CASE WHEN n.parent_id = c.case_id THEN n.child_id ELSE n.parent_id END
FROM connected c JOIN normalized n
ON n.parent_id = c.case_id OR n.child_id = c.case_id
)
SELECT DISTINCT s.id, s.titel, s.status, s.type
FROM connected c JOIN sag_sager s ON s.id = c.case_id
WHERE s.deleted_at IS NULL ORDER BY s.id""",
(sag_id,),
) or []
if not include_all:
relations = [row for row in relations if row.get("id") == sag_id]
ids = {row.get("id") for row in relations}
if not ids:
return [], False
marks = ",".join(["%s"] * len(ids))
edges = execute_query(
f"""WITH normalized AS ({_normalized_case_relation_sql()})
SELECT n.parent_id, n.child_id, n.relationstype,
p.titel AS parent_title, c.titel AS child_title
FROM normalized n
JOIN sag_sager p ON p.id = n.parent_id AND p.deleted_at IS NULL
JOIN sag_sager c ON c.id = n.child_id AND c.deleted_at IS NULL
WHERE n.parent_id IN ({marks}) AND n.child_id IN ({marks})
ORDER BY n.parent_id, n.child_id""",
tuple(ids) + tuple(ids),
) or []
nodes = {row["id"]: {"id": row["id"], "title": row.get("titel") or f"Sag #{row['id']}", "children": []}
for row in relations}
parents: dict[int, list[int]] = {case_id: [] for case_id in ids}
for edge in edges:
parent_id, child_id = edge.get("parent_id"), edge.get("child_id")
if parent_id not in nodes or child_id not in nodes:
continue
parents[child_id].append(parent_id)
nodes[parent_id]["children"].append({
"id": child_id, "title": nodes[child_id]["title"],
"relation_type": edge.get("relationstype") or "",
})
def prune(node_id: int, path: set[int]) -> dict:
node = dict(nodes[node_id])
node["is_current"] = node_id == sag_id
node["relation"] = "current" if node_id == sag_id else ("parent" if sag_id in _descendant_ids(node_id, nodes) else "child")
children = []
for child in node["children"]:
child_id = child["id"]
if child_id in path:
continue
children.append({**prune(child_id, path | {child_id}), "relation_type": child.get("relation_type")})
node["children"] = children
return node
# Render each root so multiple parent branches remain visible. A pure cycle
# has no root; start at the focused case and let the visited path break it.
root_ids = sorted(case_id for case_id in ids if not parents.get(case_id))
if not root_ids:
root_ids = [sag_id]
return [prune(root_id, {root_id}) for root_id in root_ids], False
def _descendant_ids(node_id: int, nodes: dict[int, dict]) -> set[int]:
found: set[int] = set()
stack = list(nodes.get(node_id, {}).get("children", []))
while stack:
item = stack.pop()
child_id = item.get("id")
if child_id in found:
continue
found.add(child_id)
stack.extend(nodes.get(child_id, {}).get("children", []))
return found
def _ancestors_for_case(case_id: int, parents_by_child: dict[int, set[int]]) -> set[int]:
found: set[int] = set()
pending = list(parents_by_child.get(case_id, set()))
while pending:
parent_id = pending.pop()
if parent_id == case_id or parent_id in found:
continue
found.add(parent_id)
pending.extend(parents_by_child.get(parent_id, set()))
return found
@router.get("/sag/{sag_id}/case-tree")
async def get_sag_case_tree(sag_id: int):
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Case not found")
tree, truncated = _load_case_tree(sag_id)
return {"tree": tree, "truncated": truncated, "max_depth": None}
@router.get("/sag/{sag_id}/timeline")
async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
async def get_sag_timeline(
sag_id: int,
include_subcases: bool = Query(False),
whole_tree: bool = Query(False),
relation_types: Optional[str] = Query(None),
event_types: Optional[str] = Query(None),
search: Optional[str] = Query(None, max_length=200),
limit: Optional[int] = Query(None, ge=1, le=200),
cursor: Optional[str] = Query(None, max_length=1000),
):
"""Return a unified timeline for the case with optional child cases."""
try:
check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Case not found")
if include_subcases:
if whole_tree:
tree_roots, tree_truncated = _load_case_tree(sag_id)
case_rows = []
def collect_tree_cases(nodes: list[dict]) -> None:
for node in nodes:
case_rows.append({"id": node["id"], "titel": node.get("title")})
collect_tree_cases(node.get("children") or [])
collect_tree_cases(tree_roots)
elif include_subcases:
case_tree_query = """
WITH RECURSIVE normalized_relations AS (
SELECT
@ -4112,6 +4240,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"""
case_rows = execute_query(case_tree_query, (sag_id,)) or []
else:
tree_truncated = False
case_rows = execute_query(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,)
@ -4331,6 +4460,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
COALESCE(NULLIF(TRIM(recorder.full_name), ''), NULLIF(TRIM(recorder.username), ''),
NULLIF(TRIM(t.user_name), ''), 'System') AS actor_name,
COALESCE(NULLIF(TRIM(employee.full_name), ''), NULLIF(TRIM(employee.username), ''),
NULLIF(TRIM(t.user_name), ''),
CONCAT('Medarbejder #', t.medarbejder_id::text)) AS employee_name,
s.titel AS source_sag_titel
FROM tmodule_times t
@ -4440,12 +4570,64 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
except Exception:
return datetime.min
events.sort(key=_sort_key, reverse=True)
relation_edges = execute_query(
f"""WITH normalized AS ({_normalized_case_relation_sql()})
SELECT parent_id, child_id FROM normalized
WHERE parent_id IN ({placeholders}) OR child_id IN ({placeholders})""",
tuple(case_ids) + tuple(case_ids),
) or []
parents_by_child: dict[int, set[int]] = {}
for edge in relation_edges:
parents_by_child.setdefault(edge["child_id"], set()).add(edge["parent_id"])
current_ancestors = _ancestors_for_case(sag_id, parents_by_child)
for event in events:
event_case_id = event.get("sag_id")
event["relation_type"] = "current" if event_case_id == sag_id else (
"parent" if event_case_id in current_ancestors else "child"
)
events = [event for event in events if event.get("timestamp")]
if relation_types:
selected_relations = {part.strip().lower() for part in relation_types.split(",") if part.strip()}
events = [event for event in events if event.get("relation_type") in selected_relations]
if event_types:
selected_types = {part.strip().lower() for part in event_types.split(",") if part.strip()}
events = [event for event in events if str(event.get("event_type") or "").lower() in selected_types]
if search:
needle = search.casefold()
events = [event for event in events if needle in " ".join(str(event.get(key) or "") for key in
("title", "description", "sag_titel", "forfatter", "source")).casefold()]
events.sort(key=lambda item: (_sort_key(item), str(item.get("id") or "")), reverse=True)
total = len(events)
if limit is not None:
if cursor:
try:
decoded = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)).decode("utf-8")
cursor_ts, cursor_id = decoded.split("|", 1)
cursor_time = _sort_key({"timestamp": cursor_ts})
except Exception:
raise HTTPException(status_code=400, detail="Invalid timeline cursor")
events = [event for event in events if (_sort_key(event), str(event.get("id") or "")) < (cursor_time, cursor_id)]
page = events[:limit]
has_more = len(events) > limit
next_cursor = None
if has_more and page:
last = page[-1]
raw_timestamp = str(last.get("timestamp") or "")
next_cursor = base64.urlsafe_b64encode(f"{raw_timestamp}|{last.get('id') or ''}".encode()).decode().rstrip("=")
events = page
else:
has_more = False
next_cursor = None
return {
"case_tree": case_rows,
"events": events,
"total": len(events),
"total": total,
"next_cursor": next_cursor,
"has_more": has_more,
"truncated": tree_truncated,
}
except HTTPException:
raise

View File

@ -1290,6 +1290,41 @@
margin-top: 0.5rem;
}
.case-relation-explorer { --rt-accent: #126b82; border: 1px solid rgba(18,107,130,.14); border-radius: 20px; overflow: hidden; background: var(--bg-card,#fff); box-shadow: 0 14px 38px rgba(28,61,78,.08); }
.case-relation-hero { display:flex; justify-content:space-between; align-items:center; gap:1rem; padding:1.5rem 1.65rem 1.3rem; color:#fff; background:radial-gradient(circle at 88% 8%,rgba(255,255,255,.2),transparent 24%),linear-gradient(118deg,#123c54,#126b82 58%,#2294a3); }
.case-relation-kicker { font-size:.68rem; letter-spacing:.16em; font-weight:800; opacity:.76; }
.case-relation-hero h2 { margin:.25rem 0; font-size:1.35rem; font-weight:760; }
.case-relation-hero p { margin:0; color:rgba(255,255,255,.78); font-size:.88rem; }
.case-relation-status { font-size:.78rem; color:rgba(255,255,255,.82); text-align:right; }
.case-relation-toolbar { display:flex; align-items:end; gap:.7rem; flex-wrap:wrap; padding:1rem 1.2rem; border-bottom:1px solid var(--border-color,#e4eaed); background:color-mix(in srgb,var(--accent,#126b82) 3%,var(--bg-card,#fff)); }
.case-relation-toolbar>label:not(.case-relation-search) { display:grid; gap:.25rem; min-width:145px; font-size:.7rem; color:var(--text-secondary,#657783); font-weight:700; }
.case-relation-search { position:relative; flex:1 1 280px; }
.case-relation-search i { position:absolute; left:.8rem; top:50%; transform:translateY(-50%); color:#78909a; }
.case-relation-search input { width:100%; min-height:36px; border:1px solid var(--border-color,#dce5e9); border-radius:9px; padding:.45rem .7rem .45rem 2.25rem; background:var(--bg-card,#fff); color:var(--text-primary,#23343d); }
.case-relation-summary { display:flex; justify-content:space-between; gap:.75rem; padding:.65rem 1.25rem; color:var(--text-secondary,#6b7c86); font-size:.74rem; border-bottom:1px solid var(--border-color,#edf0f2); }
.case-relation-tree { padding:1.1rem 1.2rem 1.4rem; overflow:auto; }
.relation-tree-branch { margin-left:1.1rem; padding-left:1.05rem; border-left:1px solid #d7e3e8; }
.relation-tree-node { position:relative; margin:.45rem 0; }
.relation-tree-node:before { content:""; position:absolute; top:1.05rem; left:-1.1rem; width:.8rem; border-top:1px solid #d7e3e8; }
.relation-tree-node-card { border:1px solid #e2eaed; border-radius:13px; background:var(--bg-card,#fff); box-shadow:0 3px 12px rgba(28,61,78,.045); transition:border-color .16s,box-shadow .16s,transform .16s; }
.relation-tree-node-card:hover { border-color:#acd0d8; box-shadow:0 8px 22px rgba(28,61,78,.09); transform:translateY(-1px); }
.relation-tree-node-card.is-current { border-color:#57aab4; background:linear-gradient(110deg,rgba(27,142,154,.075),var(--bg-card,#fff) 60%); box-shadow:0 0 0 2px rgba(27,142,154,.08); }
.relation-tree-node-main { display:flex; align-items:center; gap:.65rem; padding:.75rem .85rem; }
.relation-tree-node-icon { display:grid; place-items:center; width:34px; height:34px; flex:0 0 34px; border-radius:10px; background:#edf6f7; color:#187487; font-size:1rem; }
.relation-tree-node-card.is-current .relation-tree-node-icon { background:#187487; color:#fff; }
.relation-tree-node-info { min-width:0; flex:1; }
.relation-tree-title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:720; color:var(--text-primary,#263c47); text-decoration:none; }
.relation-tree-meta { display:flex; flex-wrap:wrap; gap:.35rem .6rem; margin-top:.18rem; color:var(--text-secondary,#71828b); font-size:.72rem; }
.relation-tree-node-actions { display:flex; align-items:center; gap:.4rem; }
.relation-tree-relation { border-radius:99px; padding:.22rem .55rem; background:#f0f4f6; color:#50636d; font-size:.68rem; font-weight:700; white-space:nowrap; }
.relation-tree-module-panel { padding:.15rem .85rem .85rem 3.45rem; }
.relation-tree-module-content { margin-top:.65rem; max-height:260px; overflow:auto; border:1px solid #e8eef0; border-radius:10px; padding:.65rem; background:color-mix(in srgb,var(--accent,#126b82) 2%,var(--bg-card,#fff)); }
.relation-tree-module-row { padding:.45rem 0; border-bottom:1px solid #e8eef0; font-size:.78rem; white-space:pre-wrap; overflow-wrap:anywhere; }
.relation-tree-module-row:last-child { border-bottom:0; }
[data-bs-theme="dark"] .case-relation-explorer { border-color:#334650; }
[data-bs-theme="dark"] .relation-tree-node-card { border-color:#334650; }
@media(max-width:700px) { .case-relation-hero { align-items:flex-start; flex-direction:column; padding:1.1rem; } .case-relation-status { text-align:left; } .case-relation-toolbar { padding:.8rem; } .case-relation-summary { flex-direction:column; } .relation-tree-node-main { align-items:flex-start; flex-wrap:wrap; } .relation-tree-node-actions { width:100%; justify-content:flex-end; } .relation-tree-node-actions .relation-tree-relation { margin-right:auto; } .relation-tree-module-panel { padding-left:.8rem; } }
.history-timeline-shell {
border: 1px solid rgba(15, 76, 117, 0.18);
border-radius: 12px;
@ -4904,6 +4939,11 @@
<span class="case-tab-count-badge" id="historyTabCountBadge"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="relations-tree-tab" data-bs-target="#relations-tree" type="button" role="tab" onclick="return activateCaseTabFromButton(event, 'relations-tree')">
<i class="bi bi-diagram-3 me-2"></i>Relations-træ
</button>
</li>
</ul>
<div id="timeActiveBanner" class="alert alert-warning d-none justify-content-between align-items-center mb-3" role="alert">
@ -4914,7 +4954,7 @@
<div class="d-flex gap-2">
<button id="timeV1PauseButton" class="btn btn-sm btn-outline-secondary" onclick="pauseLiveTimerV1()"><i class="bi bi-pause-fill me-1"></i>Pause</button>
<button id="timeV1ResumeButton" class="btn btn-sm btn-outline-primary d-none" onclick="resumeCaseTimerV1()"><i class="bi bi-play-fill me-1"></i>Genoptag</button>
<button id="timeV1BannerStopButton" class="btn btn-sm btn-danger" onclick="stopLiveTimerV1({ entry_status: 'afventer' })"><i class="bi bi-stop-fill me-1"></i>Stop</button>
<button id="timeV1BannerStopButton" class="btn btn-sm btn-danger" onclick="stopLiveTimerV1({ entry_status: 'kladde' })"><i class="bi bi-stop-fill me-1"></i>Stop</button>
</div>
</div>
@ -10090,7 +10130,7 @@
<button id="timeV1StartButton" class="btn btn-sm btn-primary" onclick="startLiveTimerV1()">
<i class="bi bi-play-fill me-1"></i>Start timer
</button>
<button id="timeV1StopButton" class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({ entry_status: 'afventer' })" disabled>
<button id="timeV1StopButton" class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({ entry_status: 'kladde' })" disabled>
<i class="bi bi-stop-fill me-1"></i>Stop timer
</button>
</div>
@ -10922,43 +10962,41 @@
</div>
</div>
<div class="tab-pane fade" id="relations-tree" role="tabpanel" tabindex="0">
<section class="case-relation-explorer" aria-labelledby="caseTreeHeading">
<div class="case-relation-hero">
<div><div class="case-relation-kicker">SAGERNES SAMMENHÆNG</div><h2 id="caseTreeHeading">Relations-træ</h2><p>Find forbindelserne, og åbn modulindhold direkte på hver sag.</p></div>
<div class="case-relation-status" id="caseTreeStatus" aria-live="polite">Åbn fanen for at indlæse relationer.</div>
</div>
<div class="case-relation-toolbar" role="search">
<label class="case-relation-search"><i class="bi bi-search" aria-hidden="true"></i><span class="visually-hidden">Søg i relationstræ</span><input id="relationTreeSearch" type="search" placeholder="Søg på sag, titel eller relation…" autocomplete="off"></label>
<label><span>Relation</span><select id="relationTreeType" class="form-select form-select-sm"><option value="">Alle relationer</option></select></label>
<label><span>Placering</span><select id="relationTreePosition" class="form-select form-select-sm"><option value="">Hele træet</option><option value="parent">Overliggende sager</option><option value="current">Aktuel sag</option><option value="child">Undersager</option></select></label>
<label><span>Status</span><select id="relationTreeStatusFilter" class="form-select form-select-sm"><option value="">Alle statusser</option></select></label>
<button type="button" id="relationTreeReset" class="btn btn-sm btn-light border"><i class="bi bi-arrow-counterclockwise me-1"></i>Nulstil</button>
</div>
<div class="case-relation-summary"><span id="relationTreeCount">Sager indlæses…</span><span><i class="bi bi-info-circle me-1"></i>Vælg “Se modulindhold” på en sag for detaljer</span></div>
<div id="caseTreeContent" class="case-relation-tree" aria-live="polite"></div>
</section>
</div>
<div class="tab-pane fade" id="history" role="tabpanel" tabindex="0" data-module="history" data-has-content="true">
<div class="history-timeline-shell">
<div class="history-timeline-toolbar">
<label class="form-check-label small d-inline-flex align-items-center gap-2 me-2">
<input class="form-check-input mt-0" type="checkbox" id="historyIncludeSubcasesToggle">
Inkluder undersager
</label>
<input id="historyTimelineSearch" type="text" class="form-control form-control-sm" placeholder="Søg i historik..." style="max-width: 260px;">
<label class="form-check-label small d-inline-flex align-items-center gap-1">
<input class="form-check-input mt-0" type="checkbox" id="historyFilterComments" checked>
Kommentarer
</label>
<label class="form-check-label small d-inline-flex align-items-center gap-1">
<input class="form-check-input mt-0" type="checkbox" id="historyFilterDescription" checked>
Beskrivelse
</label>
<label class="form-check-label small d-inline-flex align-items-center gap-1">
<input class="form-check-input mt-0" type="checkbox" id="historyFilterReminders" checked>
Påmindelser
</label>
<label class="form-check-label small d-inline-flex align-items-center gap-1">
<input class="form-check-input mt-0" type="checkbox" id="historyFilterTime" checked>
Tidsforbrug
</label>
<label class="form-check-label small d-inline-flex align-items-center gap-1">
<input class="form-check-input mt-0" type="checkbox" id="historyFilterFieldChanges" checked>
Feltændringer
</label>
<label class="form-check-label small d-inline-flex align-items-center gap-1">
<input class="form-check-input mt-0" type="checkbox" id="historyFilterCaseActivity" checked>
Sagsaktivitet
</label>
<label class="small" for="historyRelationFilter">Sag i træet</label>
<select id="historyRelationFilter" class="form-select form-select-sm" aria-label="Filtrér på sagens relation" style="max-width:180px"><option value="">Alle relationer</option><option value="parent">Overliggende sag</option><option value="current">Aktuel sag</option><option value="child">Undersager</option></select>
<label class="small" for="historyEventFilter">Hændelsestype</label>
<select id="historyEventFilter" class="form-select form-select-sm" aria-label="Filtrér på hændelsestype" style="max-width:180px"><option value="">Alle typer</option><option value="comment">Kommentarer</option><option value="field_change">Feltændringer</option><option value="description">Beskrivelse</option><option value="reminder">Påmindelser</option><option value="time">Tidsforbrug</option><option value="case">Sag</option><option value="tag">Tags</option><option value="todo">Opgaver</option><option value="relation">Relationer</option><option value="call">Opkald</option><option value="anydesk">AnyDesk</option></select>
<label class="visually-hidden" for="historyTimelineSearch">Søg i historik</label>
<input id="historyTimelineSearch" type="search" class="form-control form-control-sm" placeholder="Søg i historik..." style="max-width:220px">
<button type="button" class="btn btn-sm btn-outline-secondary" id="historyTimelineApply">Søg</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="historyTimelineReset">Nulstil</button>
<span class="ms-auto badge text-bg-light" id="historyTimelineCount">0</span>
</div>
<div class="history-timeline-list" id="historyTimelineList">
<div class="text-muted text-center py-4">Åbn fanen for at indlæse historik...</div>
</div>
<div class="text-center p-3"><button type="button" class="btn btn-sm btn-outline-primary d-none" id="historyTimelineMore">Vis flere hændelser</button></div>
</div>
</div>
@ -12368,34 +12406,7 @@
const list = document.getElementById('historyTimelineList');
if (!list) return;
const search = String(document.getElementById('historyTimelineSearch')?.value || '').trim().toLowerCase();
const includeComments = document.getElementById('historyFilterComments')?.checked !== false;
const includeDescription = document.getElementById('historyFilterDescription')?.checked !== false;
const includeReminders = document.getElementById('historyFilterReminders')?.checked !== false;
const includeTime = document.getElementById('historyFilterTime')?.checked !== false;
const includeFieldChanges = document.getElementById('historyFilterFieldChanges')?.checked !== false;
const includeCaseActivity = document.getElementById('historyFilterCaseActivity')?.checked !== false;
const filtered = (caseHistoryTimelineCache || []).filter((event) => {
if (event.event_type === 'comment' && !includeComments) return false;
if (event.event_type === 'description' && !includeDescription) return false;
if (event.event_type === 'reminder' && !includeReminders) return false;
if (event.event_type === 'time' && !includeTime) return false;
if (event.event_type === 'field_change' && !includeFieldChanges) return false;
if (['case', 'tag', 'todo', 'relation'].includes(event.event_type) && !includeCaseActivity) return false;
if (!search) return true;
const haystack = [
event.title,
event.description,
event.sag_titel,
event.forfatter,
event.source,
].join(' ').toLowerCase();
return haystack.includes(search);
});
const filtered = caseHistoryTimelineCache || [];
const counter = document.getElementById('historyTimelineCount');
if (counter) counter.textContent = `${filtered.length}`;
@ -12409,6 +12420,7 @@
const icon = _historyIconFor(event);
const ts = _historyFormatTime(event.timestamp);
const caseMeta = event.sag_id ? `Sag #${event.sag_id}${event.sag_titel ? ` · ${_historyEscape(event.sag_titel)}` : ''}` : '';
const relationLabel = ({parent:'Overliggende sag',current:'Aktuel sag',child:'Undersag'})[event.relation_type] || '';
const sourceBadge = event.source ? `<span class="badge text-bg-light border">${_historyEscape(event.source)}</span>` : '';
const isFieldChange = (event?.event_type || '') === 'field_change';
const subtypeBadge = event.event_subtype
@ -12422,6 +12434,7 @@
<i class="bi ${icon}"></i>
<span><strong>Hvornår:</strong> ${ts}</span>
<span><strong>Hvem:</strong> ${_historyEscape(event.forfatter || 'System')}</span>
${relationLabel ? `<span class="badge text-bg-light border">${relationLabel}</span>` : ''}
${sourceBadge}
${subtypeBadge}
${caseMeta ? `<span class="text-muted">${caseMeta}</span>` : ''}
@ -12433,25 +12446,120 @@
}).join('');
}
async function loadCaseHistoryTimeline(forceReload = false) {
const includeSubcases = !!document.getElementById('historyIncludeSubcasesToggle')?.checked;
const loadKey = includeSubcases ? 'with_subcases' : 'case_only';
const list = document.getElementById('historyTimelineList');
if (!list) return;
if (!forceReload && caseHistoryTimelineLoadedKey === loadKey) {
renderCaseHistoryTimeline();
let historyNextCursor = null;
const HISTORY_PAGE_SIZE = 40;
function historyQuery(useCursor = false) {
const params = new URLSearchParams({whole_tree:'true', limit:String(HISTORY_PAGE_SIZE)});
const relation = document.getElementById('historyRelationFilter')?.value;
const eventType = document.getElementById('historyEventFilter')?.value;
const search = String(document.getElementById('historyTimelineSearch')?.value || '').trim();
if (relation) params.set('relation_types', relation);
if (eventType) params.set('event_types', eventType);
if (search) params.set('search', search);
if (useCursor && historyNextCursor) params.set('cursor', historyNextCursor);
return params;
}
function collectRelationTreeNodes(nodes, result = []) {
(nodes || []).forEach(node => { result.push(node); collectRelationTreeNodes(node.children, result); });
return result;
}
function relationTreeNodeMatches(node) {
const search = String(document.getElementById('relationTreeSearch')?.value || '').trim().toLocaleLowerCase('da-DK');
const relation = document.getElementById('relationTreeType')?.value || '';
const position = document.getElementById('relationTreePosition')?.value || '';
const status = document.getElementById('relationTreeStatusFilter')?.value || '';
const relationLabel = String(node.relation_type || (node.is_current ? 'aktuel sag' : '')).toLocaleLowerCase('da-DK');
const positionMatch = !position || node.relation === position;
const relationMatch = !relation || relationLabel === relation.toLocaleLowerCase('da-DK');
const statusMatch = !status || String(node.status || '').toLocaleLowerCase('da-DK') === status.toLocaleLowerCase('da-DK');
const searchMatch = !search || [node.id, node.title, node.status, node.type, node.relation_type].join(' ').toLocaleLowerCase('da-DK').includes(search);
return positionMatch && relationMatch && statusMatch && searchMatch;
}
function renderRelationTreeNode(node) {
const ownMatch = relationTreeNodeMatches(node);
const renderedChildren = (node.children || []).map(renderRelationTreeNode).filter(Boolean);
if (!ownMatch && !renderedChildren.length) return '';
const current = !!node.is_current;
const relationLabel = current ? 'Aktuel sag' : (node.relation_type || (node.relation === 'parent' ? 'Overliggende sag' : 'Undersag'));
const statusLabel = node.status || 'Ukendt status';
const typeLabel = node.type || 'Sag';
const safeId = Number(node.id);
const cardClass = `relation-tree-node-card${current ? ' is-current' : ''}${ownMatch ? '' : ' opacity-50'}`;
return `<div class="relation-tree-node" data-case-id="${safeId}">
<article class="${cardClass}">
<div class="relation-tree-node-main">
<span class="relation-tree-node-icon"><i class="bi ${current?'bi-bullseye':'bi-file-earmark-text'}"></i></span>
<div class="relation-tree-node-info"><a class="relation-tree-title" ${current?'aria-current="page"':''} href="/sag/${encodeURIComponent(safeId)}/v4">#${safeId} · ${_historyEscape(node.title || `Sag #${safeId}`)}</a><div class="relation-tree-meta"><span>${_historyEscape(typeLabel)}</span><span>·</span><span>${_historyEscape(statusLabel)}</span></div></div>
<div class="relation-tree-node-actions"><span class="relation-tree-relation">${_historyEscape(relationLabel)}</span><button class="btn btn-sm btn-outline-primary" type="button" data-relation-modules="${safeId}" aria-expanded="false"><i class="bi bi-layout-text-window-reverse me-1"></i>Se modulindhold</button></div>
</div>
<div class="relation-tree-module-panel d-none" id="relation-modules-${safeId}"><div class="d-flex flex-wrap gap-1" role="group" aria-label="Moduler for sag ${safeId}"><button class="btn btn-sm btn-light border" type="button" data-relation-module="comments" data-case-id="${safeId}">Kommentarer</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="todos" data-case-id="${safeId}">Opgaver</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="files" data-case-id="${safeId}">Filer</button><button class="btn btn-sm btn-light border" type="button" data-relation-module="relations" data-case-id="${safeId}">Relationer</button><a class="btn btn-sm btn-light border" href="/sag/${safeId}/v4"><i class="bi bi-box-arrow-up-right me-1"></i>Alle moduler</a></div><div class="relation-tree-module-content d-none" id="relation-module-content-${safeId}" aria-live="polite"></div></div>
</article>
${renderedChildren.length ? `<div class="relation-tree-branch">${renderedChildren.join('')}</div>` : ''}
</div>`;
}
function renderCaseTreeNodes(nodes) {
const tree = (nodes || []).map(renderRelationTreeNode).filter(Boolean).join('');
const count = collectRelationTreeNodes(nodes).filter(relationTreeNodeMatches).length;
const total = collectRelationTreeNodes(nodes).length;
document.getElementById('relationTreeCount').textContent = count === total ? `${total} sager i relationstræet` : `${count} af ${total} sager matcher filtrene`;
return tree || '<div class="text-center text-muted py-5"><i class="bi bi-search fs-3 d-block mb-2"></i>Ingen sager matcher dine filtre. Prøv at ændre søgningen eller nulstil filtrene.</div>';
}
function renderRelationModuleRows(module, items, caseId) {
if (!Array.isArray(items) || !items.length) return '<div class="text-muted small">Ingen indhold i dette modul.</div>';
const labels = {comments: item => `${item.created_at || ''} · ${item.forfatter || 'Bruger'}\n${item.indhold || ''}`, todos: item => `${item.is_done ? '✓' : '○'} ${item.title || 'Opgave'}${item.description ? `\n${item.description}` : ''}`, files: item => `${item.filename || 'Fil'} · ${item.size_bytes || 0} bytes`, relations: item => `Sag #${item.kilde_sag_id === caseId ? item.målsag_id : item.kilde_sag_id} · ${item.relationstype || 'Relation'}`};
const label = labels[module] || (item => JSON.stringify(item));
return items.map(item => `<div class="relation-tree-module-row">${_historyEscape(label(item))}</div>`).join('');
}
async function loadRelationModule(caseId, module, target) {
const route = {comments:'kommentarer',todos:'todo-steps',files:'files',relations:'relationer'}[module];
target.classList.remove('d-none');
target.innerHTML = '<span class="text-muted small"><span class="spinner-border spinner-border-sm me-1"></span>Henter indhold…</span>';
try {
const response = await fetch(`/api/v1/sag/${caseId}/${route}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const items = await response.json();
target.innerHTML = renderRelationModuleRows(module, items, caseId);
} catch (error) {
target.innerHTML = '<span class="text-danger small">Kunne ikke hente modulindhold.</span>';
}
}
async function loadCaseTree() {
const status = document.getElementById('caseTreeStatus');
const content = document.getElementById('caseTreeContent');
if (!content) return;
if (window.caseRelationTreeLoaded) {
content.innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []);
return;
}
list.innerHTML = '<div class="text-muted text-center py-4"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser historik...</div>';
try {
const res = await fetch(`/api/v1/sag/${caseIds}/timeline?include_subcases=${includeSubcases ? 'true' : 'false'}`);
const res = await fetch(`/api/v1/sag/${caseIds}/case-tree`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
caseHistoryTimelineCache = Array.isArray(payload?.events) ? payload.events : [];
caseHistoryTimelineLoadedKey = loadKey;
window.caseRelationTreeData = payload.tree || [];
const allNodes = collectRelationTreeNodes(window.caseRelationTreeData);
const relationSelect = document.getElementById('relationTreeType');
const statusSelect = document.getElementById('relationTreeStatusFilter');
relationSelect.length = 1;
statusSelect.length = 1;
[...new Set(allNodes.map(node => node.relation_type).filter(Boolean))].sort().forEach(value => relationSelect.add(new Option(value, value)));
[...new Set(allNodes.map(node => node.status).filter(Boolean))].sort().forEach(value => statusSelect.add(new Option(value, value)));
content.innerHTML = renderCaseTreeNodes(window.caseRelationTreeData);
window.caseRelationTreeLoaded = true;
status.textContent = payload.truncated ? 'Træet er afkortet af sikkerhedsgrænsen.' : '';
} catch (error) { status.textContent = 'Kunne ikke indlæse sagstræet.'; content.innerHTML = ''; }
}
async function loadCaseHistoryTimeline(reset = true) {
const list = document.getElementById('historyTimelineList');
if (!list) return;
if (reset) { historyNextCursor = null; caseHistoryTimelineCache = []; list.innerHTML = '<div class="text-muted text-center py-4"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser historik...</div>'; }
else if (!historyNextCursor) return;
try {
const res = await fetch(`/api/v1/sag/${caseIds}/timeline?${historyQuery(!reset)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
caseHistoryTimelineCache = caseHistoryTimelineCache.concat(Array.isArray(payload?.events) ? payload.events : []);
historyNextCursor = payload?.next_cursor || null;
window.initialCaseTabCounts = Object.assign({}, window.initialCaseTabCounts || {}, { history: caseHistoryTimelineCache.length });
if (typeof _setCaseTabCountBadge === 'function') {
@ -12459,6 +12567,7 @@
}
renderCaseHistoryTimeline();
document.getElementById('historyTimelineMore')?.classList.toggle('d-none', !payload.has_more);
} catch (error) {
list.innerHTML = '<div class="text-danger text-center py-4">Kunne ikke indlæse historik.</div>';
console.error('Historik-timeline fejl:', error);
@ -12468,24 +12577,44 @@
document.addEventListener('DOMContentLoaded', () => {
const historyTab = document.getElementById('history-tab');
if (historyTab) {
historyTab.addEventListener('shown.bs.tab', () => loadCaseHistoryTimeline(false));
historyTab.addEventListener('shown.bs.tab', () => loadCaseHistoryTimeline(true));
}
loadCaseHistoryTimeline(false);
loadCaseHistoryTimeline(true);
const includeToggle = document.getElementById('historyIncludeSubcasesToggle');
if (includeToggle) {
includeToggle.addEventListener('change', () => loadCaseHistoryTimeline(true));
}
const searchInput = document.getElementById('historyTimelineSearch');
if (searchInput) {
searchInput.addEventListener('input', renderCaseHistoryTimeline);
}
['historyFilterComments', 'historyFilterDescription', 'historyFilterReminders', 'historyFilterTime', 'historyFilterFieldChanges', 'historyFilterCaseActivity'].forEach((id) => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', renderCaseHistoryTimeline);
document.getElementById('historyTimelineApply')?.addEventListener('click', () => loadCaseHistoryTimeline(true));
document.getElementById('historyTimelineReset')?.addEventListener('click', () => {
document.getElementById('historyRelationFilter').value = '';
document.getElementById('historyEventFilter').value = '';
document.getElementById('historyTimelineSearch').value = '';
loadCaseHistoryTimeline(true);
});
['historyRelationFilter','historyEventFilter'].forEach(id => document.getElementById(id)?.addEventListener('change', () => loadCaseHistoryTimeline(true)));
document.getElementById('historyTimelineSearch')?.addEventListener('keydown', event => { if(event.key === 'Enter') loadCaseHistoryTimeline(true); });
document.getElementById('relations-tree-tab')?.addEventListener('shown.bs.tab', loadCaseTree);
['relationTreeSearch','relationTreeType','relationTreePosition','relationTreeStatusFilter'].forEach(id => document.getElementById(id)?.addEventListener(id === 'relationTreeSearch' ? 'input' : 'change', () => {
const target = document.getElementById('caseTreeContent');
target.innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []);
}));
document.getElementById('relationTreeReset')?.addEventListener('click', () => {
['relationTreeSearch','relationTreeType','relationTreePosition','relationTreeStatusFilter'].forEach(id => document.getElementById(id).value = '');
document.getElementById('caseTreeContent').innerHTML = renderCaseTreeNodes(window.caseRelationTreeData || []);
});
document.getElementById('caseTreeContent')?.addEventListener('click', event => {
const moduleToggle = event.target.closest('[data-relation-modules]');
if (moduleToggle) {
const caseId = Number(moduleToggle.dataset.relationModules);
const panel = document.getElementById(`relation-modules-${caseId}`);
const opening = panel.classList.contains('d-none');
panel.classList.toggle('d-none', !opening);
moduleToggle.setAttribute('aria-expanded', String(opening));
return;
}
const moduleButton = event.target.closest('[data-relation-module]');
if (!moduleButton) return;
const caseId = Number(moduleButton.dataset.caseId);
const target = document.getElementById(`relation-module-content-${caseId}`);
loadRelationModule(caseId, moduleButton.dataset.relationModule, target);
});
});
</script>
@ -13385,7 +13514,7 @@
let actions = '';
if (own && running) {
actions = `<button class="btn btn-sm btn-outline-warning" onclick="pauseLiveTimerV1()"><i class="bi bi-pause-fill me-1"></i>Pause</button>
<button class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({time_id:${id}, entry_status:'afventer'})"><i class="bi bi-stop-fill me-1"></i>Stop</button>`;
<button class="btn btn-sm btn-outline-danger" onclick="stopLiveTimerV1({time_id:${id}, entry_status:'kladde'})"><i class="bi bi-stop-fill me-1"></i>Stop</button>`;
} else if (own && paused) {
actions = `<button class="btn btn-sm btn-primary" onclick="resumeCaseTimerByIdV1(${id})"><i class="bi bi-play-fill me-1"></i>Genoptag</button>`;
} else if (own) {

View File

@ -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)
@ -606,6 +658,49 @@ class VTigerService:
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=<JSON string>. 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]:
"""
Fetch service contracts from vTiger

View File

@ -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":

File diff suppressed because it is too large Load Diff

View File

@ -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.';

View File

@ -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'
));

View File

@ -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);

View File

@ -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);

View File

@ -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,
}]

View File

@ -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 = "<p>Hej &amp; velkommen</p><ul><li>Første</li><li>Anden</li></ul>"
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"

View File

@ -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"]}

View File

@ -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(