feat(plan): bulk entry creation endpoint + service
This commit is contained in:
@@ -761,6 +761,150 @@ export async function deleteOverride(
|
||||
return { data: { ok: true }, oldData: existing, description };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bulk entry creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Bulk-assign a project/category to many employees over a date range. For each
|
||||
* employee, walk the range, keep the days that pass the weekday filter
|
||||
* (weekends excluded unless include_weekends) AND are under the per-cell cap,
|
||||
* group contiguous kept days into runs, and create one range entry per run by
|
||||
* reusing createEntry (so cap / past-date / category validation stays DRY).
|
||||
*
|
||||
* Days that pass the weekday filter but are already at the cap are counted in
|
||||
* skipped_days (and split the range). Weekend days, when excluded, are out of
|
||||
* scope and are NOT counted as skipped.
|
||||
*/
|
||||
export async function bulkCreateEntries(
|
||||
input: {
|
||||
user_ids: number[];
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
include_weekends: boolean;
|
||||
project_id?: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
},
|
||||
actorUserId: number,
|
||||
): Promise<
|
||||
Result<{
|
||||
created_entries: number;
|
||||
created_days: number;
|
||||
skipped_days: number;
|
||||
users: number;
|
||||
}>
|
||||
> {
|
||||
if (input.user_ids.length === 0) {
|
||||
return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
|
||||
}
|
||||
if (input.date_to < input.date_from) {
|
||||
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
||||
}
|
||||
const lockFrom = assertNotPastDate(input.date_from, false);
|
||||
if (lockFrom) return lockFrom;
|
||||
const lockTo = assertNotPastDate(input.date_to, false);
|
||||
if (lockTo) return lockTo;
|
||||
const catErr = await assertActiveCategory(input.category);
|
||||
if (catErr) return catErr;
|
||||
|
||||
const from = toDateOnly(input.date_from);
|
||||
const to = toDateOnly(input.date_to);
|
||||
|
||||
// The day list for the whole range (UTC midnights), built once.
|
||||
const days: Date[] = [];
|
||||
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
days.push(new Date(d));
|
||||
}
|
||||
|
||||
let createdEntries = 0;
|
||||
let createdDays = 0;
|
||||
let skippedDays = 0;
|
||||
|
||||
for (const userId of input.user_ids) {
|
||||
// Per-day cap counts from this user's existing active entries.
|
||||
const existing = await prisma.work_plan_entries.findMany({
|
||||
where: {
|
||||
user_id: userId,
|
||||
is_deleted: false,
|
||||
date_from: { lte: to },
|
||||
date_to: { gte: from },
|
||||
},
|
||||
select: { date_from: true, date_to: true },
|
||||
});
|
||||
const countFor = (day: Date): number =>
|
||||
existing.filter((e) => e.date_from <= day && e.date_to >= day).length;
|
||||
|
||||
// Eligibility per day (weekday filter + under-cap). Tally cap skips.
|
||||
const eligible: boolean[] = [];
|
||||
for (const day of days) {
|
||||
const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
if (isWeekend && !input.include_weekends) {
|
||||
eligible.push(false);
|
||||
continue;
|
||||
}
|
||||
if (countFor(day) >= MAX_RECORDS_PER_CELL) {
|
||||
skippedDays++;
|
||||
eligible.push(false);
|
||||
continue;
|
||||
}
|
||||
eligible.push(true);
|
||||
}
|
||||
|
||||
// Group contiguous eligible days into runs; create one range per run.
|
||||
let runStart = -1;
|
||||
for (let i = 0; i <= days.length; i++) {
|
||||
const ok = i < days.length && eligible[i];
|
||||
if (ok && runStart === -1) {
|
||||
runStart = i;
|
||||
} else if (!ok && runStart !== -1) {
|
||||
const segFrom = days[runStart].toISOString().slice(0, 10);
|
||||
const segTo = days[i - 1].toISOString().slice(0, 10);
|
||||
const runDays = i - runStart;
|
||||
const res = await createEntry(
|
||||
{
|
||||
user_id: userId,
|
||||
date_from: segFrom,
|
||||
date_to: segTo,
|
||||
project_id: input.project_id ?? null,
|
||||
category: input.category,
|
||||
note: input.note,
|
||||
},
|
||||
actorUserId,
|
||||
false,
|
||||
);
|
||||
if ("data" in res) {
|
||||
createdEntries++;
|
||||
createdDays += runDays;
|
||||
} else {
|
||||
// Rare race (e.g. the cap filled concurrently between the count and
|
||||
// the create). Treat the run's days as skipped, but log — services
|
||||
// must not swallow non-fatal failures (CLAUDE.md known-issue #4).
|
||||
console.warn("[plan.service] bulkCreateEntries: segment skipped", {
|
||||
userId,
|
||||
segFrom,
|
||||
segTo,
|
||||
error: res.error,
|
||||
});
|
||||
skippedDays += runDays;
|
||||
}
|
||||
runStart = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
created_entries: createdEntries,
|
||||
created_days: createdDays,
|
||||
skipped_days: skippedDays,
|
||||
users: input.user_ids.length,
|
||||
},
|
||||
oldData: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw-row list endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user