krona-staff/src/domain/attendance/work-session.ts
2026-09-24 17:00:08 +07:00

113 lines
3.5 KiB
TypeScript

export type AttendanceEventType = 'check_in' | 'check_out';
export interface AttendanceEventInput {
id: string;
type: AttendanceEventType;
occurredAt: string;
}
export type WorkSessionAnomaly =
| 'auto_closed'
| 'missing_check_in'
| 'missing_check_out'
| 'repeated_check_in';
export interface ReconcileWorkSessionOptions {
now: string;
/** A still-open shift becomes anomalous after this many minutes. */
openShiftThresholdMinutes: number;
/** Optional scheduler-provided cutoff. It is applied only after the cutoff has actually passed. */
automaticCloseAt?: string;
}
export interface WorkSessionCandidate {
startedAt: string | null;
endedAt: string | null;
workedMinutes: number;
status: 'open' | 'pending_review';
anomalies: WorkSessionAnomaly[];
attendanceEventIds: string[];
}
interface ParsedEvent extends AttendanceEventInput {
timestamp: number;
}
export function reconcileWorkSession(
events: readonly AttendanceEventInput[],
options: ReconcileWorkSessionOptions,
): WorkSessionCandidate | null {
if (!Number.isInteger(options.openShiftThresholdMinutes) || options.openShiftThresholdMinutes < 1) {
throw new Error('openShiftThresholdMinutes must be a positive integer');
}
if (events.length === 0) return null;
const now = parseInstant(options.now, 'now');
const automaticCloseAt = options.automaticCloseAt
? parseInstant(options.automaticCloseAt, 'automaticCloseAt')
: undefined;
const parsed = events.map(parseEvent).sort((left, right) =>
left.timestamp - right.timestamp || left.id.localeCompare(right.id));
const anomalies = new Set<WorkSessionAnomaly>();
const attendanceEventIds: string[] = [];
let openAt: number | null = null;
let firstStart: number | null = null;
let lastEnd: number | null = null;
let workedMilliseconds = 0;
for (const event of parsed) {
attendanceEventIds.push(event.id);
if (event.type === 'check_in') {
if (openAt !== null) {
anomalies.add('repeated_check_in');
continue;
}
openAt = event.timestamp;
firstStart ??= event.timestamp;
continue;
}
if (openAt === null) {
anomalies.add('missing_check_in');
lastEnd = Math.max(lastEnd ?? event.timestamp, event.timestamp);
continue;
}
workedMilliseconds += Math.max(0, event.timestamp - openAt);
lastEnd = event.timestamp;
openAt = null;
}
if (openAt !== null) {
const canAutoClose = automaticCloseAt !== undefined
&& automaticCloseAt > openAt
&& automaticCloseAt <= now;
if (canAutoClose) {
workedMilliseconds += automaticCloseAt - openAt;
lastEnd = automaticCloseAt;
openAt = null;
anomalies.add('auto_closed');
} else if (now - openAt >= options.openShiftThresholdMinutes * 60_000) {
anomalies.add('missing_check_out');
}
}
return {
startedAt: firstStart === null ? null : new Date(firstStart).toISOString(),
endedAt: openAt === null && lastEnd !== null ? new Date(lastEnd).toISOString() : null,
workedMinutes: Math.floor(workedMilliseconds / 60_000),
status: openAt !== null && anomalies.size === 0 ? 'open' : 'pending_review',
anomalies: [...anomalies].sort(),
attendanceEventIds,
};
}
function parseEvent(event: AttendanceEventInput): ParsedEvent {
return { ...event, timestamp: parseInstant(event.occurredAt, `event ${event.id}`) };
}
function parseInstant(value: string, label: string): number {
const timestamp = new Date(value).valueOf();
if (!Number.isFinite(timestamp)) throw new Error(`${label} must be an ISO date-time`);
return timestamp;
}