import { sql } from 'drizzle-orm'; import { Actor, authorize, digest, rows, TimeService } from './service'; import { fail, normalizeSpan, overlaps, Span } from './domain'; type Candidate = { span: Span; origins: { kind: string; id: string; }[]; }; /** Conservative replay: never invent missing ends or silently discard ambiguous points. */ export function previewLegacy(events: any[], entries: any[], connects: any[]) { const issues: string[] = [], candidates: Candidate[] = []; const invalidated = new Set(events.map(e => e.invalidates_event_id).filter(Boolean)); const valid = events.filter(e => !invalidated.has(e.id) && e.eventtype !== 'invalidated'); const states = new Map(); for (const e of [...valid].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id))) { if (['submitted', 'approved', 'rejected'].includes(e.eventtype) && e.related_event_id) states.set(e.related_event_id, e.eventtype); } const points = valid.filter(e => !['submitted', 'approved', 'rejected'].includes(e.eventtype)) .sort((a, b) => Date.parse(a.eventtime) - Date.parse(b.eventtime) || String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id)); let current: any = null, origins: Candidate['origins'] = []; const close = () => { if (!current) return; const statusSet = new Set(origins.map(o => states.get(o.id) || 'factual')); if (statusSet.size > 1) issues.push(`Uneinheitliche Freigaben ab ${current.started_at}; vor Übernahme vereinheitlichen.`); current.status = statusSet.size === 1 ? [...statusSet][0] : 'factual'; if (!current.ended_at) issues.push(`Offener Alt-Eintrag ab ${current.started_at}; zuerst abschließen.`); try { const normalized = normalizeSpan(current); normalized.status = current.status; candidates.push({ span: normalized, origins: [...origins] }); } catch (e: any) { issues.push(`${current.started_at}: ${e.message}`); } current = null; origins = []; }; for (const e of points) { const type = e.eventtype === 'START' ? 'work_start' : e.eventtype === 'STOP' || e.eventtype === 'auto_stop' ? 'work_end' : e.eventtype; const time = new Date(e.eventtime).toISOString(); if (type === 'pause_start' || type === 'pause_end') { if (!current || current.type !== 'work') { issues.push(`Pause ohne Anwesenheit: ${e.id}`); continue; } const open = current.breaks.find((p: any) => !p.ended_at); if (type === 'pause_start' && !open) current.breaks.push({ started_at: time, ended_at: null }); else if (type === 'pause_end' && open) open.ended_at = time; else issues.push(`Widersprüchliche Pause: ${e.id}`); origins.push({ kind: 'event', id: e.id }); continue; } if (type.endsWith('_start')) { if (current) { issues.push(`Neuer Beginn vor vorherigem Ende: ${e.id}`); close(); } current = { type: type.slice(0, -6), started_at: time, ended_at: null, description: e.metadata?.description || e.payload?.description || '', breaks: [], projects: [], timezone: 'Europe/Berlin' }; origins = [{ kind: 'event', id: e.id }]; if (e.metadata?.project) current.projects.push({ project_id: Number(e.metadata.project), started_at: time, ended_at: null, notes: current.description }); } else if (type.endsWith('_end')) { if (!current || current.type !== type.slice(0, -4)) { issues.push(`Ende ohne passenden Beginn: ${e.id}`); continue; } current.ended_at = time; for (const p of current.breaks) if (!p.ended_at) p.ended_at = time; for (const p of current.projects) p.ended_at = time; origins.push({ kind: 'event', id: e.id }); close(); } else issues.push(`Unbekanntes Ereignis ${e.eventtype}: ${e.id}`); } close(); for (const e of entries.filter(e => !e.archived)) { try { const span = normalizeSpan({ ...e, ended_at: e.stopped_at, timezone: 'Europe/Berlin', breaks: [], projects: connects.filter(c => c.time_entry_id === e.id).map(c => ({ ...c, ended_at: c.stopped_at })) }); span.status = e.state === 'draft' ? 'factual' : e.state; if (!span.ended_at) issues.push(`Offener Alt-Eintrag ${e.id}; zuerst abschließen.`); candidates.push({ span, origins: [{ kind: 'entry', id: e.id }] }); } catch (err: any) { issues.push(`Alt-Eintrag ${e.id}: ${err.message}`); } } candidates.sort((a, b) => Date.parse(a.span.started_at) - Date.parse(b.span.started_at)); for (let i = 1; i < candidates.length; i++) if (overlaps(candidates[i - 1].span, candidates[i].span)) issues.push(`Überlappende Alt-Einträge ab ${candidates[i].span.started_at}.`); const totalMinutes = candidates.reduce((n, { span: s }) => n + (s.type === 'work' && s.ended_at ? (Date.parse(s.ended_at) - Date.parse(s.started_at) - s.breaks.reduce((v, p) => v + (p.ended_at ? Date.parse(p.ended_at) - Date.parse(p.started_at) : 0), 0)) / 60000 : 0), 0); return { candidates, issues, totalMinutes, count: candidates.length, fingerprint: digest({ events, entries, connects }) }; } export class TimeMigration { constructor(public service: TimeService) { } async preview(a: Actor, user: string, db = this.service.db) { authorize(a, user, 'activate'); const events = await rows(db, sql `SELECT * FROM staff_time_events WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid ORDER BY event_time,id`); // Raw SQL column names differ from the historical Drizzle property names. const mapped = events.map(e => ({ ...e, eventtype: e.event_type, eventtime: e.event_time })); const entries = await rows(db, sql `SELECT * FROM staff_time_entries WHERE tenant_id=${a.tenant} AND user_id=${user}::uuid ORDER BY started_at,id`); const connects = await rows(db, sql `SELECT c.* FROM staff_time_entry_connects c JOIN staff_time_entries e ON e.id=c.time_entry_id WHERE e.tenant_id=${a.tenant} AND e.user_id=${user}::uuid ORDER BY c.started_at,c.id`); const result = previewLegacy(mapped, entries, connects); for (const { span } of result.candidates) for (const project of span.projects) { if (!(await rows(db, sql `SELECT id FROM projects WHERE id=${project.project_id} AND tenant=${a.tenant}`)).length) result.issues.push(`Projekt ${project.project_id} fehlt im Mandanten.`); } return { ...result, active: await this.service.active(a, user, db) }; } async activate(a: Actor, user: string, fingerprint: string) { authorize(a, user, 'activate'); return this.service.db.transaction(async (tx: any) => { await this.service.lock(tx, a, user); if (await this.service.active(a, user, tx)) return { active: true, alreadyActivated: true }; const preview = await this.preview(a, user, tx); if (preview.fingerprint !== fingerprint) fail('Altdaten wurden geändert. Vorschau erneut laden.', 409); if (preview.issues.length) fail('Altdaten enthalten Konflikte. Vor Aktivierung korrigieren.', 409); for (const c of preview.candidates) { const created = await this.service.save(tx, a, user, c.span, null, 'migration', 'Übernahme aus bisheriger Zeiterfassung', 'migration'); for (const origin of c.origins) await tx.execute(sql `INSERT INTO staff_time_span_origins(tenant_id,source_kind,source_id,span_id) VALUES(${a.tenant},${origin.kind},${origin.id}::uuid,${created.id}::uuid)`); } await tx.execute(sql `INSERT INTO staff_time_v2_accounts(tenant_id,user_id,activated_by) VALUES(${a.tenant},${user}::uuid,${a.user}::uuid)`); return { active: true, count: preview.count, totalMinutes: preview.totalMinutes }; }); } }