346 lines
17 KiB
TypeScript
346 lines
17 KiB
TypeScript
import { toDateFromString, toHHmmFromString, toStringFromDate, toStringFromHHmm } from "../../../../utils/date-time-helpers";
|
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
|
import { OvertimeService, WeekOvertimeSummary } from "src/time-and-attendance/domains/services/overtime.service";
|
|
import { updateShiftDto } from "../dtos/shift-update.dto";
|
|
import { PrismaService } from "src/prisma/prisma.service";
|
|
import { GetShiftDto } from "../dtos/shift-get.dto";
|
|
import { ShiftDto } from "../dtos/shift-create.dto";
|
|
import { shift_select } from "src/time-and-attendance/utils/selects.utils";
|
|
|
|
type Normalized = { date: Date; start_time: Date; end_time: Date; };
|
|
|
|
export type ShiftWithOvertimeDto = {
|
|
shift: GetShiftDto;
|
|
overtime: WeekOvertimeSummary;
|
|
};
|
|
|
|
export type CreateResult = { ok: true; data: ShiftWithOvertimeDto } | { ok: false; error: any };
|
|
export type UpdatePayload = { id: number; dto: updateShiftDto };
|
|
export type UpdateResult = { ok: true; id: number; data: ShiftWithOvertimeDto } | { ok: false; id: number; error: any };
|
|
export type DeleteResult = { ok: true; id: number; overtime: WeekOvertimeSummary } | { ok: false; id: number; error: any };
|
|
|
|
type NormedOk = { index: number; dto: ShiftDto; normed: Normalized };
|
|
type NormedErr = { index: number; error: any };
|
|
|
|
const overlaps = (a: { start: Date; end: Date }, b: { start: Date; end: Date }) =>
|
|
!(a.end <= b.start || a.start >= b.end);
|
|
|
|
@Injectable()
|
|
export class ShiftsUpsertService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly overtime: OvertimeService,
|
|
) { }
|
|
|
|
//_________________________________________________________________
|
|
// CREATE
|
|
//_________________________________________________________________
|
|
//normalized frontend data to match DB
|
|
//loads all shifts from a selected day to check for overlaping shifts
|
|
//checks for overlaping shifts
|
|
//create new shifts
|
|
//calculate overtime
|
|
async createShifts(timesheet_id: number, dtos: ShiftDto[]): Promise<CreateResult[]> {
|
|
if (!Array.isArray(dtos) || dtos.length === 0) return [];
|
|
|
|
const normed_shift: Array<NormedOk | NormedErr> = dtos.map((dto, index) => {
|
|
try {
|
|
const normed = this.normalizeShiftDto(dto);
|
|
if (normed.end_time <= normed.start_time) {
|
|
return { index, error: new BadRequestException(`end_time must be greater than start_time (index ${index})`) };
|
|
}
|
|
return { index, dto, normed };
|
|
} catch (error) {
|
|
return { index, error };
|
|
}
|
|
});
|
|
const ok_items = normed_shift.filter((x): x is NormedOk => "normed" in x);
|
|
|
|
const regroup_by_date = new Map<number, number[]>();
|
|
|
|
ok_items.forEach(({ index, normed }) => {
|
|
const d = normed.date;
|
|
const key = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
if (!regroup_by_date.has(key)) regroup_by_date.set(key, []);
|
|
regroup_by_date.get(key)!.push(index);
|
|
});
|
|
|
|
for (const indices of regroup_by_date.values()) {
|
|
const ordered = indices
|
|
.map(index => {
|
|
const item = normed_shift[index] as NormedOk;
|
|
return { index: index, start: item.normed.start_time, end: item.normed.end_time };
|
|
})
|
|
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
|
|
for (let j = 1; j < ordered.length; j++) {
|
|
if (overlaps({ start: ordered[j - 1].start, end: ordered[j - 1].end }, { start: ordered[j].start, end: ordered[j].end })) {
|
|
const err = new ConflictException({
|
|
error_code: 'SHIFT_OVERLAP_BATCH',
|
|
message: 'New shift overlaps with another shift in the same batch (same day).',
|
|
});
|
|
return dtos.map((_dto, key) =>
|
|
indices.includes(key)
|
|
? ({ ok: false, error: err } as CreateResult)
|
|
: ({ ok: false, error: new BadRequestException('Batch aborted due to overlaps in another date group') })
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const results: CreateResult[] = Array.from({ length: dtos.length }, () => ({ ok: false, error: new Error('uninitialized') }));
|
|
|
|
|
|
normed_shift.forEach((x, i) => {
|
|
if ("error" in x) results[i] = { ok: false, error: x.error };
|
|
});
|
|
|
|
const unique_dates = Array.from(regroup_by_date.keys()).map(ms => new Date(ms));
|
|
const existing_date = new Map<number, { start_time: Date; end_time: Date }[]>();
|
|
for (const d of unique_dates) {
|
|
const rows = await tx.shifts.findMany({
|
|
where: { timesheet_id, date: d },
|
|
select: { start_time: true, end_time: true },
|
|
});
|
|
existing_date.set(d.getTime(), rows.map(r => ({ start_time: r.start_time, end_time: r.end_time })));
|
|
}
|
|
|
|
for (const item of ok_items) {
|
|
const { index, dto, normed } = item;
|
|
const dayKey = new Date(normed.date.getFullYear(), normed.date.getMonth(), normed.date.getDate()).getTime();
|
|
const existing = existing_date.get(dayKey) ?? [];
|
|
|
|
const hit = existing.find(e => overlaps({ start: e.start_time, end: e.end_time }, { start: normed.start_time, end: normed.end_time }));
|
|
if (hit) {
|
|
results[index] = {
|
|
ok: false,
|
|
error: new ConflictException({
|
|
error_code: 'SHIFT_OVERLAP',
|
|
message: 'New shift overlaps with existing shift(s)',
|
|
conflicts: [{
|
|
start_time: toStringFromHHmm(hit.start_time),
|
|
end_time: toStringFromHHmm(hit.end_time),
|
|
type: 'UNKNOWN',
|
|
}],
|
|
}),
|
|
};
|
|
continue;
|
|
}
|
|
|
|
const row = await tx.shifts.create({
|
|
data: {
|
|
timesheet_id,
|
|
bank_code_id: dto.bank_code_id,
|
|
date: normed.date,
|
|
start_time: normed.start_time,
|
|
end_time: normed.end_time,
|
|
is_remote: dto.is_remote,
|
|
comment: dto.comment ?? undefined,
|
|
},
|
|
select: shift_select,
|
|
});
|
|
|
|
existing.push({ start_time: row.start_time, end_time: row.end_time });
|
|
|
|
const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id, normed.date, tx);
|
|
const shift: GetShiftDto = {
|
|
timesheet_id: row.timesheet_id,
|
|
bank_code_id: row.bank_code_id,
|
|
date: toStringFromDate(row.date),
|
|
start_time: toStringFromHHmm(row.start_time),
|
|
end_time: toStringFromHHmm(row.end_time),
|
|
is_remote: row.is_remote,
|
|
is_approved: false,
|
|
comment: row.comment ?? undefined,
|
|
};
|
|
results[index] = { ok: true, data: { shift, overtime: summary } };
|
|
}
|
|
|
|
return results;
|
|
});
|
|
}
|
|
|
|
//_________________________________________________________________
|
|
// UPDATE
|
|
//_________________________________________________________________
|
|
// finds existing shifts in DB
|
|
// verify if shifts are already approved
|
|
// normalized Date and Time format to string
|
|
// check for valid start and end times
|
|
// check for overlaping possibility
|
|
// buil a set of data to manipulate modified data only
|
|
// update shifts in DB
|
|
// recalculate overtime after update
|
|
// return an updated version to display
|
|
async updateShifts(updates: UpdatePayload[]): Promise<UpdateResult[]> {
|
|
if (!Array.isArray(updates) || updates.length === 0) return [];
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const shift_ids = updates.map(update_shift => update_shift.id);
|
|
const rows = await tx.shifts.findMany({
|
|
where: { id: { in: shift_ids } },
|
|
select: shift_select,
|
|
});
|
|
const regroup_id = new Map(rows.map(r => [r.id, r]));
|
|
|
|
for (const update of updates) {
|
|
const existing = regroup_id.get(update.id);
|
|
if (!existing) {
|
|
return updates.map(exist => exist.id === update.id
|
|
? ({ ok: false, id: update.id, error: new NotFoundException(`Shift with id: ${update.id} not found`) } as UpdateResult)
|
|
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to missing shift') }));
|
|
}
|
|
if (existing.is_approved) {
|
|
return updates.map(exist => exist.id === update.id
|
|
? ({ ok: false, id: update.id, error: new BadRequestException('Approved shift cannot be updated') } as UpdateResult)
|
|
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to approved shift in update set') }));
|
|
}
|
|
}
|
|
|
|
const planned_updates = updates.map(update => {
|
|
const exist_shift = regroup_id.get(update.id)!;
|
|
const date_string = update.dto.date ?? toStringFromDate(exist_shift.date);
|
|
const start_string = update.dto.start_time ?? toStringFromHHmm(exist_shift.start_time);
|
|
const end_string = update.dto.end_time ?? toStringFromHHmm(exist_shift.end_time);
|
|
const normed: Normalized = {
|
|
date: toDateFromString(date_string),
|
|
start_time: toHHmmFromString(start_string),
|
|
end_time: toHHmmFromString(end_string),
|
|
};
|
|
return { update, exist_shift, normed };
|
|
});
|
|
|
|
const groups = new Map<string, { existing: { start: Date; end: Date; id: number }[], incoming: typeof planned_updates }>();
|
|
function key(timesheet: number, d: Date) {
|
|
const day_date = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
return `${timesheet}|${day_date.getTime()}`;
|
|
}
|
|
|
|
const unique_pairs = new Map<string, { timesheet_id: number; date: Date }>();
|
|
for (const { exist_shift, normed } of planned_updates) {
|
|
unique_pairs.set(key(exist_shift.timesheet_id, exist_shift.date), { timesheet_id: exist_shift.timesheet_id, date: exist_shift.date });
|
|
unique_pairs.set(key(exist_shift.timesheet_id, normed.date), { timesheet_id: exist_shift.timesheet_id, date: normed.date });
|
|
}
|
|
|
|
for (const group of unique_pairs.values()) {
|
|
const day_date = new Date(group.date.getFullYear(), group.date.getMonth(), group.date.getDate());
|
|
const existing = await tx.shifts.findMany({
|
|
where: { timesheet_id: group.timesheet_id, date: day_date },
|
|
select: { id: true, start_time: true, end_time: true },
|
|
});
|
|
groups.set(key(group.timesheet_id, day_date), { existing: existing.map(row => ({ id: row.id, start: row.start_time, end: row.end_time })), incoming: planned_updates });
|
|
}
|
|
|
|
for (const planned of planned_updates) {
|
|
const keys = key(planned.exist_shift.timesheet_id, planned.normed.date);
|
|
const group = groups.get(keys)!;
|
|
|
|
const conflict = group.existing.find(row =>
|
|
row.id !== planned.exist_shift.id && overlaps({ start: row.start, end: row.end }, { start: planned.normed.start_time, end: planned.normed.end_time })
|
|
);
|
|
if (conflict) {
|
|
return updates.map(exist =>
|
|
exist.id === planned.exist_shift.id
|
|
? ({
|
|
ok: false, id: exist.id, error: new ConflictException({
|
|
error_code: 'SHIFT_OVERLAP',
|
|
message: 'New shift overlaps with existing shift(s)',
|
|
conflicts: [{ start_time: toStringFromHHmm(conflict.start), end_time: toStringFromHHmm(conflict.end), type: 'UNKNOWN' }],
|
|
})
|
|
} as UpdateResult)
|
|
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to overlap in another update') })
|
|
);
|
|
}
|
|
}
|
|
|
|
const regoup_by_day = new Map<string, { id: number; start: Date; end: Date }[]>();
|
|
for (const planned of planned_updates) {
|
|
const keys = key(planned.exist_shift.timesheet_id, planned.normed.date);
|
|
if (!regoup_by_day.has(keys)) regoup_by_day.set(keys, []);
|
|
regoup_by_day.get(keys)!.push({ id: planned.exist_shift.id, start: planned.normed.start_time, end: planned.normed.end_time });
|
|
}
|
|
for (const arr of regoup_by_day.values()) {
|
|
arr.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
for (let i = 1; i < arr.length; i++) {
|
|
if (overlaps({ start: arr[i - 1].start, end: arr[i - 1].end }, { start: arr[i].start, end: arr[i].end })) {
|
|
const error = new ConflictException({ error_code: 'SHIFT_OVERLAP_BATCH', message: 'Overlaps between updates within the same day.' });
|
|
return updates.map(exist => ({ ok: false, id: exist.id, error: error }));
|
|
}
|
|
}
|
|
}
|
|
|
|
const results: UpdateResult[] = [];
|
|
for (const planned of planned_updates) {
|
|
const data: any = {};
|
|
const { dto } = planned.update;
|
|
if (dto.date !== undefined) data.date = planned.normed.date;
|
|
if (dto.start_time !== undefined) data.start_time = planned.normed.start_time;
|
|
if (dto.end_time !== undefined) data.end_time = planned.normed.end_time;
|
|
if (dto.bank_code_id !== undefined) data.bank_code_id = dto.bank_code_id;
|
|
if (dto.is_remote !== undefined) data.is_remote = dto.is_remote;
|
|
if (dto.comment !== undefined) data.comment = dto.comment ?? null;
|
|
|
|
const row = await tx.shifts.update({
|
|
where: { id: planned.exist_shift.id },
|
|
data,
|
|
select: shift_select,
|
|
});
|
|
|
|
const summary_new = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, planned.exist_shift.date, tx);
|
|
if (row.date.getTime() !== planned.exist_shift.date.getTime()) {
|
|
await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx);
|
|
}
|
|
|
|
const shift: GetShiftDto = {
|
|
timesheet_id: row.timesheet_id,
|
|
bank_code_id: row.bank_code_id,
|
|
date: toStringFromDate(row.date),
|
|
start_time: toStringFromHHmm(row.start_time),
|
|
end_time: toStringFromHHmm(row.end_time),
|
|
is_approved: row.is_approved,
|
|
is_remote: row.is_remote,
|
|
comment: row.comment ?? undefined,
|
|
};
|
|
|
|
results.push({ ok: true, id: planned.exist_shift.id, data: { shift, overtime: summary_new } });
|
|
}
|
|
return results;
|
|
});
|
|
|
|
}
|
|
|
|
//_________________________________________________________________
|
|
// DELETE
|
|
//_________________________________________________________________
|
|
//finds shifts using shit_ids
|
|
//recalc overtime shifts after delete
|
|
//blocs deletion if approved
|
|
async deleteShift(shift_id: number) {
|
|
return await this.prisma.$transaction(async (tx) => {
|
|
const shift = await tx.shifts.findUnique({
|
|
where: { id: shift_id },
|
|
select: { id: true, date: true, timesheet_id: true },
|
|
});
|
|
if (!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`);
|
|
|
|
await tx.shifts.delete({ where: { id: shift_id } });
|
|
|
|
const summary = await this.overtime.getWeekOvertimeSummary(shift.timesheet_id, shift.date, tx);
|
|
return {
|
|
success: true,
|
|
overtime: summary
|
|
};
|
|
});
|
|
}
|
|
|
|
//_________________________________________________________________
|
|
// LOCAL HELPERS
|
|
//_________________________________________________________________
|
|
//converts all string hours and date to Date and HHmm formats
|
|
private normalizeShiftDto = (dto: ShiftDto): Normalized => {
|
|
const date = toDateFromString(dto.date);
|
|
const start_time = toHHmmFromString(dto.start_time);
|
|
const end_time = toHHmmFromString(dto.end_time);
|
|
return { date, start_time, end_time };
|
|
}
|
|
} |