mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
Fare and route-coach, production checklist updates
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
-- AlterTable: change distanceKm from Decimal to Double Precision on RouteStop
|
||||
ALTER TABLE "RouteStop" ALTER COLUMN "distanceKm" TYPE DOUBLE PRECISION;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RouteCoachTemplate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"routeId" TEXT NOT NULL,
|
||||
"coachId" TEXT NOT NULL,
|
||||
"positionNumber" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RouteCoachTemplate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RouteCoachTemplate_routeId_idx" ON "RouteCoachTemplate"("routeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RouteCoachTemplate_routeId_positionNumber_key" ON "RouteCoachTemplate"("routeId", "positionNumber");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_routeId_fkey" FOREIGN KEY ("routeId") REFERENCES "Route"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RouteCoachTemplate" ADD CONSTRAINT "RouteCoachTemplate_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "Coach"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SeatClass" ADD COLUMN "bedPosition" TEXT,
|
||||
ADD COLUMN "nationalityType" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SeatClass_coachTypeId_nationalityType_bedPosition_idx" ON "SeatClass"("coachTypeId", "nationalityType", "bedPosition");
|
||||
@@ -88,7 +88,9 @@ model SeatClass {
|
||||
coachTypeId String
|
||||
name String
|
||||
description String?
|
||||
baseFareMinor Int @default(0) // per-km rate
|
||||
nationalityType String? // 'LOCAL' | 'INTERNATIONAL'
|
||||
bedPosition String? // 'UPPER' | 'MIDDLE' | 'LOWER' | null for regular seat
|
||||
baseFareMinor Int @default(0) // per-km rate (tariff decimal × 100000)
|
||||
premiumMinor Int @default(0) // flat fee per passenger
|
||||
insuranceFeeMinor Int @default(0) // flat fee per passenger
|
||||
isActive Boolean @default(true)
|
||||
@@ -100,6 +102,7 @@ model SeatClass {
|
||||
segmentFares SegmentFareRule[]
|
||||
@@unique([coachTypeId, name])
|
||||
@@index([coachTypeId])
|
||||
@@index([coachTypeId, nationalityType, bedPosition])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -420,9 +423,10 @@ model Coach {
|
||||
status String @default("ACTIVE") // 'ACTIVE', 'MAINTENANCE', 'INACTIVE'
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
coachType CoachType @relation(fields: [coachTypeId], references: [id])
|
||||
seats Seat[]
|
||||
assignments CoachAssignment[]
|
||||
routeTemplates RouteCoachTemplate[]
|
||||
@@index([coachTypeId])
|
||||
@@index([sequence])
|
||||
@@schema("passenger")
|
||||
@@ -998,6 +1002,7 @@ model Route {
|
||||
fareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
schedules TrainSchedule[]
|
||||
coachTemplates RouteCoachTemplate[]
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1015,6 +1020,20 @@ model RouteStop {
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model RouteCoachTemplate {
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
coachId String
|
||||
positionNumber Int
|
||||
createdAt DateTime @default(now())
|
||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||
coach Coach @relation(fields: [coachId], references: [id])
|
||||
|
||||
@@unique([routeId, positionNumber])
|
||||
@@index([routeId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model RouteFareRule {
|
||||
id String @id @default(uuid())
|
||||
routeId String
|
||||
|
||||
@@ -166,21 +166,41 @@ async function seedCoachTypesAndClasses() {
|
||||
});
|
||||
}
|
||||
|
||||
// Tariff rates: baseFareMinor = tariff_decimal × 100000
|
||||
// Formula: fare = km × (baseFareMinor / 100000) × 1.02 × exchangeRate
|
||||
// LOCAL = Ethiopian or Djiboutian nationals
|
||||
// INTERNATIONAL = all other nationalities
|
||||
const seatClasses = [
|
||||
{ name: 'VIP Bed Lower', coachCode: 'SBC', baseFareMinor: 900, premiumMinor: 50, insuranceFeeMinor: 25 },
|
||||
{ name: 'VIP Bed Upper', coachCode: 'SBC', baseFareMinor: 800, premiumMinor: 45, insuranceFeeMinor: 20 },
|
||||
{ name: 'Economy Bed Upper', coachCode: 'HBC', baseFareMinor: 600, premiumMinor: 30, insuranceFeeMinor: 15 },
|
||||
{ name: 'Economy Bed Middle', coachCode: 'HBC', baseFareMinor: 550, premiumMinor: 28, insuranceFeeMinor: 14 },
|
||||
{ name: 'Economy Bed Lower', coachCode: 'HBC', baseFareMinor: 500, premiumMinor: 25, insuranceFeeMinor: 12 },
|
||||
{ name: 'Economy Regular', coachCode: 'HSC', baseFareMinor: 250, premiumMinor: 12, insuranceFeeMinor: 6 },
|
||||
// LOCAL rates
|
||||
{ name: 'Economy Regular (Local)', coachCode: 'HSC', nationalityType: 'LOCAL', bedPosition: null, baseFareMinor: 3000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Upper (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 4000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Middle (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'MIDDLE',baseFareMinor: 5500, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Lower (Local)', coachCode: 'HBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Upper (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'UPPER', baseFareMinor: 7500, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Lower (Local)', coachCode: 'SBC', nationalityType: 'LOCAL', bedPosition: 'LOWER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
// INTERNATIONAL rates
|
||||
{ name: 'Economy Regular (Intl)', coachCode: 'HSC', nationalityType: 'INTERNATIONAL', bedPosition: null, baseFareMinor: 6000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Upper (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 8000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Middle (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'MIDDLE',baseFareMinor: 11000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'Economy Bed Lower (Intl)', coachCode: 'HBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 12000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Upper (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'UPPER', baseFareMinor: 15000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
{ name: 'VIP Bed Lower (Intl)', coachCode: 'SBC', nationalityType: 'INTERNATIONAL', bedPosition: 'LOWER', baseFareMinor: 16000, premiumMinor: 0, insuranceFeeMinor: 0 },
|
||||
];
|
||||
|
||||
for (const sc of seatClasses) {
|
||||
const ct = await prisma.coachType.findUnique({ where: { id: sc.coachCode } });
|
||||
await prisma.seatClass.upsert({
|
||||
where: { coachTypeId_name: { coachTypeId: ct!.id, name: sc.name } },
|
||||
update: {},
|
||||
create: { coachTypeId: ct!.id, name: sc.name, baseFareMinor: sc.baseFareMinor, premiumMinor: sc.premiumMinor, insuranceFeeMinor: sc.insuranceFeeMinor },
|
||||
update: { nationalityType: sc.nationalityType, bedPosition: sc.bedPosition, baseFareMinor: sc.baseFareMinor },
|
||||
create: {
|
||||
coachTypeId: ct!.id,
|
||||
name: sc.name,
|
||||
nationalityType: sc.nationalityType,
|
||||
bedPosition: sc.bedPosition,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
premiumMinor: sc.premiumMinor,
|
||||
insuranceFeeMinor: sc.insuranceFeeMinor,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ✅ ${coachTypes.length} coach types, ${seatClasses.length} seat classes created`);
|
||||
@@ -238,6 +258,58 @@ async function seedRoute() {
|
||||
});
|
||||
}
|
||||
console.log(` ✅ Route with ${returnStationCodes.length} stops created`);
|
||||
|
||||
// Full cross-border route: Sebeta → Nagad (all 15 stations)
|
||||
const fullRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-201' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'Route-201',
|
||||
name: 'Sebeta - Nagad (Full Cross-Border)',
|
||||
description: 'Full Ethio-Djibouti cross-border route from Sebeta to Nagad',
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
effectiveUntil: new Date('2034-12-31'),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Cumulative distances from Sebeta (km) for all 15 stations
|
||||
const fullStationCodes = ['SBT', 'LEB', 'BSH', 'MOJ', 'ADM', 'MTE', 'MIS', 'BIK', 'DRE', 'ADG', 'AYS', 'DAW', 'ALS', 'HOL', 'NAG'];
|
||||
const fullDistancesKm = [0, 11.5, 67.2, 89.9, 106.7, 180.2, 231.6, 293.6, 413.0, 453.0, 498.0, 531.0, 601.0, 632.0, 656.0];
|
||||
for (let i = 0; i < fullStationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: fullStationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: fullRoute.id, sequence: i + 1 } },
|
||||
update: { distanceKm: fullDistancesKm[i] },
|
||||
create: { routeId: fullRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
|
||||
// Full cross-border return route: Nagad → Sebeta
|
||||
const fullReturnRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-202' },
|
||||
update: {},
|
||||
create: {
|
||||
code: 'Route-202',
|
||||
name: 'Nagad - Sebeta (Full Cross-Border Return)',
|
||||
description: 'Full Ethio-Djibouti cross-border return route from Nagad to Sebeta',
|
||||
effectiveFrom: new Date('2026-01-01'),
|
||||
effectiveUntil: new Date('2034-12-31'),
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fullReturnStationCodes = ['NAG', 'HOL', 'ALS', 'DAW', 'AYS', 'ADG', 'DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
|
||||
const fullReturnDistancesKm = [0, 24.0, 55.0, 125.0, 158.0, 203.0, 243.0, 362.4, 424.4, 475.8, 549.3, 566.1, 588.8, 644.5, 656.0];
|
||||
for (let i = 0; i < fullReturnStationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: fullReturnStationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: fullReturnRoute.id, sequence: i + 1 } },
|
||||
update: { distanceKm: fullReturnDistancesKm[i] },
|
||||
create: { routeId: fullReturnRoute.id, stationId: station!.id, sequence: i + 1, distanceKm: fullReturnDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
console.log(` ✅ Full cross-border routes (Route-201, Route-202) with 15 stops each created`);
|
||||
}
|
||||
|
||||
async function seedCoaches() {
|
||||
@@ -431,34 +503,61 @@ async function seedTrips() {
|
||||
async function seedFareRules() {
|
||||
console.log('\n💰 Seeding fare rules...');
|
||||
const route = await prisma.route.findUnique({ where: { code: 'Route-101' } });
|
||||
const returnRoute = await prisma.route.findUnique({ where: { code: 'Route-102' } });
|
||||
const seatClasses = await prisma.seatClass.findMany();
|
||||
const validFrom = new Date('2024-01-01');
|
||||
|
||||
const fareRules = [];
|
||||
for (const sc of seatClasses) {
|
||||
fareRules.push({
|
||||
routeId: route!.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'ADULT' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
fareRules.push({
|
||||
routeId: route!.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'CHILD' as const,
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.5),
|
||||
discountPercent: 10,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
// Delete existing FareRule rows so re-seed is idempotent
|
||||
await prisma.fareRule.deleteMany({});
|
||||
|
||||
const fareRules: any[] = [];
|
||||
for (const route of [{ code: 'Route-101' }, { code: 'Route-102' }, { code: 'Route-201' }, { code: 'Route-202' }]) {
|
||||
for (const sc of seatClasses) {
|
||||
fareRules.push({
|
||||
route: route.code,
|
||||
seatClassId: sc.id,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
|
||||
fareRules.map(fr => prisma.fareRule.create({ data: fr }))
|
||||
);
|
||||
console.log(` ✅ ${fareRules.length} fare rules for ADULT/CHILD categories created`);
|
||||
console.log(` ✅ ${fareRules.length} fare rules created in FareRule table`);
|
||||
|
||||
const allRoutes = await prisma.route.findMany({
|
||||
where: { code: { in: ['Route-101', 'Route-102', 'Route-201', 'Route-202'] } },
|
||||
});
|
||||
const routeFareRules: any[] = [];
|
||||
for (const r of allRoutes) {
|
||||
for (const sc of seatClasses) {
|
||||
routeFareRules.push({
|
||||
routeId: r.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'ADULT' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
// CHILD: same per-km rate as ADULT — age-based free/paid logic is handled
|
||||
// at booking time (first child free, subsequent children full fare).
|
||||
routeFareRules.push({
|
||||
routeId: r.id,
|
||||
seatClassId: sc.id,
|
||||
passengerCategory: 'CHILD' as const,
|
||||
baseFareMinor: sc.baseFareMinor,
|
||||
currency: 'ETB',
|
||||
validFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
routeFareRules.map(fr => prisma.routeFareRule.create({ data: fr }).catch(() => {}))
|
||||
);
|
||||
console.log(` ✅ ${routeFareRules.length} route fare rules for ADULT/CHILD categories created`);
|
||||
}
|
||||
|
||||
async function seedCurrency() {
|
||||
@@ -758,7 +857,23 @@ async function runStep(name: string, step: () => Promise<unknown>): Promise<bool
|
||||
async function main() {
|
||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['System Users', seedSystemUsers],
|
||||
['Stations', seedStations],
|
||||
['Coach Types & Classes', seedCoachTypesAndClasses],
|
||||
['Route', seedRoute],
|
||||
['Coaches', seedCoaches],
|
||||
['Trips', seedTrips],
|
||||
['Fare Rules', seedFareRules],
|
||||
['Currency', seedCurrency],
|
||||
['Payment Methods', seedPaymentMethods],
|
||||
['Segment Fares', seedSegmentFares],
|
||||
['Notification Templates', seedNotificationTemplates],
|
||||
['Menu & Food', seedMenuAndFood],
|
||||
['Promotions', seedPromotions],
|
||||
['FAQ', seedFAQ],
|
||||
['Fraud Rules', seedFraudRules],
|
||||
['Kulubbi Package', seedKulubbiPackage],
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
Reference in New Issue
Block a user