feat(plan): resolveCell with override-precedence semantics
This commit is contained in:
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