feat(plan): bulk entry creation endpoint + service
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
@@ -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