From 8bba849f9324e3e9af81bd16627ff6363f5b2dcb Mon Sep 17 00:00:00 2001 From: Ru Chern CHONG Date: Wed, 29 Jan 2025 15:07:04 +0800 Subject: [PATCH 1/5] Add func to get COE PQP rates --- src/lib/__tests__/getPqpRates.test.ts | 377 ++++++++++++++++++++++++++ src/lib/getPqpRates.ts | 63 +++++ src/types/index.ts | 18 +- src/v1/routes/coe.ts | 18 +- 4 files changed, 469 insertions(+), 7 deletions(-) create mode 100644 src/lib/__tests__/getPqpRates.test.ts create mode 100644 src/lib/getPqpRates.ts diff --git a/src/lib/__tests__/getPqpRates.test.ts b/src/lib/__tests__/getPqpRates.test.ts new file mode 100644 index 0000000..e1c48a3 --- /dev/null +++ b/src/lib/__tests__/getPqpRates.test.ts @@ -0,0 +1,377 @@ +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-01": { + "Category A": 94513, + "Category B": 110537, + "Category C": 68481, + "Category D": 8457, + }, + "2024-12": { + "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-01": { "Category A": 94513 }, + "2024-12": { "Category A": 97747 }, + }); + }); + + // it("should handle less than 6 records for a category", () => { + // const limitedData = [ + // { vehicle_class: "Category A", premium: 93601 }, + // { vehicle_class: "Category A", premium: 93699 }, + // { vehicle_class: "Category A", premium: 96000 }, + // ]; + // + // const result = getPqpRates(limitedData); + // expect(result).toEqual({ + // "Category A": 94433, // (93601 + 93699 + 96000) / 3 -> 94433.33 -> 94433 + // }); + // }); + // + // it("should handle decimal values correctly", () => { + // const decimalData = [ + // { vehicle_class: "Category A", premium: 93601.5 }, + // { vehicle_class: "Category A", premium: 93699.75 }, + // { vehicle_class: "Category A", premium: 96000.25 }, + // { vehicle_class: "Category A", premium: 94000.8 }, + // { vehicle_class: "Category A", premium: 89889.9 }, + // { vehicle_class: "Category A", premium: 99889.3 }, + // ]; + // + // const result = getPqpRates(decimalData); + // expect(result["Category A"]).toBe(94514); // Rounded up from 94513.583 + // }); + // + // it("should handle string premium values", () => { + // const stringPremiumData = [ + // { vehicle_class: "Category A", premium: 93601 }, + // { vehicle_class: "Category A", premium: 93699 }, + // ]; + // + // const result = getPqpRates(stringPremiumData); + // expect(result["Category A"]).toBe(93650); + // }); + // + // it("should take only the most recent 6 records when more are provided", () => { + // const extendedData = [ + // { vehicle_class: "Category A", premium: 100000 }, // Should be ignored + // { vehicle_class: "Category A", premium: 100000 }, // Should be ignored + // ...mockCoeData.filter((coe) => coe.vehicle_class === "Category A"), + // ]; + // + // const result = getPqpRates(extendedData); + // expect(result["Category A"]).toBe(96648); + // }); +}); diff --git a/src/lib/getPqpRates.ts b/src/lib/getPqpRates.ts new file mode 100644 index 0000000..14d8289 --- /dev/null +++ b/src/lib/getPqpRates.ts @@ -0,0 +1,63 @@ +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 }) => { + if (!acc[month]) { + acc[month] = {}; + } + acc[month][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..f490d24 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, gt, gte, lte, ne, sql } from "drizzle-orm"; import { Hono } from "hono"; const app = new Hono(); @@ -71,4 +73,16 @@ app.get("/latest", async (c) => { return c.json(results); }); +app.get("/pqp", async (c) => { + 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); + + return c.json(pqpRates); +}); + export default app; From cdf9b34201f70b51d5498598ec82ac4b62d762c3 Mon Sep 17 00:00:00 2001 From: Ru Chern CHONG Date: Wed, 29 Jan 2025 17:14:07 +0800 Subject: [PATCH 2/5] Add caching for COE PQP rates --- src/v1/routes/coe.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/v1/routes/coe.ts b/src/v1/routes/coe.ts index f490d24..9dd9104 100644 --- a/src/v1/routes/coe.ts +++ b/src/v1/routes/coe.ts @@ -74,6 +74,13 @@ app.get("/latest", async (c) => { }); 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) @@ -82,6 +89,8 @@ app.get("/pqp", async (c) => { const pqpRates = getPqpRates(results); + await redis.set(CACHE_KEY, pqpRates, { ex: CACHE_TTL }); + return c.json(pqpRates); }); From 6b918661de4a4acdc8bf5336315bf2c52be4742f Mon Sep 17 00:00:00 2001 From: Ru Chern CHONG Date: Wed, 29 Jan 2025 17:18:46 +0800 Subject: [PATCH 3/5] Set PQP to 1 month in advanced --- src/lib/__tests__/getPqpRates.test.ts | 8 ++++---- src/lib/getPqpRates.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/lib/__tests__/getPqpRates.test.ts b/src/lib/__tests__/getPqpRates.test.ts index e1c48a3..0041851 100644 --- a/src/lib/__tests__/getPqpRates.test.ts +++ b/src/lib/__tests__/getPqpRates.test.ts @@ -296,13 +296,13 @@ describe("getPqpRates", () => { it("should correctly calculate PQP rates for all vehicle categories", () => { expect(getPqpRates(mockCoeData)).toEqual({ - "2025-01": { + "2025-02": { "Category A": 94513, "Category B": 110537, "Category C": 68481, "Category D": 8457, }, - "2024-12": { + "2025-01": { "Category A": 97747, "Category B": 109164, "Category C": 70912, @@ -322,8 +322,8 @@ describe("getPqpRates", () => { ); const result = getPqpRates(singleCategoryData); expect(result).toEqual({ - "2025-01": { "Category A": 94513 }, - "2024-12": { "Category A": 97747 }, + "2025-02": { "Category A": 94513 }, + "2025-01": { "Category A": 97747 }, }); }); diff --git a/src/lib/getPqpRates.ts b/src/lib/getPqpRates.ts index 14d8289..7def2b1 100644 --- a/src/lib/getPqpRates.ts +++ b/src/lib/getPqpRates.ts @@ -52,10 +52,14 @@ const getPqpRates = (data: COE[]): Record> => { ); return pqpResults.reduce((acc, { month, vehicle_class, pqp }) => { - if (!acc[month]) { - acc[month] = {}; + const nextMonth = new Date(month); + nextMonth.setMonth(nextMonth.getMonth() + 1); + const nextMonthKey = nextMonth.toISOString().slice(0, 7); + + if (!acc[nextMonthKey]) { + acc[nextMonthKey] = {}; } - acc[month][vehicle_class] = pqp; + acc[nextMonthKey][vehicle_class] = pqp; return acc; }, {}); }; From 3f7ad36a06629fa8a33b9252001c06606ca39d03 Mon Sep 17 00:00:00 2001 From: Ru Chern CHONG Date: Wed, 29 Jan 2025 17:21:05 +0800 Subject: [PATCH 4/5] Remove unused imports --- src/v1/routes/coe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/v1/routes/coe.ts b/src/v1/routes/coe.ts index 9dd9104..f5b5f81 100644 --- a/src/v1/routes/coe.ts +++ b/src/v1/routes/coe.ts @@ -9,7 +9,7 @@ import { type COE, COEQuerySchema, MonthsQuerySchema } from "@/schemas"; import { VehicleClass } from "@/types"; import { zValidator } from "@hono/zod-validator"; import { coe } from "@sgcarstrends/schema"; -import { and, asc, desc, eq, gt, gte, lte, ne, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gte, lte, ne } from "drizzle-orm"; import { Hono } from "hono"; const app = new Hono(); From 241795c845c85d2f88b3b1e49fc6a8de19e5c453 Mon Sep 17 00:00:00 2001 From: Ru Chern CHONG Date: Wed, 29 Jan 2025 17:21:40 +0800 Subject: [PATCH 5/5] Remove unused test cases --- src/lib/__tests__/getPqpRates.test.ts | 48 --------------------------- 1 file changed, 48 deletions(-) diff --git a/src/lib/__tests__/getPqpRates.test.ts b/src/lib/__tests__/getPqpRates.test.ts index 0041851..db66421 100644 --- a/src/lib/__tests__/getPqpRates.test.ts +++ b/src/lib/__tests__/getPqpRates.test.ts @@ -326,52 +326,4 @@ describe("getPqpRates", () => { "2025-01": { "Category A": 97747 }, }); }); - - // it("should handle less than 6 records for a category", () => { - // const limitedData = [ - // { vehicle_class: "Category A", premium: 93601 }, - // { vehicle_class: "Category A", premium: 93699 }, - // { vehicle_class: "Category A", premium: 96000 }, - // ]; - // - // const result = getPqpRates(limitedData); - // expect(result).toEqual({ - // "Category A": 94433, // (93601 + 93699 + 96000) / 3 -> 94433.33 -> 94433 - // }); - // }); - // - // it("should handle decimal values correctly", () => { - // const decimalData = [ - // { vehicle_class: "Category A", premium: 93601.5 }, - // { vehicle_class: "Category A", premium: 93699.75 }, - // { vehicle_class: "Category A", premium: 96000.25 }, - // { vehicle_class: "Category A", premium: 94000.8 }, - // { vehicle_class: "Category A", premium: 89889.9 }, - // { vehicle_class: "Category A", premium: 99889.3 }, - // ]; - // - // const result = getPqpRates(decimalData); - // expect(result["Category A"]).toBe(94514); // Rounded up from 94513.583 - // }); - // - // it("should handle string premium values", () => { - // const stringPremiumData = [ - // { vehicle_class: "Category A", premium: 93601 }, - // { vehicle_class: "Category A", premium: 93699 }, - // ]; - // - // const result = getPqpRates(stringPremiumData); - // expect(result["Category A"]).toBe(93650); - // }); - // - // it("should take only the most recent 6 records when more are provided", () => { - // const extendedData = [ - // { vehicle_class: "Category A", premium: 100000 }, // Should be ignored - // { vehicle_class: "Category A", premium: 100000 }, // Should be ignored - // ...mockCoeData.filter((coe) => coe.vehicle_class === "Category A"), - // ]; - // - // const result = getPqpRates(extendedData); - // expect(result["Category A"]).toBe(96648); - // }); });