import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common"; import { Prisma, Shifts } from "@prisma/client"; import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types"; import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; import { weekStartSunday, formatHHmm } from "./shifts-date-time-helpers"; import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; import { OvertimeService } from "src/modules/business-logics/services/overtime.service"; import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; export type Tx = Prisma.TransactionClient; export type Normalized = Awaited>; export class ShiftsHelpersService { constructor( private readonly bankTypeResolver: BankCodesResolver, private readonly overtimeService: OvertimeService, ) { } async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) { const start_of_week = weekStartSunday(date_only); return tx.timesheets.upsert({ where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, update: {}, create: { employee_id, start_date: start_of_week }, select: { id: true }, }); } async normalizeRequired( raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null, label: 'old_shift' | 'new_shift' = 'new_shift', ): Promise { if (!raw) throw new BadRequestException(`${label} is required`); const norm = await normalizeShiftPayload(raw); if (norm.end_time.getTime() <= norm.start_time.getTime()) { throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`); } return norm; } async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise { const found = await this.bankTypeResolver.findByType(type, tx); const id = found?.id; if (typeof id !== 'number') { throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`); } return id; } async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) { return tx.shifts.findMany({ where: { timesheet_id, date: date_only }, include: { bank_code: true }, orderBy: { start_time: 'asc' }, }); } async assertNoOverlap( day_shifts: Array, new_norm: Normalized | undefined, exclude_id?: number, ) { if (!new_norm) return; const conflicts = day_shifts.filter((s) => { if (exclude_id && s.id === exclude_id) return false; return overlaps( new_norm.start_time.getTime(), new_norm.end_time.getTime(), s.start_time.getTime(), s.end_time.getTime(), ); }); if (conflicts.length) { const payload = conflicts.map((s) => ({ start_time: formatHHmm(s.start_time), end_time: formatHHmm(s.end_time), type: s.bank_code?.type ?? 'UNKNOWN', })); throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts: payload, }); } } async findExactOldShift( tx: Tx, params: { timesheet_id: number; date_only: Date; norm: Normalized; bank_code_id: number; comment?: string; }, ) { const { timesheet_id, date_only, norm, bank_code_id } = params; return tx.shifts.findFirst({ where: { timesheet_id, date: date_only, start_time: norm.start_time, end_time: norm.end_time, is_remote: norm.is_remote, is_approved: norm.is_approved, comment: norm.comment ?? null, bank_code_id, }, select: { id: true }, }); } async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date, action: UpsertAction) { // Switch regular → weekly overtime si > 40h await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx); const [daily, weekly] = await Promise.all([ this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only), this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only), ]); } async mapDay( fresh: Array, ): Promise { return fresh.map((s) => ({ start_time: formatHHmm(s.start_time), end_time: formatHHmm(s.end_time), type: s.bank_code?.type ?? 'UNKNOWN', is_remote: s.is_remote, comment: s.comment ?? null, })); } }