147 lines
8.4 KiB
TypeScript
147 lines
8.4 KiB
TypeScript
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<string, string>();
|
|
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 };
|
|
});
|
|
}
|
|
}
|