This commit is contained in:
wayanrivan
2026-04-20 08:57:45 +07:00
2 changed files with 134 additions and 127 deletions

View File

@ -137,31 +137,45 @@ export class ScheduleController {
#swagger.parameters['limit'] = { in: 'query', required: false, type: 'number' } #swagger.parameters['limit'] = { in: 'query', required: false, type: 'number' }
#swagger.parameters['page'] = { in: 'query', required: false, type: 'number' } #swagger.parameters['page'] = { in: 'query', required: false, type: 'number' }
#swagger.responses[200] = { #swagger.responses[200] = {
description: 'Schedule calendar resources and events', description: 'Schedule calendar rooms and events',
schema: { schema: {
status: true, status: true,
code: 200, code: 200,
message: 'success', message: 'success',
data: { data: {
resources: [{ count: 1,
id: 'room-1:doctor-1', page: 1,
roomId: 'room-1', total_count: 1,
roomName: 'Room 1', list: {
doctorId: 'doctor-1', rooms: [{
doctorName: 'Dr A' id: 'uuid-string',
}], name: 'string',
events: [{ code: 'string',
id: 'occ:series-101:2026-03-20', picture: 'string',
seriesId: 'series-101', location: 'string',
resourceId: 'room-1:doctor-1', status: 'string',
title: 'Practice', department: {
startAt: '2026-03-20T09:00:00+07:00', name: 'string',
endAt: '2026-03-20T12:00:00+07:00', },
isRecurring: true, }],
isException: false, events: [{
status: 'confirmed' id: 'occ:series-101:2026-03-20',
}], series_id: 'series-101',
meta: { total_count: 24, page: 1, limit: 20 } room_id: 'room-1',
room_name: 'Room 1',
doctor_id: 'doctor-1',
doctor_name: 'Doctor 1',
title: 'Practice',
start_at: '2026-03-20T09:00:00+07:00',
end_at: '2026-03-20T12:00:00+07:00',
quota: 10,
used_quota: 5,
left_quota: 5,
is_recurring: true,
is_exception: false,
status: 'confirmed'
}],
}
} }
} }
} }

View File

