diff --git a/src/lib/__tests__/getPqpRates.test.ts b/src/lib/__tests__/getPqpRates.test.ts new file mode 100644 index 0000000..db66421 --- /dev/null +++ b/src/lib/__tests__/getPqpRates.test.ts @@ -0,0 +1,329 @@ +import getPqpRates from "@/lib/getPqpRates"; +import { type COE, VehicleClass } from "@/types"; +import { describe, expect, it } from "vitest"; + +describe("getPqpRates", () => { + const mockCoeData: COE[] = [ + { + month: "2025-01", + bidding_no: 2, + vehicle_class: "Category A", + quota: 1069, + bids_success: 1058, + bids_received: 1484, + premium: 93601, + }, + { + month: "2025-01", + bidding_no: 2, + vehicle_class: "Category B", + quota: 686, + bids_success: 686, + bids_received: 1071, + premium: 116625, + }, + { + month: "2025-01", + bidding_no: 2, + vehicle_class: "Category C", + quota: 234, + bids_success: 233, + bids_received: 373, + premium: 65476, + }, + { + month: "2025-01", + bidding_no: 2, + vehicle_class: "Category D", + quota: 536, + bids_success: 530, + bids_received: 651, + premium: 7721, + }, + { + month: "2025-01", + bidding_no: 1, + vehicle_class: "Category A", + quota: 1034, + bids_success: 1034, + bids_received: 1381, + premium: 93699, + }, + { + month: "2025-01", + bidding_no: 1, + vehicle_class: "Category B", + quota: 677, + bids_success: 674, + bids_received: 1266, + premium: 121501, + }, + { + month: "2025-01", + bidding_no: 1, + vehicle_class: "Category C", + quota: 248, + bids_success: 246, + bids_received: 364, + premium: 67891, + }, + { + month: "2025-01", + bidding_no: 1, + vehicle_class: "Category D", + quota: 520, + bids_success: 513, + bids_received: 595, + premium: 9001, + }, + { + month: "2024-12", + bidding_no: 2, + vehicle_class: "Category A", + quota: 1035, + bids_success: 998, + bids_received: 1506, + premium: 96000, + }, + { + month: "2024-12", + bidding_no: 2, + vehicle_class: "Category B", + quota: 676, + bids_success: 667, + bids_received: 987, + premium: 109000, + }, + { + month: "2024-12", + bidding_no: 2, + vehicle_class: "Category C", + quota: 255, + bids_success: 255, + bids_received: 400, + premium: 69890, + }, + { + month: "2024-12", + bidding_no: 2, + vehicle_class: "Category D", + quota: 519, + bids_success: 504, + bids_received: 641, + premium: 8381, + }, + { + month: "2024-12", + bidding_no: 1, + vehicle_class: "Category A", + quota: 1037, + bids_success: 1034, + bids_received: 1475, + premium: 94000, + }, + { + month: "2024-12", + bidding_no: 1, + vehicle_class: "Category B", + quota: 689, + bids_success: 689, + bids_received: 866, + premium: 103010, + }, + { + month: "2024-12", + bidding_no: 1, + vehicle_class: "Category C", + quota: 262, + bids_success: 249, + bids_received: 418, + premium: 70289, + }, + { + month: "2024-12", + bidding_no: 1, + vehicle_class: "Category D", + quota: 517, + bids_success: 516, + bids_received: 570, + premium: 7878, + }, + { + month: "2024-11", + bidding_no: 2, + vehicle_class: "Category A", + quota: 1041, + bids_success: 1038, + bids_received: 1320, + premium: 89889, + }, + { + month: "2024-11", + bidding_no: 2, + vehicle_class: "Category B", + quota: 678, + bids_success: 678, + bids_received: 874, + premium: 105081, + }, + { + month: "2024-11", + bidding_no: 2, + vehicle_class: "Category C", + quota: 235, + bids_success: 214, + bids_received: 378, + premium: 69000, + }, + { + month: "2024-11", + bidding_no: 2, + vehicle_class: "Category D", + quota: 524, + bids_success: 524, + bids_received: 637, + premium: 8669, + }, + { + month: "2024-11", + bidding_no: 1, + vehicle_class: "Category A", + quota: 1040, + bids_success: 1035, + bids_received: 1338, + premium: 99889, + }, + { + month: "2024-11", + bidding_no: 1, + vehicle_class: "Category B", + quota: 696, + bids_success: 683, + bids_received: 1039, + premium: 108001, + }, + { + month: "2024-11", + bidding_no: 1, + vehicle_class: "Category C", + quota: 236, + bids_success: 209, + bids_received: 365, + premium: 68340, + }, + { + month: "2024-11", + bidding_no: 1, + vehicle_class: "Category D", + quota: 520, + bids_success: 520, + bids_received: 598, + premium: 9089, + }, + { + month: "2024-10", + bidding_no: 2, + vehicle_class: "Category A", + quota: 1058, + bids_success: 1048, + bids_received: 1499, + premium: 102900, + }, + { + month: "2024-10", + bidding_no: 2, + vehicle_class: "Category B", + quota: 670, + bids_success: 669, + bids_received: 975, + premium: 113890, + }, + { + month: "2024-10", + bidding_no: 2, + vehicle_class: "Category C", + quota: 222, + bids_success: 222, + bids_received: 337, + premium: 72939, + }, + { + month: "2024-10", + bidding_no: 2, + vehicle_class: "Category D", + quota: 522, + bids_success: 515, + bids_received: 626, + premium: 9589, + }, + { + month: "2024-10", + bidding_no: 1, + vehicle_class: "Category A", + quota: 994, + bids_success: 986, + bids_received: 1604, + premium: 103799, + }, + { + month: "2024-10", + bidding_no: 1, + vehicle_class: "Category B", + quota: 671, + bids_success: 652, + bids_received: 1084, + premium: 116002, + }, + { + month: "2024-10", + bidding_no: 1, + vehicle_class: "Category C", + quota: 218, + bids_success: 216, + bids_received: 321, + premium: 75009, + }, + { + month: "2024-10", + bidding_no: 1, + vehicle_class: "Category D", + quota: 520, + bids_success: 519, + bids_received: 604, + premium: 10001, + }, + ]; + + it("should correctly calculate PQP rates for all vehicle categories", () => { + expect(getPqpRates(mockCoeData)).toEqual({ + "2025-02": { + "Category A": 94513, + "Category B": 110537, + "Category C": 68481, + "Category D": 8457, + }, + "2025-01": { + "Category A": 97747, + "Category B": 109164, + "Category C": 70912, + "Category D": 8935, + }, + }); + }); + + it("should handle empty input array", () => { + const result = getPqpRates([]); + expect(result).toEqual({}); + }); + + it("should handle single category data", () => { + const singleCategoryData = mockCoeData.filter( + ({ vehicle_class }) => vehicle_class === VehicleClass.CategoryA, + ); + const result = getPqpRates(singleCategoryData); + expect(result).toEqual({ + "2025-02": { "Category A": 94513 }, + "2025-01": { "Category A": 97747 }, + }); + }); +}); diff --git a/src/lib/getPqpRates.ts b/src/lib/getPqpRates.ts new file mode 100644 index 0000000..7def2b1 --- /dev/null +++ b/src/lib/getPqpRates.ts @@ -0,0 +1,67 @@ +import type { COE, VehicleClass } from "@/types"; + +interface PQPResult { + month: string; + vehicle_class: string; + pqp: number; +} + +type COEByCategory = Record; + +const getPqpRates = (data: COE[]): Record> => { + const groupedByCategory: COEByCategory = data.reduce((acc, coe) => { + if (!acc[coe.vehicle_class]) { + acc[coe.vehicle_class] = []; + } + acc[coe.vehicle_class].push(coe); + return acc; + }, {} as COEByCategory); + + const calculateMonthDifference = (currentMonth: string, bidMonth: string) => { + const [currentYear, currentMonthNum] = currentMonth.split("-").map(Number); + const [bidYear, bidMonthNum] = bidMonth.split("-").map(Number); + return (currentYear - bidYear) * 12 + (currentMonthNum - bidMonthNum); + }; + + const calculatePqp = (coe: COE[]) => + Math.ceil(coe.reduce((sum, { premium }) => sum + premium, 0) / coe.length); + + const pqpResults: PQPResult[] = Object.entries(groupedByCategory).flatMap( + ([vehicle_class, biddings]) => { + const uniqueMonths = [...new Set(biddings.map(({ month }) => month))]; + + return uniqueMonths + .map((month) => { + const relevantBids = biddings + .filter((bid) => { + const monthsDiff = calculateMonthDifference(month, bid.month); + return monthsDiff >= 0 && monthsDiff <= 2; + }) + .slice(0, 6); + + if (relevantBids.length !== 6) { + return null; + } + + const pqp = calculatePqp(relevantBids); + + return { month, vehicle_class, pqp }; + }) + .filter((result) => !!result); + }, + ); + + return pqpResults.reduce((acc, { month, vehicle_class, pqp }) => { + const nextMonth = new Date(month); + nextMonth.setMonth(nextMonth.getMonth() + 1); + const nextMonthKey = nextMonth.toISOString().slice(0, 7); + + if (!acc[nextMonthKey]) { + acc[nextMonthKey] = {}; + } + acc[nextMonthKey][vehicle_class] = pqp; + return acc; + }, {}); +}; + +export default getPqpRates; diff --git a/src/types/index.ts b/src/types/index.ts index 3b4d065..982681a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,6 +17,14 @@ export enum OrderBy { DESC = "desc", } +export enum VehicleClass { + CategoryA = "Category A", + CategoryB = "Category B", + CategoryC = "Category C", + CategoryD = "Category D", + CategoryE = "Category E", +} + // Base types export type Stage = "dev" | "staging" | "prod"; export type Make = Car["make"]; @@ -32,12 +40,12 @@ export interface Car { export interface COE { month: string; - bidding_no: string; + bidding_no: number; vehicle_class: string; - quota: string; - bids_success: string; - bids_received: string; - premium: string; + quota: number; + bids_success: number; + bids_received: number; + premium: number; } export interface UpdateParams { diff --git a/src/v1/routes/coe.ts b/src/v1/routes/coe.ts index 2fbaaf6..f5b5f81 100644 --- a/src/v1/routes/coe.ts +++ b/src/v1/routes/coe.ts @@ -2,12 +2,14 @@ import { CACHE_TTL } from "@/config"; import db from "@/config/db"; import redis from "@/config/redis"; import { getLatestMonth } from "@/lib/getLatestMonth"; +import getPqpRates from "@/lib/getPqpRates"; import { getUniqueMonths } from "@/lib/getUniqueMonths"; import { groupMonthsByYear } from "@/lib/groupMonthsByYear"; -import { coe } from "@sgcarstrends/schema"; import { type COE, COEQuerySchema, MonthsQuerySchema } from "@/schemas"; +import { VehicleClass } from "@/types"; import { zValidator } from "@hono/zod-validator"; -import { and, asc, desc, eq, gte, lte } from "drizzle-orm"; +import { coe } from "@sgcarstrends/schema"; +import { and, asc, desc, eq, gte, lte, ne } from "drizzle-orm"; import { Hono } from "hono"; const app = new Hono(); @@ -71,4 +73,25 @@ app.get("/latest", async (c) => { return c.json(results); }); +app.get("/pqp", async (c) => { + const CACHE_KEY = "coe:pqp"; + + const cachedData = await redis.get(CACHE_KEY); + if (cachedData) { + return c.json(cachedData); + } + + const results = await db + .select() + .from(coe) + .where(ne(coe.vehicle_class, VehicleClass.CategoryE)) // Category E COEs are not included in PQP calculation + .orderBy(desc(coe.month), desc(coe.bidding_no), asc(coe.vehicle_class)); + + const pqpRates = getPqpRates(results); + + await redis.set(CACHE_KEY, pqpRates, { ex: CACHE_TTL }); + + return c.json(pqpRates); +}); + export default app;