diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts index aedfd34..72c4ab0 100644 --- a/src/__tests__/plan.test.ts +++ b/src/__tests__/plan.test.ts @@ -20,6 +20,7 @@ import { deleteOverride, listEntries, listOverrides, + bulkCreateEntries, } from "../services/plan.service"; // --------------------------------------------------------------------------- @@ -788,6 +789,170 @@ describe("plan.service.listOverrides (employee scoping)", () => { }); }); +// --------------------------------------------------------------------------- +// bulkCreateEntries +// --------------------------------------------------------------------------- + +describe("plan.service.bulkCreateEntries", () => { + it("creates one continuous range per employee when weekends are included", async () => { + // 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one + // range 06-01..06-03 covering all 3 days. + const res = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-06-01", + date_to: "2096-06-03", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}bulk-wknd`, + }, + adminUserId, + ); + expect("data" in res).toBe(true); + if (!("data" in res)) return; + expect(res.data.created_entries).toBe(1); + expect(res.data.created_days).toBe(3); + expect(res.data.skipped_days).toBe(0); + expect(res.data.users).toBe(1); + }); + + it("splits into per-work-week ranges and skips weekends when excluded", async () => { + // 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue. + // Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped. + const res = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-06-01", + date_to: "2096-06-05", + include_weekends: false, + project_id: null, + category: "work", + note: `${N}bulk-week`, + }, + adminUserId, + ); + expect("data" in res).toBe(true); + if (!("data" in res)) return; + expect(res.data.created_entries).toBe(2); + expect(res.data.created_days).toBe(3); + expect(res.data.skipped_days).toBe(0); + }); + + it("skips days already at the cap and splits the range around them", async () => { + // Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on + // bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips + // 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on. + for (let i = 0; i < 3; i++) { + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2096-07-02"), + date_to: new Date("2096-07-02"), + category: "work", + note: `${N}cap${i}`, + created_by: adminUserId, + }, + }); + } + const res = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-07-01", + date_to: "2096-07-03", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}bulk-cap`, + }, + adminUserId, + ); + expect("data" in res).toBe(true); + if (!("data" in res)) return; + expect(res.data.created_entries).toBe(2); + expect(res.data.created_days).toBe(2); + expect(res.data.skipped_days).toBe(1); + }); + + it("aggregates across multiple employees", async () => { + const res = await bulkCreateEntries( + { + user_ids: [adminUserId, noPermUserId], + date_from: "2096-08-03", // Friday + date_to: "2096-08-03", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}bulk-multi`, + }, + adminUserId, + ); + expect("data" in res).toBe(true); + if (!("data" in res)) return; + expect(res.data.users).toBe(2); + expect(res.data.created_entries).toBe(2); + expect(res.data.created_days).toBe(2); + }); + + it("rejects empty user_ids, past dates, bad range, and inactive category", async () => { + const empty = await bulkCreateEntries( + { + user_ids: [], + date_from: "2096-06-01", + date_to: "2096-06-01", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}x`, + }, + adminUserId, + ); + expect("error" in empty && empty.status).toBe(400); + + const past = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2000-01-01", + date_to: "2000-01-02", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}x`, + }, + adminUserId, + ); + expect("error" in past && past.status).toBe(403); + + const badRange = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-06-10", + date_to: "2096-06-01", + include_weekends: true, + project_id: null, + category: "work", + note: `${N}x`, + }, + adminUserId, + ); + expect("error" in badRange && badRange.status).toBe(400); + + const badCat = await bulkCreateEntries( + { + user_ids: [adminUserId], + date_from: "2096-06-01", + date_to: "2096-06-01", + include_weekends: true, + project_id: null, + category: "__nope__", + note: `${N}x`, + }, + adminUserId, + ); + expect("error" in badCat && badCat.status).toBe(400); + }); +}); + // --------------------------------------------------------------------------- // HTTP route tests (Fastify inject) // --------------------------------------------------------------------------- @@ -1036,4 +1201,32 @@ describe("HTTP /api/admin/plan", () => { ); expect(res.statusCode).toBe(201); }); + + it("POST /entries/bulk requires attendance.manage", async () => { + const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, { + user_ids: [adminUserId], + date_from: "2095-06-01", + date_to: "2095-06-01", + include_weekends: true, + category: "work", + note: `${N}bulk-forbidden`, + }); + expect(res.statusCode).toBe(403); + }); + + it("POST /entries/bulk creates entries for an admin and returns a summary", async () => { + const res = await authPost("/api/admin/plan/entries/bulk", adminToken, { + user_ids: [adminUserId], + date_from: "2095-06-05", // Sunday; weekends on → 1 day + date_to: "2095-06-05", + include_weekends: true, + category: "work", + note: `${N}bulk-http`, + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data.created_days).toBe(1); + expect(body.data.users).toBe(1); + }); }); diff --git a/src/routes/admin/plan.ts b/src/routes/admin/plan.ts index 9205591..2f829be 100644 --- a/src/routes/admin/plan.ts +++ b/src/routes/admin/plan.ts @@ -15,6 +15,7 @@ import { UpdatePlanOverrideSchema, PlanRangeQuerySchema, PlanGridQuerySchema, + BulkPlanEntrySchema, } from "../../schemas/plan.schema"; import { resolveGrid, @@ -27,6 +28,7 @@ import { deleteOverride, listEntries, listOverrides, + bulkCreateEntries, } from "../../services/plan.service"; import { CreatePlanCategorySchema, @@ -259,6 +261,29 @@ export default async function planRoutes(app: FastifyInstance) { }, ); + // --- POST /plan/entries/bulk --- + app.post( + "/entries/bulk", + { preHandler: requirePermission("attendance.manage") }, + async (request: FastifyRequest, reply: FastifyReply) => { + const body = parseBody(BulkPlanEntrySchema, request.body); + if ("error" in body) return error(reply, body.error, 400); + const result = await bulkCreateEntries( + body.data!, + request.authData!.userId, + ); + if ("error" in result) return error(reply, result.error, result.status); + await logAudit({ + request, + authData: request.authData, + action: "create", + entityType: "work_plan_entry", + description: `Hromadné přiřazení: ${body.data!.category} — ${result.data.users} zaměstnanců, ${body.data!.date_from}–${body.data!.date_to} (${result.data.created_days} dní)`, + }); + return success(reply, result.data, 201, "Hromadné přiřazení dokončeno"); + }, + ); + // --- PATCH /plan/entries/:id --- app.patch( "/entries/:id", diff --git a/src/schemas/plan.schema.ts b/src/schemas/plan.schema.ts index 54bbfbe..600655a 100644 --- a/src/schemas/plan.schema.ts +++ b/src/schemas/plan.schema.ts @@ -83,3 +83,26 @@ export const PlanGridQuerySchema = z.object({ date_to: isoDate, view: z.enum(["week", "month"]).default("week"), }); + +export const BulkPlanEntrySchema = z + .object({ + user_ids: z + .array(intFromForm) + .min(1, "Vyberte alespoň jednoho zaměstnance"), + date_from: isoDate, + date_to: isoDate, + include_weekends: z + .union([z.boolean(), z.string()]) + .transform((v) => v === true || v === "true" || v === "1") + .default(false), + project_id: z + .union([z.number(), z.string(), z.null()]) + .transform((v) => (v === null ? null : Number(v))) + .nullish(), + category: planCategoryEnum.default("work"), + note: z.string().max(500, "Maximálně 500 znaků").default(""), + }) + .refine((v) => v.date_to >= v.date_from, { + message: "Datum do musí být stejné nebo po datumu od", + path: ["date_to"], + }); diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts index 9e008c2..2d7b21d 100644 --- a/src/services/plan.service.ts +++ b/src/services/plan.service.ts @@ -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 // ---------------------------------------------------------------------------