@ -1,7 +1,9 @@
import { Room, ScheduleException, ScheduleSeries, Status, User } from "entity"; import { Room, ScheduleException, ScheduleSeries, Status, User } from "entity";
import { SelectQueryBuilder } from "typeorm"; import { IsNull, SelectQueryBuilder } from "typeorm";
import CommonHelper from "../helpers/common"; import CommonHelper from "../helpers/common";
import { OrmHelper } from "../helpers/orm"; import { OrmHelper } from "../helpers/orm";
import dayjs from "dayjs";
import { RoomModel } from "./room";
type RecurrenceInput = { type RecurrenceInput = {
type: "daily" | "weekly" | "monthly"; type: "daily" | "weekly" | "monthly";
@ -58,132 +60,123 @@ export class ScheduleModel {
} }
static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise<any> { static async getScheduleCalendarRange(start: string, end: string, filterObj = {}, options: CalendarQueryOptions = {}): Promise<any> {
const startDate = new Date(start); const startDate = dayjs(start);
const endDate = new Date(end); const endDate = dayjs(end);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { if (!startDate.isValid() || !endDate.isValid()) {
throw new Error("Invalid start or end date"); throw new Error("Invalid start or end date");
} }
const rangeStart = ScheduleModel.startOfDay(startDate); const rangeStart = startDate.startOf("day").toDate();
const rangeEnd = ScheduleModel.endOfDay(endDate); const rangeEnd = endDate.endOf("day").toDate();
if (rangeStart > rangeEnd) { if (rangeStart > rangeEnd) {
throw new Error("Start date must be before end date"); 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); const seriesQuery = await ScheduleModel.listSeries(filterObj);
seriesQuery const seriesList = await seriesQuery
.andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: ScheduleModel.formatYmd(rangeEnd) }) .where("ScheduleSeries.room_id IN (:...roomIds)", { roomIds })
.andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { .andWhere("ScheduleSeries.start_date <= :rangeEndDate", { rangeEndDate: dayjs(rangeEnd).format("YYYY-MM-DD") })
rangeStartDate: ScheduleModel.formatYmd(rangeStart), .andWhere("(ScheduleSeries.until_date IS NULL OR ScheduleSeries.until_date >= :rangeStartDate)", { rangeStartDate: dayjs(rangeStart).format("YYYY-MM-DD") })
}) .andWhere("ScheduleSeries.deleted_at IS NULL")
.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(); .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[] = []; const allEvents: any[] = [];
if (seriesList.length > 0) {
const seriesIds = seriesList.map((series) => series.id);
const usageMap = await ScheduleModel.getDailyUsedQuotaMap(seriesIds, rangeStart, rangeEnd);
for (const series of seriesList) { const exceptionRepo = OrmHelper.DB.getRepository(ScheduleException);
const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd); const exceptions = await exceptionRepo
for (const occurrenceDate of occurrenceDates) { .createQueryBuilder("ScheduleException")
const occurrenceYmd = ScheduleModel.formatYmd(occurrenceDate); .leftJoinAndSelect("ScheduleException.override_room", "OverrideRoom")
const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`); .leftJoinAndSelect("ScheduleException.override_doctor", "OverrideDoctor")
if (exception && exception.is_cancelled) continue; .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 defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time); const exceptionMap = new Map<string, ScheduleException>();
const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time); for (const ex of exceptions) {
const key = `${ex.series_id}:${dayjs(ex.occurrence_date).format("YYYY-MM-DD")}`;
exceptionMap.set(key, ex);
}
const doctor = exception?.override_doctor ?? series.doctor; for (const series of seriesList) {
const room = exception?.override_room ?? series.room; const occurrenceDates = ScheduleModel.expandSeriesOccurrences(series, rangeStart, rangeEnd);
const eventStart = exception?.override_start_at ? new Date(exception.override_start_at) : defaultStart; for (const occurrenceDate of occurrenceDates) {
const eventEnd = exception?.override_end_at ? new Date(exception.override_end_at) : defaultEnd; const occurrenceYmd = dayjs(occurrenceDate).format("YYYY-MM-DD");
const eventTitle = exception?.override_title || series.title; const exception = exceptionMap.get(`${series.id}:${occurrenceYmd}`);
if (exception && exception.is_cancelled) continue;
const roomId = room?.id || null; const defaultStart = ScheduleModel.combineDateAndTime(occurrenceYmd, series.start_time);
const doctorId = doctor?.id || null; const defaultEnd = ScheduleModel.combineDateAndTime(occurrenceYmd, series.end_time);
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({ const doctor = exception?.override_doctor ?? series.doctor;
id: `occ:${series.id}:${occurrenceYmd}`, const room = exception?.override_room ?? series.room;
seriesId: series.id, const eventStart = exception?.override_start_at ? exception.override_start_at : defaultStart;
resourceId, const eventEnd = exception?.override_end_at ? exception.override_end_at : defaultEnd;
title: eventTitle, const eventTitle = exception?.override_title || series.title;
startAt: eventStart.toISOString(),
endAt: eventEnd.toISOString(), const roomId = room?.id || null;
quota: series.quota ?? null, const doctorId = doctor?.id || null;
usedQuota: usageMap.get(`${series.id}:${occurrenceYmd}`) || 0, const roomName = (room as any)?.room || (room as any)?.name || (room as any)?.code || "";
leftQuota: const doctorName = (doctor as any)?.name || (doctor as any)?.fullname || (doctor as any)?.username || "";
typeof series.quota === "number"
? Math.max(0, series.quota - (usageMap.get(`${series.id}:${occurrenceYmd}`) || 0)) allEvents.push({
: null, id: `occ:${series.id}:${occurrenceYmd}`,
isRecurring: !!series.recurrence_rule, series_id: series.id,
isException: !!exception, title: eventTitle,
status: "confirmed", room_id: roomId,
_resource: { room_name: roomName,
id: resourceId, doctor_id: doctorId,
roomId, doctor_name: doctorName,
roomName, start_at: eventStart,
doctorId, end_at: eventEnd,
doctorName, quota: series.quota ?? null,
}, 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,
is_recurring: !!series.recurrence_rule,
is_exception: !!exception,
status: "confirmed",
});
}
} }
} }
const allResourcesMap = new Map<string, any>(); for (const room of rooms) {
for (const event of allEvents) { const roomEvents = allEvents.filter((event) => event.room_id === room.id);
allResourcesMap.set(event._resource.id, event._resource); room.events = roomEvents;
} }
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 { return {
resources: pagedResources, count: rooms.length,
events: pagedEvents, page: page,
meta: { total_count: roomTotalCount,
total_count: totalCount, list: rooms,
page,
limit,
},
}; };
} }
@ -469,8 +462,8 @@ export class ScheduleModel {
}; };
} }
private static combineDateAndTime(ymd: string, hhmmss: string): Date { private static combineDateAndTime(ymd: string, hhmmss: string) {
return new Date(`${ymd}T${hhmmss}`); return `${ymd} ${hhmmss}`;
} }
private static startOfDay(date: Date): Date { private static startOfDay(date: Date): Date {