Fare and route-coach, production checklist updates

This commit is contained in:
Stephanos A
2026-07-02 22:42:15 +03:00
parent 4aadf588d4
commit 200476dfd6
37 changed files with 1672 additions and 248 deletions

View File

@@ -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;

View File

@@ -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");

View File

@@ -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

View File

@@ -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;