feat(plan): resolveCell with override-precedence semantics
This commit is contained in:
121
src/__tests__/plan.test.ts
Normal file
121
src/__tests__/plan.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { resolveCell } from "../services/plan.service";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Note-prefix for test-created rows so we can clean them up safely. */
|
||||||
|
const N = "wh_plan_";
|
||||||
|
|
||||||
|
let adminUserId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Pick the first admin user from the test database as our fixture.
|
||||||
|
// The users -> roles relation is named `roles` in the Prisma schema
|
||||||
|
// (users.role_id references roles.id).
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminUserId = admin.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clean up any leftover test data from previous runs. We only touch rows
|
||||||
|
// whose `note` contains our test prefix, so we never disturb real data.
|
||||||
|
await prisma.work_plan_entries.deleteMany({
|
||||||
|
where: { note: { contains: N } },
|
||||||
|
});
|
||||||
|
await prisma.work_plan_overrides.deleteMany({
|
||||||
|
where: { note: { contains: N } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Final cleanup so we never leave soft-deleted test rows behind.
|
||||||
|
await prisma.work_plan_entries.deleteMany({
|
||||||
|
where: { note: { contains: N } },
|
||||||
|
});
|
||||||
|
await prisma.work_plan_overrides.deleteMany({
|
||||||
|
where: { note: { contains: N } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveCell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("plan.service.resolveCell", () => {
|
||||||
|
it("returns null when nothing covers the date", async () => {
|
||||||
|
// No entry, no override for (adminUserId, "2099-01-01")
|
||||||
|
const result = await resolveCell(adminUserId, "2099-01-01");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the entry that covers the date", async () => {
|
||||||
|
await prisma.work_plan_entries.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: new Date("2099-06-01"),
|
||||||
|
date_to: new Date("2099-06-10"),
|
||||||
|
category: "work",
|
||||||
|
note: `${N}PLC upgrade`,
|
||||||
|
created_by: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.source).toBe("entry");
|
||||||
|
expect(result?.note).toBe(`${N}PLC upgrade`);
|
||||||
|
expect(result?.category).toBe("work");
|
||||||
|
expect(result?.rangeFrom).toBe("2099-06-01");
|
||||||
|
expect(result?.rangeTo).toBe("2099-06-10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the override for that day, not the entry", async () => {
|
||||||
|
// An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05
|
||||||
|
// takes precedence.
|
||||||
|
await prisma.work_plan_entries.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: new Date("2099-06-01"),
|
||||||
|
date_to: new Date("2099-06-10"),
|
||||||
|
category: "work",
|
||||||
|
note: `${N}PLC upgrade`,
|
||||||
|
created_by: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.work_plan_overrides.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
shift_date: new Date("2099-06-05"),
|
||||||
|
category: "leave",
|
||||||
|
note: `${N}Volno po noční`,
|
||||||
|
created_by: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.source).toBe("override");
|
||||||
|
expect(result?.note).toBe(`${N}Volno po noční`);
|
||||||
|
expect(result?.category).toBe("leave");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores soft-deleted entries and overrides", async () => {
|
||||||
|
await prisma.work_plan_entries.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: new Date("2099-06-01"),
|
||||||
|
date_to: new Date("2099-06-10"),
|
||||||
|
category: "work",
|
||||||
|
note: `${N}deleted entry`,
|
||||||
|
is_deleted: true,
|
||||||
|
created_by: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/services/plan.service.ts
Normal file
98
src/services/plan.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import prisma from "../config/database";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved plan cell for a single (user, date). Either from a range entry
|
||||||
|
* (work_plan_entries) or from a single-day override (work_plan_overrides).
|
||||||
|
*/
|
||||||
|
export interface ResolvedCell {
|
||||||
|
/** Where the cell came from. */
|
||||||
|
source: "entry" | "override";
|
||||||
|
/** ID of the work_plan_entries row, or null when source is "override". */
|
||||||
|
entryId: number | null;
|
||||||
|
/** ID of the work_plan_overrides row, or null when source is "entry". */
|
||||||
|
overrideId: number | null;
|
||||||
|
user_id: number;
|
||||||
|
/** The date the cell resolves to (echoes the input, YYYY-MM-DD). */
|
||||||
|
shift_date: string;
|
||||||
|
project_id: number | null;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
/** Original entry range, or null when source is "override". */
|
||||||
|
rangeFrom: string | null;
|
||||||
|
rangeTo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the effective plan cell for (userId, dateStr).
|
||||||
|
*
|
||||||
|
* Precedence: override > entry > null.
|
||||||
|
* Soft-deleted rows are ignored (is_deleted = false filter).
|
||||||
|
* If multiple entries cover the same day, the latest by created_at wins
|
||||||
|
* and a warning is logged.
|
||||||
|
*
|
||||||
|
* dateStr must be a YYYY-MM-DD string. The function builds a JS Date
|
||||||
|
* from it and Prisma handles timezone conversion against the MySQL
|
||||||
|
* @db.Date columns.
|
||||||
|
*/
|
||||||
|
export async function resolveCell(
|
||||||
|
userId: number,
|
||||||
|
dateStr: string,
|
||||||
|
): Promise<ResolvedCell | null> {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
|
||||||
|
// 1. Single-day override for this exact date
|
||||||
|
const override = await prisma.work_plan_overrides.findFirst({
|
||||||
|
where: { user_id: userId, shift_date: date, is_deleted: false },
|
||||||
|
});
|
||||||
|
if (override) {
|
||||||
|
return {
|
||||||
|
source: "override",
|
||||||
|
entryId: null,
|
||||||
|
overrideId: override.id,
|
||||||
|
user_id: override.user_id,
|
||||||
|
shift_date: dateStr,
|
||||||
|
project_id: override.project_id,
|
||||||
|
category: override.category,
|
||||||
|
note: override.note,
|
||||||
|
rangeFrom: null,
|
||||||
|
rangeTo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Range entry that covers the day
|
||||||
|
const entries = await prisma.work_plan_entries.findMany({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
date_from: { lte: date },
|
||||||
|
date_to: { gte: date },
|
||||||
|
is_deleted: false,
|
||||||
|
},
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
if (entries.length > 1) {
|
||||||
|
// Multiple entries overlap. Use the latest. A persistent audit-log
|
||||||
|
// entry would be ideal here, but logAudit() currently requires a
|
||||||
|
// FastifyRequest — services have no request. Use console.warn as a
|
||||||
|
// stand-in until a service-context audit logger is introduced.
|
||||||
|
console.warn(
|
||||||
|
`[plan.service] multiple entries cover user ${userId} on ${dateStr}; using the latest (entry id ${entries[0].id})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = entries[0];
|
||||||
|
return {
|
||||||
|
source: "entry",
|
||||||
|
entryId: entry.id,
|
||||||
|
overrideId: null,
|
||||||
|
user_id: entry.user_id,
|
||||||
|
shift_date: dateStr,
|
||||||
|
project_id: entry.project_id,
|
||||||
|
category: entry.category,
|
||||||
|
note: entry.note,
|
||||||
|
rangeFrom: entry.date_from.toISOString().slice(0, 10),
|
||||||
|
rangeTo: entry.date_to.toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user