fix(schedule): calendar

This commit is contained in:
avicenan
2026-04-19 19:32:33 +07:00
parent b8430d3752
commit f055d612d3
2 changed files with 348 additions and 83 deletions

View File

@ -1,7 +1,9 @@
import { Room, ScheduleException, ScheduleSeries, Status, User } from "entity";
import { SelectQueryBuilder } from "typeorm";
import { IsNull, SelectQueryBuilder } from "typeorm";
import CommonHelper from "../helpers/common";
import { OrmHelper } from "../helpers/orm";
import dayjs from "dayjs";
import { RoomModel } from "./room";
type RecurrenceInput = {
type: "daily" | "weekly" | "monthly";
@ -57,32 +59,309 @@ export class ScheduleModel {
return query;
}
// static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise<any> {
// const startDate = new Date(start);
// const endDate = new Date(end);
// if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
// throw new Error("Invalid start or end date");
// }
// const rangeStart = ScheduleModel.startOfDay(startDate);
// const rangeEnd = ScheduleModel.endOfDay(endDate);
// if (rangeStart > rangeEnd) {
// throw new Error("Start date must be before end date");
// }
// const seriesQuery = await ScheduleModel.listSeries(filterObj);
// seriesQuery
// .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: ScheduleModel.formatYmd(rangeEnd) })
// .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", {
// rangeStartDate: ScheduleModel.formatYmd(rangeStart),
// })
// .andWhere("ScheduleSeries.deleted_at IS NULL");
// const seriesList: ScheduleSeries[] = await seriesQuery.getMany();
// if (seriesList.length === 0) return { resources: [], events: [] };
// const seriesIds = seriesList.map((series) => series.id);
// const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd);
// const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException);
// const exceptions = await exceptionRepo
// .createQueryBuilder("ScheduleException")
// .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom")
// .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor")
// .where("ScheduleException.series_id IN (:...seriesIds)", { seriesIds })
// .andWhere("ScheduleException.occurrence_date BETWEEN :rangeStart AND :rangeEnd", {
// rangeStart: ScheduleModel.formatYmd(rangeStart),
// rangeEnd: ScheduleModel.formatYmd(rangeEnd),
// })
// .andWhere("ScheduleException.deleted_at IS NULL")
// .getMany();
// const exceptionMap = new Map<string, ScheduleException>();
// for (const ex of exceptions) {
// const key = `${ex.series_id}:${ScheduleModel.formatYmd(new Date(ex.occurrence_date))}`;
// exceptionMap.set(key, ex);
// }
// const allEvents: any[] = [];
// for (const series of seriesList) {
// const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd);
// for (const occurrenceDate of occurrenceDates) {
// const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate);
// const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`);
// if (exception && exception.is_cancelled) continue;
// const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time);
// const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time);
// // const doctor = exception?.override_doctor ?? series.doctor;
// const room = exception?.override_room ?? series.room;
// const eventStart = exception?.override_start_at ? new Date(exception.override_start_at) : defaultStart;
// const eventEnd = exception?.override_end_at ? new Date(exception.override_end_at) : defaultEnd;
// const eventTitle = exception?.override_title || series.title;
// const roomId = room?.id || null;
// // const doctorId = doctor?.id || null;
// const resourceId = `${roomId || "unknown-room"}`;
// // const resourceId = `${roomId || "unknown-room"}:${doctorId || "unknown-doctor"}`;
// const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || "";
// // const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || "";
// allEvents.push({
// id: `occ:${series.id}:${occurrenceYmd}`,
// seriesId: series.id,
// resourceId,
// title: eventTitle,
// startAt: eventStart.toISOString(),
// endAt: eventEnd.toISOString(),
// quota: series.quota ?? null,
// usedQuota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0,
// leftQuota:
// typeof series.quota === "number"
// ? Math.max(0, series.quota - (usageMap.get(`${series.id}:${occurrenceYmd}`) || 0))
// : null,
// isRecurring: !!series.recurrence_rule,
// isException: !!exception,
// status: "confirmed",
// _resource: {
// id: resourceId,
// roomId,
// roomName,
// // doctorId,
// // doctorName,
// },
// });
// }
// }
// const allResourcesMap = new Map<string, any>();
// for (const event of allEvents) {
// allResourcesMap.set(event._resource.id, event._resource);
// }
// const allResources = [...allResourcesMap.values()].sort((a, b) => a.id.localeCompare(b.id));
// const totalCount = allResources.length;
// const page = options.page ?? 1;
// const limit = options.limit ?? totalCount;
// const offset = (page - 1) * limit;
// const pagedResources = allResources.slice(offset, offset + limit);
// const allowedResourceIds = new Set(pagedResources.map((resource) => resource.id));
// const pagedEvents = allEvents
// .filter((event) => allowedResourceIds.has(event.resourceId))
// .sort((a, b) => {
// if (a.startAt === b.startAt) return a.id.localeCompare(b.id);
// return a.startAt.localeCompare(b.startAt);
// })
// .map((event) => {
// const { _resource, ...cleanEvent } = event;
// return cleanEvent;
// });
// return {
// resources: pagedResources,
// events: pagedEvents,
// meta: {
// total_count: totalCount,
// page,
// limit,
// },
// };
// }
// return resource as rooms and all the events with relation of room and doctor
// static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise<any> {
// const startDate = dayjs(start);
// const endDate = dayjs(end);
// if (!startDate.isValid() || !endDate.isValid()) {
// throw new Error("Invalid start or end date");
// }
// const rangeStart = startDate.startOf("day").toDate();
// const rangeEnd = endDate.endOf("day").toDate();
// if (rangeStart > rangeEnd) {
// throw new Error("Start date must be before end date");
// }
// const seriesQuery = await ScheduleModel.listSeries(filterObj);
// seriesQuery
// .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: dayjs(rangeEnd).format("YYYY-MM-DD") })
// .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", {
// rangeStartDate: dayjs(rangeStart).format("YYYY-MM-DD"),
// })
// .andWhere("ScheduleSeries.deleted_at IS NULL");
// const seriesList: ScheduleSeries[] = await seriesQuery.getMany();
// if (seriesList.length === 0) return { rooms: [], events: [] };
// const seriesIds = seriesList.map((series) => series.id);
// const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd);
// const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException);
// const exceptions = await exceptionRepo
// .createQueryBuilder("ScheduleException")
// .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom")
// .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor")
// .where("ScheduleException.series_id IN (:...seriesIds)", { seriesIds })
// .andWhere("ScheduleException.occurrence_date BETWEEN :rangeStart AND :rangeEnd", {
// rangeStart: ScheduleModel.formatYmd(rangeStart),
// rangeEnd: ScheduleModel.formatYmd(rangeEnd),
// })
// .andWhere("ScheduleException.deleted_at IS NULL")
// .getMany();
// const exceptionMap = new Map<string, ScheduleException>();
// for (const ex of exceptions) {
// const key = `${ex.series_id}:${dayjs(ex.occurrence_date).format("YYYY-MM-DD")}`;
// exceptionMap.set(key, ex);
// }
// const allEvents: any[] = [];
// for (const series of seriesList) {
// const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd);
// for (const occurrenceDate of occurrenceDates) {
// const occurrenceYmd = dayjs(occurrenceDate).format("YYYY-MM-DD");
// const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`);
// if (exception && exception.is_cancelled) continue;
// const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time);
// const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time);
// const doctor = exception?.override_doctor ?? series.doctor;
// const room = exception?.override_room ?? series.room;
// const eventStart = exception?.override_start_at ? new Date(exception.override_start_at) : defaultStart;
// const eventEnd = exception?.override_end_at ? new Date(exception.override_end_at) : defaultEnd;
// const eventTitle = exception?.override_title || series.title;
// const roomId = room?.id || null;
// const doctorId = doctor?.id || null;
// const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || "";
// const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || "";
// allEvents.push({
// id: `occ:${series.id}:${occurrenceYmd}`,
// seriesId: series.id,
// roomId,
// doctorId,
// doctorName,
// title: eventTitle,
// startAt: eventStart.toISOString(),
// endAt: eventEnd.toISOString(),
// quota: series.quota ?? null,
// usedQuota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0,
// leftQuota:
// typeof series.quota === "number"
// ? Math.max(0, series.quota - (usageMap.get(`${series.id}:${occurrenceYmd}`) || 0))
// : null,
// isRecurring: !!series.recurrence_rule,
// isException: !!exception,
// status: "confirmed",
// _room: {
// id: roomId,
// name: roomName,
// },
// });
// }
// }
// const allRoomsMap = new Map<string, any>();
// for (const event of allEvents) {
// allRoomsMap.set(event._room.id, event._room);
// }
// const allRooms = [...allRoomsMap.values()].sort((a, b) => a.id.localeCompare(b.id));
// const totalCount = allRooms.length;
// const page = options.page ?? 1;
// const limit = options.limit ?? totalCount;
// const offset = (page - 1) * limit;
// const pagedRooms = allRooms.slice(offset, offset + limit);
// const allowedRoomIds = new Set(pagedRooms.map((room) => room.id));
// const pagedEvents = allEvents
// .filter((event) => allowedRoomIds.has(event.roomId))
// .sort((a, b) => {
// if (a.startAt === b.startAt) return a.id.localeCompare(b.id);
// return a.startAt.localeCompare(b.startAt);
// })
// .map((event) => {
// const { _room, ...cleanEvent } = event;
// return cleanEvent;
// });
// return {
// rooms: pagedRooms,
// events: pagedEvents,
// meta: {
// total_count: totalCount,
// page,
// limit,
// },
// };
// }
static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise<any> {
const startDate = new Date(start);
const endDate = new Date(end);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
const startDate = dayjs(start);
const endDate = dayjs(end);
if (!startDate.isValid() || !endDate.isValid()) {
throw new Error("Invalid start or end date");
}
const rangeStart = ScheduleModel.startOfDay(startDate);
const rangeEnd = ScheduleModel.endOfDay(endDate);
const rangeStart = startDate.startOf("day").toDate();
const rangeEnd = endDate.endOf("day").toDate();
if (rangeStart > rangeEnd) {
throw new Error("Start date must be before end date");
}
const page = options.page ?? 1;
const limit = options.limit ?? 10;
const offset = (page - 1) * limit;
const query = await RoomModel.list({ ...filterObj, with_deleted: false });
const roomQuery = query
.select(["Room.id", "Room.room", "Room.code", "Room.picture", "Room.location", "Room.status", "Department.name"])
.leftJoin("Room.department", "Department")
.where("Department.name = :departmentName", { departmentName: "Outpatient" });
const roomTotalCount = await roomQuery
.getCount();
const rooms = await roomQuery
.orderBy("Room.room", "ASC")
.offset(offset)
.limit(limit)
.getMany();
const roomIds = rooms.map((room) => room.id);
const seriesQuery = await ScheduleModel.listSeries(filterObj);
seriesQuery
.andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: ScheduleModel.formatYmd(rangeEnd) })
.andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", {
rangeStartDate: ScheduleModel.formatYmd(rangeStart),
})
.andWhere("ScheduleSeries.deleted_at IS NULL");
const seriesList: ScheduleSeries[] = await seriesQuery.getMany();
if (seriesList.length === 0) return { resources: [], events: [] };
const seriesList = await seriesQuery
.where("ScheduleSeries.room_id IN (:...roomIds)", { roomIds })
.andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: dayjs(rangeEnd).format("YYYY-MM-DD") })
.andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { rangeStartDate: dayjs(rangeStart).format("YYYY-MM-DD") })
.andWhere("ScheduleSeries.deleted_at IS NULL")
.getMany();
if (seriesList.length === 0) return { rooms: rooms, events: [], meta: { total_count: rooms.length, page, limit } };
const seriesIds = seriesList.map((series) => series.id);
const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd);
const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException);
const exceptions = await exceptionRepo
.createQueryBuilder("ScheduleException")
@ -98,7 +377,7 @@ export class ScheduleModel {
const exceptionMap = new Map<string, ScheduleException>();
for (const ex of exceptions) {
const key = `${ex.series_id}:${ScheduleModel.formatYmd(new Date(ex.occurrence_date))}`;
const key = `${ex.series_id}:${dayjs(ex.occurrence_date).format("YYYY-MM-DD")}`;
exceptionMap.set(key, ex);
}
@ -107,7 +386,7 @@ export class ScheduleModel {
for (const series of seriesList) {
const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd);
for (const occurrenceDate of occurrenceDates) {
const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate);
const occurrenceYmd = dayjs(occurrenceDate).format("YYYY-MM-DD");
const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`);
if (exception && exception.is_cancelled) continue;
@ -122,68 +401,40 @@ export class ScheduleModel {
const roomId = room?.id || null;
const doctorId = doctor?.id || null;
const resourceId = `${roomId || "unknown-room"}:${doctorId || "unknown-doctor"}`;
const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || "";
const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || "";
allEvents.push({
id: `occ:${series.id}:${occurrenceYmd}`,
seriesId: series.id,
resourceId,
series_id: series.id,
title: eventTitle,
startAt: eventStart.toISOString(),
endAt: eventEnd.toISOString(),
room_id: roomId,
room_name: roomName,
doctor_id: doctorId,
doctor_name: doctorName,
start_at: eventStart.toISOString(),
end_at: eventEnd.toISOString(),
quota: series.quota ?? null,
usedQuota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0,
leftQuota:
used_quota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0,
left_quota:
typeof series.quota === "number"
? Math.max(0, series.quota - (usageMap.get(`${series.id}:${occurrenceYmd}`) || 0))
: null,
isRecurring: !!series.recurrence_rule,
isException: !!exception,
is_recurring: !!series.recurrence_rule,
is_exception: !!exception,
status: "confirmed",
_resource: {
id: resourceId,
roomId,
roomName,
doctorId,
doctorName,
},
});
}
}
const allResourcesMap = new Map<string, any>();
for (const event of allEvents) {
allResourcesMap.set(event._resource.id, event._resource);
}
const allResources = [...allResourcesMap.values()].sort((a, b) => a.id.localeCompare(b.id));
const totalCount = allResources.length;
const page = options.page ?? 1;
const limit = options.limit ?? totalCount;
const offset = (page - 1) * limit;
const pagedResources = allResources.slice(offset, offset + limit);
const allowedResourceIds = new Set(pagedResources.map((resource) => resource.id));
const pagedEvents = allEvents
.filter((event) => allowedResourceIds.has(event.resourceId))
.sort((a, b) => {
if (a.startAt === b.startAt) return a.id.localeCompare(b.id);
return a.startAt.localeCompare(b.startAt);
})
.map((event) => {
const { _resource, ...cleanEvent } = event;
return cleanEvent;
});
return {
resources: pagedResources,
events: pagedEvents,
meta: {
total_count: totalCount,
page,
limit,
},
count: rooms.length,
page: page,
total_count: roomTotalCount,
list: {
rooms: rooms,
events: allEvents,
}
};
}