From f1ecd83e9c7d78d5ebd21d3433b1a6962dd3b708 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:45:05 +0700 Subject: [PATCH] Persist multimodal order routes --- graphql-contracts | 2 +- .../4_add_order_route_stages/migration.sql | 141 +++++++++ prisma/schema.prisma | 62 ++++ src/demo-workspace.ts | 116 ++++++- src/schemas/team.ts | 282 ++++++++++++++++-- 5 files changed, 575 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/4_add_order_route_stages/migration.sql diff --git a/graphql-contracts b/graphql-contracts index a599040..4fffef4 160000 --- a/graphql-contracts +++ b/graphql-contracts @@ -1 +1 @@ -Subproject commit a599040ca801a7c829a44d49f762bc469a73b34e +Subproject commit 4fffef491517b49dca75a074e416a949b0e01826 diff --git a/prisma/migrations/4_add_order_route_stages/migration.sql b/prisma/migrations/4_add_order_route_stages/migration.sql new file mode 100644 index 0000000..246d8fa --- /dev/null +++ b/prisma/migrations/4_add_order_route_stages/migration.sql @@ -0,0 +1,141 @@ +-- AlterTable +ALTER TABLE "orders_order" ADD COLUMN "destination_hub_uuid" VARCHAR(100), +ADD COLUMN "source_hub_uuid" VARCHAR(100); + +-- CreateTable +CREATE TABLE "orders_quotation_route_stage" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "quotation_id" INTEGER NOT NULL, + "sequence" INTEGER NOT NULL, + "stage_type" VARCHAR(30) NOT NULL, + "transport_type" VARCHAR(50), + "source_point_uuid" VARCHAR(100), + "source_point_type" VARCHAR(30) NOT NULL, + "source_location_name" VARCHAR(255) NOT NULL, + "source_latitude" DOUBLE PRECISION, + "source_longitude" DOUBLE PRECISION, + "destination_point_uuid" VARCHAR(100), + "destination_point_type" VARCHAR(30) NOT NULL, + "destination_location_name" VARCHAR(255) NOT NULL, + "destination_latitude" DOUBLE PRECISION, + "destination_longitude" DOUBLE PRECISION, + "distance_km" DOUBLE PRECISION, + "travel_time_seconds" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "orders_quotation_route_stage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "orders_order_route_stage" ( + "id" SERIAL NOT NULL, + "uuid" TEXT NOT NULL, + "order_id" INTEGER NOT NULL, + "quotation_route_stage_uuid" VARCHAR(100), + "sequence" INTEGER NOT NULL, + "stage_type" VARCHAR(30) NOT NULL, + "transport_type" VARCHAR(50), + "source_point_uuid" VARCHAR(100), + "source_point_type" VARCHAR(30) NOT NULL, + "source_location_name" VARCHAR(255) NOT NULL, + "source_latitude" DOUBLE PRECISION, + "source_longitude" DOUBLE PRECISION, + "destination_point_uuid" VARCHAR(100), + "destination_point_type" VARCHAR(30) NOT NULL, + "destination_location_name" VARCHAR(255) NOT NULL, + "destination_latitude" DOUBLE PRECISION, + "destination_longitude" DOUBLE PRECISION, + "distance_km" DOUBLE PRECISION, + "travel_time_seconds" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "orders_order_route_stage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_quotation_route_stage_uuid_key" ON "orders_quotation_route_stage"("uuid"); + +-- CreateIndex +CREATE INDEX "orders_quotation_route_stage_quotation_id_sequence_idx" ON "orders_quotation_route_stage"("quotation_id", "sequence"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_quotation_route_stage_quotation_id_sequence_key" ON "orders_quotation_route_stage"("quotation_id", "sequence"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_order_route_stage_uuid_key" ON "orders_order_route_stage"("uuid"); + +-- CreateIndex +CREATE INDEX "orders_order_route_stage_order_id_sequence_idx" ON "orders_order_route_stage"("order_id", "sequence"); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_order_route_stage_order_id_sequence_key" ON "orders_order_route_stage"("order_id", "sequence"); + +-- AddForeignKey +ALTER TABLE "orders_quotation_route_stage" ADD CONSTRAINT "orders_quotation_route_stage_quotation_id_fkey" FOREIGN KEY ("quotation_id") REFERENCES "orders_quotation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "orders_order_route_stage" ADD CONSTRAINT "orders_order_route_stage_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders_order"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Backfill immutable quotation route snapshots for existing calculations. +INSERT INTO "orders_quotation_route_stage" ( + "uuid", "quotation_id", "sequence", "stage_type", "transport_type", + "source_point_uuid", "source_point_type", "source_location_name", "source_latitude", "source_longitude", + "destination_point_uuid", "destination_point_type", "destination_location_name", "destination_latitude", "destination_longitude", + "distance_km", "updated_at" +) +SELECT + quotation."uuid" || '-first-mile', quotation."id", 1, 'FIRST_MILE', 'AUTO', + quotation."source_location_uuid", 'ADDRESS', COALESCE(quotation."source_location_name", 'Origin'), quotation."source_latitude", quotation."source_longitude", + quotation."source_hub_uuid", 'HUB', COALESCE(quotation."source_hub_uuid", 'Origin hub'), NULL, NULL, + NULL, CURRENT_TIMESTAMP +FROM "orders_quotation" quotation +UNION ALL +SELECT + quotation."uuid" || '-main-haul', quotation."id", 2, 'MAIN_HAUL', COALESCE(quotation."transport_type_code", 'MULTIMODAL'), + quotation."source_hub_uuid", 'HUB', COALESCE(quotation."source_hub_uuid", 'Origin hub'), NULL, NULL, + quotation."destination_hub_uuid", 'HUB', COALESCE(quotation."destination_hub_uuid", 'Destination hub'), NULL, NULL, + quotation."route_distance_km", CURRENT_TIMESTAMP +FROM "orders_quotation" quotation +UNION ALL +SELECT + quotation."uuid" || '-last-mile', quotation."id", 3, 'LAST_MILE', 'AUTO', + quotation."destination_hub_uuid", 'HUB', COALESCE(quotation."destination_hub_uuid", 'Destination hub'), NULL, NULL, + quotation."destination_location_uuid", 'ADDRESS', COALESCE(quotation."destination_location_name", 'Destination'), quotation."destination_latitude", quotation."destination_longitude", + NULL, CURRENT_TIMESTAMP +FROM "orders_quotation" quotation; + +UPDATE "orders_order" order_row +SET + "source_hub_uuid" = quotation."source_hub_uuid", + "destination_hub_uuid" = quotation."destination_hub_uuid" +FROM "orders_quotation" quotation +WHERE order_row."quotation_id" = quotation."id"; + +INSERT INTO "orders_order_route_stage" ( + "uuid", "order_id", "quotation_route_stage_uuid", "sequence", "stage_type", "transport_type", + "source_point_uuid", "source_point_type", "source_location_name", "source_latitude", "source_longitude", + "destination_point_uuid", "destination_point_type", "destination_location_name", "destination_latitude", "destination_longitude", + "distance_km", "travel_time_seconds", "updated_at" +) +SELECT + order_row."uuid" || '-stage-' || route_stage."sequence", order_row."id", route_stage."uuid", route_stage."sequence", route_stage."stage_type", route_stage."transport_type", + route_stage."source_point_uuid", route_stage."source_point_type", route_stage."source_location_name", route_stage."source_latitude", route_stage."source_longitude", + route_stage."destination_point_uuid", route_stage."destination_point_type", route_stage."destination_location_name", route_stage."destination_latitude", route_stage."destination_longitude", + route_stage."distance_km", route_stage."travel_time_seconds", CURRENT_TIMESTAMP +FROM "orders_order" order_row +JOIN "orders_quotation_route_stage" route_stage ON route_stage."quotation_id" = order_row."quotation_id"; + +INSERT INTO "orders_order_route_stage" ( + "uuid", "order_id", "sequence", "stage_type", "transport_type", + "source_point_type", "source_location_name", "source_latitude", "source_longitude", + "destination_point_type", "destination_location_name", "destination_latitude", "destination_longitude", "updated_at" +) +SELECT + order_row."uuid" || '-stage-1', order_row."id", 1, 'MAIN_HAUL', 'MULTIMODAL', + 'ADDRESS', COALESCE(order_row."source_location_name", 'Origin'), order_row."source_latitude", order_row."source_longitude", + 'ADDRESS', COALESCE(order_row."destination_location_name", 'Destination'), order_row."destination_latitude", order_row."destination_longitude", CURRENT_TIMESTAMP +FROM "orders_order" order_row +WHERE order_row."quotation_id" IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8e1649a..fc64a81 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -95,12 +95,41 @@ model Quotation { orders Order[] changes QuotationChange[] + routeStages QuotationRouteStage[] @@index([teamUuid, status, createdAt]) @@index([teamUuid, sourceCountryCode, destinationCountryCode]) @@map("orders_quotation") } +model QuotationRouteStage { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + quotationId Int @map("quotation_id") + quotation Quotation @relation(fields: [quotationId], references: [id], onDelete: Cascade) + sequence Int + stageType String @map("stage_type") @db.VarChar(30) + transportType String? @map("transport_type") @db.VarChar(50) + sourcePointUuid String? @map("source_point_uuid") @db.VarChar(100) + sourcePointType String @map("source_point_type") @db.VarChar(30) + sourceLocationName String @map("source_location_name") @db.VarChar(255) + sourceLatitude Float? @map("source_latitude") + sourceLongitude Float? @map("source_longitude") + destinationPointUuid String? @map("destination_point_uuid") @db.VarChar(100) + destinationPointType String @map("destination_point_type") @db.VarChar(30) + destinationLocationName String @map("destination_location_name") @db.VarChar(255) + destinationLatitude Float? @map("destination_latitude") + destinationLongitude Float? @map("destination_longitude") + distanceKm Float? @map("distance_km") + travelTimeSeconds Int? @map("travel_time_seconds") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([quotationId, sequence]) + @@index([quotationId, sequence]) + @@map("orders_quotation_route_stage") +} + model QuotationChange { id Int @id @default(autoincrement()) uuid String @unique @default(uuid()) @@ -138,6 +167,7 @@ model Order { exchangeRateLockedAt DateTime? @map("exchange_rate_locked_at") pricingCountryCode String? @map("pricing_country_code") @db.VarChar(2) pricingKycProfileUuid String? @map("pricing_kyc_profile_uuid") @db.VarChar(100) + sourceHubUuid String? @map("source_hub_uuid") @db.VarChar(100) sourceLocationUuid String? @map("source_location_uuid") @db.VarChar(100) sourceLocationName String? @map("source_location_name") @db.VarChar(255) sourceCountryCode String? @map("source_country_code") @db.VarChar(10) @@ -146,6 +176,7 @@ model Order { destinationLocationUuid String? @map("destination_location_uuid") @db.VarChar(100) destinationLocationName String? @map("destination_location_name") @db.VarChar(255) destinationCountryCode String? @map("destination_country_code") @db.VarChar(10) + destinationHubUuid String? @map("destination_hub_uuid") @db.VarChar(100) destinationLatitude Float? @map("destination_latitude") destinationLongitude Float? @map("destination_longitude") etaDays Int? @map("eta_days") @@ -153,6 +184,37 @@ model Order { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + routeStages OrderRouteStage[] + @@index([teamUuid, createdAt]) @@map("orders_order") } + +model OrderRouteStage { + id Int @id @default(autoincrement()) + uuid String @unique @default(uuid()) + orderId Int @map("order_id") + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + quotationRouteStageUuid String? @map("quotation_route_stage_uuid") @db.VarChar(100) + sequence Int + stageType String @map("stage_type") @db.VarChar(30) + transportType String? @map("transport_type") @db.VarChar(50) + sourcePointUuid String? @map("source_point_uuid") @db.VarChar(100) + sourcePointType String @map("source_point_type") @db.VarChar(30) + sourceLocationName String @map("source_location_name") @db.VarChar(255) + sourceLatitude Float? @map("source_latitude") + sourceLongitude Float? @map("source_longitude") + destinationPointUuid String? @map("destination_point_uuid") @db.VarChar(100) + destinationPointType String @map("destination_point_type") @db.VarChar(30) + destinationLocationName String @map("destination_location_name") @db.VarChar(255) + destinationLatitude Float? @map("destination_latitude") + destinationLongitude Float? @map("destination_longitude") + distanceKm Float? @map("distance_km") + travelTimeSeconds Int? @map("travel_time_seconds") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([orderId, sequence]) + @@index([orderId, sequence]) + @@map("orders_order_route_stage") +} diff --git a/src/demo-workspace.ts b/src/demo-workspace.ts index fc7d818..a3bcaf2 100644 --- a/src/demo-workspace.ts +++ b/src/demo-workspace.ts @@ -24,6 +24,26 @@ interface DemoRoute { grossWeightKg: number volumeCbm: number routeDistanceKm: number + sourceHubUuid: string + destinationHubUuid: string + stages: DemoRouteStage[] +} + +interface DemoRouteStage { + stageType: 'FIRST_MILE' | 'MAIN_HAUL' | 'LAST_MILE' + transportType: 'AUTO' | 'RAIL' | 'SEA' | 'AIR' + sourcePointUuid: string + sourcePointType: string + sourceLocationName: string + sourceLatitude: number + sourceLongitude: number + destinationPointUuid: string + destinationPointType: string + destinationLocationName: string + destinationLatitude: number + destinationLongitude: number + distanceKm: number + travelTimeSeconds: number } const demoRoutes: DemoRoute[] = [ @@ -43,6 +63,14 @@ const demoRoutes: DemoRoute[] = [ grossWeightKg: 18400, volumeCbm: 68, routeDistanceKm: 8240, + sourceHubUuid: 'hub-mombasa-port', + destinationHubUuid: 'hub-almaty-terminal', + stages: [ + { stageType: 'FIRST_MILE', transportType: 'AUTO', sourcePointUuid: 'supplier-mombasa', sourcePointType: 'ADDRESS', sourceLocationName: 'Mombasa supplier', sourceLatitude: -4.0507, sourceLongitude: 39.6672, destinationPointUuid: 'hub-mombasa-port', destinationPointType: 'PORT', destinationLocationName: 'Port of Mombasa', destinationLatitude: -4.0435, destinationLongitude: 39.6682, distanceKm: 18, travelTimeSeconds: 3600 }, + { stageType: 'MAIN_HAUL', transportType: 'SEA', sourcePointUuid: 'hub-mombasa-port', sourcePointType: 'PORT', sourceLocationName: 'Port of Mombasa', sourceLatitude: -4.0435, sourceLongitude: 39.6682, destinationPointUuid: 'hub-jebel-ali-port', destinationPointType: 'PORT', destinationLocationName: 'Jebel Ali Port', destinationLatitude: 24.9857, destinationLongitude: 55.0273, distanceKm: 4420, travelTimeSeconds: 691200 }, + { stageType: 'MAIN_HAUL', transportType: 'RAIL', sourcePointUuid: 'hub-jebel-ali-port', sourcePointType: 'PORT', sourceLocationName: 'Jebel Ali Port', sourceLatitude: 24.9857, sourceLongitude: 55.0273, destinationPointUuid: 'hub-almaty-terminal', destinationPointType: 'RAIL_STATION', destinationLocationName: 'Almaty freight terminal', destinationLatitude: 43.222, destinationLongitude: 76.8512, distanceKm: 3782, travelTimeSeconds: 518400 }, + { stageType: 'LAST_MILE', transportType: 'AUTO', sourcePointUuid: 'hub-almaty-terminal', sourcePointType: 'RAIL_STATION', sourceLocationName: 'Almaty freight terminal', sourceLatitude: 43.222, sourceLongitude: 76.8512, destinationPointUuid: 'recipient-almaty', destinationPointType: 'ADDRESS', destinationLocationName: 'Almaty recipient', destinationLatitude: 43.2389, destinationLongitude: 76.8897, distanceKm: 20, travelTimeSeconds: 3600 }, + ], }, { title: 'Casablanca → Moscow', @@ -60,6 +88,14 @@ const demoRoutes: DemoRoute[] = [ grossWeightKg: 12600, volumeCbm: 52, routeDistanceKm: 5060, + sourceHubUuid: 'hub-casablanca-port', + destinationHubUuid: 'hub-moscow-terminal', + stages: [ + { stageType: 'FIRST_MILE', transportType: 'AUTO', sourcePointUuid: 'supplier-casablanca', sourcePointType: 'ADDRESS', sourceLocationName: 'Casablanca supplier', sourceLatitude: 33.5899, sourceLongitude: -7.6039, destinationPointUuid: 'hub-casablanca-port', destinationPointType: 'PORT', destinationLocationName: 'Port of Casablanca', destinationLatitude: 33.6047, destinationLongitude: -7.6122, distanceKm: 15, travelTimeSeconds: 2700 }, + { stageType: 'MAIN_HAUL', transportType: 'SEA', sourcePointUuid: 'hub-casablanca-port', sourcePointType: 'PORT', sourceLocationName: 'Port of Casablanca', sourceLatitude: 33.6047, sourceLongitude: -7.6122, destinationPointUuid: 'hub-algeciras-port', destinationPointType: 'PORT', destinationLocationName: 'Port of Algeciras', destinationLatitude: 36.1307, destinationLongitude: -5.4488, distanceKm: 455, travelTimeSeconds: 129600 }, + { stageType: 'MAIN_HAUL', transportType: 'RAIL', sourcePointUuid: 'hub-algeciras-port', sourcePointType: 'PORT', sourceLocationName: 'Port of Algeciras', sourceLatitude: 36.1307, sourceLongitude: -5.4488, destinationPointUuid: 'hub-moscow-terminal', destinationPointType: 'RAIL_STATION', destinationLocationName: 'Moscow freight terminal', destinationLatitude: 55.775, destinationLongitude: 37.655, distanceKm: 4560, travelTimeSeconds: 691200 }, + { stageType: 'LAST_MILE', transportType: 'AUTO', sourcePointUuid: 'hub-moscow-terminal', sourcePointType: 'RAIL_STATION', sourceLocationName: 'Moscow freight terminal', sourceLatitude: 55.775, sourceLongitude: 37.655, destinationPointUuid: 'recipient-moscow', destinationPointType: 'ADDRESS', destinationLocationName: 'Moscow recipient', destinationLatitude: 55.7558, destinationLongitude: 37.6173, distanceKm: 30, travelTimeSeconds: 5400 }, + ], }, { title: 'Nairobi → Dubai', @@ -77,6 +113,13 @@ const demoRoutes: DemoRoute[] = [ grossWeightKg: 4200, volumeCbm: 18, routeDistanceKm: 3560, + sourceHubUuid: 'hub-nairobi-airport', + destinationHubUuid: 'hub-dubai-airport', + stages: [ + { stageType: 'FIRST_MILE', transportType: 'AUTO', sourcePointUuid: 'supplier-nairobi', sourcePointType: 'ADDRESS', sourceLocationName: 'Nairobi supplier', sourceLatitude: -1.2921, sourceLongitude: 36.8219, destinationPointUuid: 'hub-nairobi-airport', destinationPointType: 'AIRPORT', destinationLocationName: 'Jomo Kenyatta International Airport', destinationLatitude: -1.3192, destinationLongitude: 36.9278, distanceKm: 18, travelTimeSeconds: 2700 }, + { stageType: 'MAIN_HAUL', transportType: 'AIR', sourcePointUuid: 'hub-nairobi-airport', sourcePointType: 'AIRPORT', sourceLocationName: 'Jomo Kenyatta International Airport', sourceLatitude: -1.3192, sourceLongitude: 36.9278, destinationPointUuid: 'hub-dubai-airport', destinationPointType: 'AIRPORT', destinationLocationName: 'Dubai International Airport', destinationLatitude: 25.2532, destinationLongitude: 55.3657, distanceKm: 3550, travelTimeSeconds: 18000 }, + { stageType: 'LAST_MILE', transportType: 'AUTO', sourcePointUuid: 'hub-dubai-airport', sourcePointType: 'AIRPORT', sourceLocationName: 'Dubai International Airport', sourceLatitude: 25.2532, sourceLongitude: 55.3657, destinationPointUuid: 'recipient-dubai', destinationPointType: 'ADDRESS', destinationLocationName: 'Dubai recipient', destinationLatitude: 25.2048, destinationLongitude: 55.2708, distanceKm: 22, travelTimeSeconds: 2700 }, + ], }, { title: 'Johannesburg → Warsaw', @@ -94,6 +137,13 @@ const demoRoutes: DemoRoute[] = [ grossWeightKg: 7600, volumeCbm: 31, routeDistanceKm: 8690, + sourceHubUuid: 'hub-johannesburg-airport', + destinationHubUuid: 'hub-warsaw-airport', + stages: [ + { stageType: 'FIRST_MILE', transportType: 'AUTO', sourcePointUuid: 'supplier-johannesburg', sourcePointType: 'ADDRESS', sourceLocationName: 'Johannesburg supplier', sourceLatitude: -26.2041, sourceLongitude: 28.0473, destinationPointUuid: 'hub-johannesburg-airport', destinationPointType: 'AIRPORT', destinationLocationName: 'O. R. Tambo International Airport', destinationLatitude: -26.1337, destinationLongitude: 28.242, distanceKm: 26, travelTimeSeconds: 3600 }, + { stageType: 'MAIN_HAUL', transportType: 'AIR', sourcePointUuid: 'hub-johannesburg-airport', sourcePointType: 'AIRPORT', sourceLocationName: 'O. R. Tambo International Airport', sourceLatitude: -26.1337, sourceLongitude: 28.242, destinationPointUuid: 'hub-warsaw-airport', destinationPointType: 'AIRPORT', destinationLocationName: 'Warsaw Chopin Airport', destinationLatitude: 52.1657, destinationLongitude: 20.9671, distanceKm: 8660, travelTimeSeconds: 39600 }, + { stageType: 'LAST_MILE', transportType: 'AUTO', sourcePointUuid: 'hub-warsaw-airport', sourcePointType: 'AIRPORT', sourceLocationName: 'Warsaw Chopin Airport', sourceLatitude: 52.1657, sourceLongitude: 20.9671, destinationPointUuid: 'recipient-warsaw', destinationPointType: 'ADDRESS', destinationLocationName: 'Warsaw recipient', destinationLatitude: 52.2297, destinationLongitude: 21.0122, distanceKm: 14, travelTimeSeconds: 2400 }, + ], }, ] @@ -114,7 +164,14 @@ function stableUuid(teamUuid: string, entity: string, index: number): string { } export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Promise { - const [quotationCount, orderCount, demoQuotationPricingCount, demoOrderPricingCount] = await Promise.all([ + const [ + quotationCount, + orderCount, + demoQuotationPricingCount, + demoOrderPricingCount, + demoQuotationWithoutRouteCount, + demoOrderWithoutRouteCount, + ] = await Promise.all([ prisma.quotation.count({ where: { teamUuid: access.teamUuid } }), prisma.order.count({ where: { teamUuid: access.teamUuid } }), prisma.quotation.count({ @@ -123,11 +180,18 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom prisma.order.count({ where: { teamUuid: access.teamUuid, notes: demoNote, sourceAmount: null }, }), + prisma.quotation.count({ + where: { teamUuid: access.teamUuid, notes: demoNote, routeStages: { none: {} } }, + }), + prisma.order.count({ + where: { teamUuid: access.teamUuid, notes: demoNote, routeStages: { none: {} } }, + }), ]) const needsCalculations = quotationCount === 0 const needsOrders = orderCount === 0 const needsPricingBackfill = demoQuotationPricingCount > 0 || demoOrderPricingCount > 0 - const kyc = needsCalculations || needsOrders || needsPricingBackfill + const needsRouteBackfill = demoQuotationWithoutRouteCount > 0 || demoOrderWithoutRouteCount > 0 + const kyc = needsCalculations || needsOrders || needsPricingBackfill || needsRouteBackfill ? await ensureDemoKycPricingContext({ teamUuid: access.teamUuid, userId: access.userId, @@ -149,6 +213,8 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom status: 'active', sourceCountryCode: route.sourceCountryCode, destinationCountryCode: route.destinationCountryCode, + sourceHubUuid: route.sourceHubUuid, + destinationHubUuid: route.destinationHubUuid, sourceAmount: new Prisma.Decimal(route.amountUsd), minSourcePrice: new Prisma.Decimal(route.amountUsd), etaDays: route.etaDays, @@ -167,6 +233,8 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom tareTypeCode: null, sourceCountryCode: route.sourceCountryCode, destinationCountryCode: route.destinationCountryCode, + sourceHubUuid: route.sourceHubUuid, + destinationHubUuid: route.destinationHubUuid, sourceAmount: new Prisma.Decimal(route.amountUsd), minSourcePrice: new Prisma.Decimal(route.amountUsd), etaDays: route.etaDays, @@ -176,7 +244,7 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom }, }) - if (!needsCalculations && !(needsOrders && createsOrder) && !needsPricingBackfill) continue + if (!needsCalculations && !(needsOrders && createsOrder) && !needsPricingBackfill && !needsRouteBackfill) continue if (kyc === null) throw new Error('KYC pricing context was not resolved for demo data') const price = await lockCurrencyPrice({ sourceAmount: route.amountUsd, @@ -201,10 +269,12 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom incotermsCode: 'DAP', transportTypeCode: route.transportTypeCode, sourceCountryCode: route.sourceCountryCode, + sourceHubUuid: route.sourceHubUuid, sourceLocationName: route.sourceLocationName, sourceLatitude: route.sourceLatitude, sourceLongitude: route.sourceLongitude, destinationCountryCode: route.destinationCountryCode, + destinationHubUuid: route.destinationHubUuid, destinationLocationName: route.destinationLocationName, destinationLatitude: route.destinationLatitude, destinationLongitude: route.destinationLongitude, @@ -229,6 +299,13 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom pricingKycProfileUuid: kyc.profileUuid, etaDays: route.etaDays, notes: demoNote, + routeStages: { + create: route.stages.map((stage, stageIndex) => ({ + uuid: stableUuid(access.teamUuid, `demo-quotation-${index}-stage`, stageIndex), + sequence: stageIndex + 1, + ...stage, + })), + }, }, update: { teamUuid: access.teamUuid, @@ -248,10 +325,20 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom pricingKycProfileUuid: kyc.profileUuid, etaDays: route.etaDays, notes: demoNote, + sourceHubUuid: route.sourceHubUuid, + destinationHubUuid: route.destinationHubUuid, + routeStages: { + deleteMany: {}, + create: route.stages.map((stage, stageIndex) => ({ + uuid: stableUuid(access.teamUuid, `demo-quotation-${index}-stage`, stageIndex), + sequence: stageIndex + 1, + ...stage, + })), + }, }, }) - if ((!needsOrders && !needsPricingBackfill) || !createsOrder) continue + if ((!needsOrders && !needsPricingBackfill && !needsRouteBackfill) || !createsOrder) continue await prisma.order.upsert({ where: { uuid: stableUuid(access.teamUuid, 'demo-order', index) }, create: { @@ -273,16 +360,26 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom exchangeRateLockedAt: pricingLockedAt, pricingCountryCode: price.pricingCountryCode, pricingKycProfileUuid: kyc.profileUuid, + sourceHubUuid: route.sourceHubUuid, sourceLocationName: route.sourceLocationName, sourceCountryCode: route.sourceCountryCode, sourceLatitude: route.sourceLatitude, sourceLongitude: route.sourceLongitude, destinationLocationName: route.destinationLocationName, destinationCountryCode: route.destinationCountryCode, + destinationHubUuid: route.destinationHubUuid, destinationLatitude: route.destinationLatitude, destinationLongitude: route.destinationLongitude, etaDays: route.etaDays, notes: demoNote, + routeStages: { + create: route.stages.map((stage, stageIndex) => ({ + uuid: stableUuid(access.teamUuid, `demo-order-${index}-stage`, stageIndex), + quotationRouteStageUuid: stableUuid(access.teamUuid, `demo-quotation-${index}-stage`, stageIndex), + sequence: stageIndex + 1, + ...stage, + })), + }, }, update: { teamUuid: access.teamUuid, @@ -301,6 +398,17 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom pricingCountryCode: price.pricingCountryCode, pricingKycProfileUuid: kyc.profileUuid, notes: demoNote, + sourceHubUuid: route.sourceHubUuid, + destinationHubUuid: route.destinationHubUuid, + routeStages: { + deleteMany: {}, + create: route.stages.map((stage, stageIndex) => ({ + uuid: stableUuid(access.teamUuid, `demo-order-${index}-stage`, stageIndex), + quotationRouteStageUuid: stableUuid(access.teamUuid, `demo-quotation-${index}-stage`, stageIndex), + sequence: stageIndex + 1, + ...stage, + })), + }, }, }) } diff --git a/src/schemas/team.ts b/src/schemas/team.ts index a392be7..6b5924d 100644 --- a/src/schemas/team.ts +++ b/src/schemas/team.ts @@ -20,6 +20,11 @@ import { const quotationInclude = { selectedTariff: true, + routeStages: { + orderBy: { + sequence: 'asc' as const, + }, + }, changes: { orderBy: { createdAt: 'desc' as const, @@ -29,6 +34,11 @@ const quotationInclude = { const orderInclude = { quotation: true, + routeStages: { + orderBy: { + sequence: 'asc' as const, + }, + }, } satisfies Prisma.OrderInclude type QuotationWithRelations = Prisma.QuotationGetPayload<{ include: typeof quotationInclude }> @@ -46,6 +56,7 @@ type CreateTariffReferenceInput = Record type UpdateTariffReferenceInput = Record type CreateQuotationInput = Record type UpdateQuotationInput = Record +type RouteStageInput = Record export const teamTypeDefs = `#graphql type Company { @@ -78,12 +89,18 @@ export const teamTypeDefs = `#graphql sequence: Int stageType: String transportType: String + sourcePointUuid: String + sourcePointType: String sourceLocationName: String sourceLatitude: Float sourceLongitude: Float + destinationPointUuid: String + destinationPointType: String destinationLocationName: String destinationLatitude: Float destinationLongitude: Float + distanceKm: Float + travelTimeSeconds: Int locationName: String locationLatitude: Float locationLongitude: Float @@ -127,13 +144,17 @@ export const teamTypeDefs = `#graphql totalAmount: Float @deprecated(reason: "Use clientAmount") currency: String @deprecated(reason: "Use clientCurrency") sourceCountryCode: String + sourceHubUuid: String sourceLocationUuid: String sourceLocationName: String sourceLatitude: Float sourceLongitude: Float destinationCountryCode: String + destinationHubUuid: String destinationLocationUuid: String destinationLocationName: String + destinationLatitude: Float + destinationLongitude: Float etaDays: Int createdAt: String updatedAt: String @@ -237,6 +258,7 @@ export const teamTypeDefs = `#graphql createdAt: String! updatedAt: String! changes: [QuotationChange!]! + routeStages: [Stage!]! } type QuotationTariffMatch { @@ -336,6 +358,7 @@ export const teamTypeDefs = `#graphql volumeCbm: Float unitsCount: Int routeDistanceKm: Int + routeStages: [RouteStageInput!] notes: String } @@ -364,9 +387,28 @@ export const teamTypeDefs = `#graphql volumeCbm: Float unitsCount: Int routeDistanceKm: Int + routeStages: [RouteStageInput!] notes: String } + input RouteStageInput { + sequence: Int! + stageType: String! + transportType: String + sourcePointUuid: String + sourcePointType: String! + sourceLocationName: String! + sourceLatitude: Float + sourceLongitude: Float + destinationPointUuid: String + destinationPointType: String! + destinationLocationName: String! + destinationLatitude: Float + destinationLongitude: Float + distanceKm: Float + travelTimeSeconds: Int + } + type Query { getTeamOrders(displayCurrency: String! = "USD"): [Order] getOrder(orderUuid: String!, displayCurrency: String! = "USD"): Order @@ -610,6 +652,62 @@ function mapQuotation( createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), changes: item.changes.map(mapQuotationChange), + routeStages: item.routeStages.map(stage => mapRouteStage(stage)), + } +} + +type RouteStageRecord = { + uuid: string + sequence: number + stageType: string + transportType: string | null + sourcePointUuid: string | null + sourcePointType: string + sourceLocationName: string + sourceLatitude: number | null + sourceLongitude: number | null + destinationPointUuid: string | null + destinationPointType: string + destinationLocationName: string + destinationLatitude: number | null + destinationLongitude: number | null + distanceKm: number | null + travelTimeSeconds: number | null +} + +function routeStageName(stageType: string): string { + if (stageType === 'FIRST_MILE') return 'First mile' + if (stageType === 'LAST_MILE') return 'Last mile' + return 'Main haul' +} + +function mapRouteStage( + stage: RouteStageRecord, + location?: { name: string | null; latitude: number | null; longitude: number | null }, +) { + return { + uuid: stage.uuid, + name: routeStageName(stage.stageType), + sequence: stage.sequence, + stageType: stage.stageType, + transportType: stage.transportType, + sourcePointUuid: stage.sourcePointUuid, + sourcePointType: stage.sourcePointType, + sourceLocationName: stage.sourceLocationName, + sourceLatitude: stage.sourceLatitude, + sourceLongitude: stage.sourceLongitude, + destinationPointUuid: stage.destinationPointUuid, + destinationPointType: stage.destinationPointType, + destinationLocationName: stage.destinationLocationName, + destinationLatitude: stage.destinationLatitude, + destinationLongitude: stage.destinationLongitude, + distanceKm: stage.distanceKm, + travelTimeSeconds: stage.travelTimeSeconds, + locationName: location?.name ?? stage.sourceLocationName, + locationLatitude: location?.latitude ?? stage.sourceLatitude, + locationLongitude: location?.longitude ?? stage.sourceLongitude, + selectedCompany: null, + trips: [], } } @@ -620,8 +718,7 @@ function mapLocalOrder( const displayAmount = display?.displayAmount ?? Number(item.clientAmount) const displayCurrency = display?.displayCurrency ?? item.clientCurrency const progress = orderLocationProgress(item.status) - const locationLatitude = interpolateCoordinate(item.sourceLatitude, item.destinationLatitude, progress) - const locationLongitude = interpolateCoordinate(item.sourceLongitude, item.destinationLongitude, progress) + const stageCount = item.routeStages.length return { uuid: item.uuid, quotationUuid: item.quotation?.uuid ?? null, @@ -646,38 +743,34 @@ function mapLocalOrder( totalAmount: Number(item.clientAmount), currency: item.clientCurrency, sourceCountryCode: item.sourceCountryCode, + sourceHubUuid: item.sourceHubUuid, sourceLocationUuid: item.sourceLocationUuid, sourceLocationName: item.sourceLocationName, sourceLatitude: item.sourceLatitude, sourceLongitude: item.sourceLongitude, destinationCountryCode: item.destinationCountryCode, + destinationHubUuid: item.destinationHubUuid, destinationLocationUuid: item.destinationLocationUuid, destinationLocationName: item.destinationLocationName, + destinationLatitude: item.destinationLatitude, + destinationLongitude: item.destinationLongitude, etaDays: item.etaDays, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), notes: item.notes ?? '', orderLines: [], - stages: [ - { - uuid: `${item.uuid}-route`, - name: item.name, - sequence: 1, - stageType: 'route', - transportType: item.quotation?.transportTypeCode ?? null, - sourceLocationName: item.sourceLocationName, - sourceLatitude: item.sourceLatitude, - sourceLongitude: item.sourceLongitude, - destinationLocationName: item.destinationLocationName, - destinationLatitude: item.destinationLatitude, - destinationLongitude: item.destinationLongitude, - locationName: orderLocationName(item, progress), - locationLatitude, - locationLongitude, - selectedCompany: null, - trips: [], - }, - ], + stages: item.routeStages.map((stage, index) => { + const stageProgress = Math.max(0, Math.min(1, progress * stageCount - index)) + return mapRouteStage(stage, { + name: stageProgress >= 1 + ? stage.destinationLocationName + : stageProgress <= 0 + ? stage.sourceLocationName + : orderLocationName(item, progress), + latitude: interpolateCoordinate(stage.sourceLatitude, stage.destinationLatitude, stageProgress), + longitude: interpolateCoordinate(stage.sourceLongitude, stage.destinationLongitude, stageProgress), + }) + }), } } @@ -797,6 +890,109 @@ function buildUpdateTariffData(input: UpdateTariffReferenceInput): Prisma.Tariff return data } +function buildRouteStageData(input: RouteStageInput): Prisma.QuotationRouteStageCreateWithoutQuotationInput { + return { + sequence: normalizeInteger(input.sequence) ?? 0, + stageType: requiredString(normalizeUpper(input.stageType), 'Route stage type'), + transportType: normalizeUpper(input.transportType), + sourcePointUuid: normalizeString(input.sourcePointUuid), + sourcePointType: requiredString(normalizeUpper(input.sourcePointType), 'Source point type'), + sourceLocationName: requiredString(input.sourceLocationName, 'Source location name'), + sourceLatitude: normalizeNumber(input.sourceLatitude), + sourceLongitude: normalizeNumber(input.sourceLongitude), + destinationPointUuid: normalizeString(input.destinationPointUuid), + destinationPointType: requiredString(normalizeUpper(input.destinationPointType), 'Destination point type'), + destinationLocationName: requiredString(input.destinationLocationName, 'Destination location name'), + destinationLatitude: normalizeNumber(input.destinationLatitude), + destinationLongitude: normalizeNumber(input.destinationLongitude), + distanceKm: normalizeNumber(input.distanceKm), + travelTimeSeconds: normalizeInteger(input.travelTimeSeconds), + } +} + +function defaultRouteStages(input: Record): Prisma.QuotationRouteStageCreateWithoutQuotationInput[] { + const sourceLocationName = normalizeString(input.sourceLocationName) ?? 'Origin' + const destinationLocationName = normalizeString(input.destinationLocationName) ?? 'Destination' + const sourceHubUuid = normalizeString(input.sourceHubUuid) + const destinationHubUuid = normalizeString(input.destinationHubUuid) + const sourceHubName = sourceHubUuid ?? 'Origin hub' + const destinationHubName = destinationHubUuid ?? 'Destination hub' + return [ + { + sequence: 1, + stageType: 'FIRST_MILE', + transportType: 'AUTO', + sourcePointUuid: normalizeString(input.sourceLocationUuid), + sourcePointType: 'ADDRESS', + sourceLocationName, + sourceLatitude: normalizeNumber(input.sourceLatitude), + sourceLongitude: normalizeNumber(input.sourceLongitude), + destinationPointUuid: sourceHubUuid, + destinationPointType: 'HUB', + destinationLocationName: sourceHubName, + }, + { + sequence: 2, + stageType: 'MAIN_HAUL', + transportType: normalizeUpper(input.transportTypeCode) ?? 'MULTIMODAL', + sourcePointUuid: sourceHubUuid, + sourcePointType: 'HUB', + sourceLocationName: sourceHubName, + destinationPointUuid: destinationHubUuid, + destinationPointType: 'HUB', + destinationLocationName: destinationHubName, + distanceKm: normalizeNumber(input.routeDistanceKm), + }, + { + sequence: 3, + stageType: 'LAST_MILE', + transportType: 'AUTO', + sourcePointUuid: destinationHubUuid, + sourcePointType: 'HUB', + sourceLocationName: destinationHubName, + destinationPointUuid: normalizeString(input.destinationLocationUuid), + destinationPointType: 'ADDRESS', + destinationLocationName, + destinationLatitude: normalizeNumber(input.destinationLatitude), + destinationLongitude: normalizeNumber(input.destinationLongitude), + }, + ] +} + +function buildRouteStages(input: Record): Prisma.QuotationRouteStageCreateWithoutQuotationInput[] { + if (!hasOwn(input, 'routeStages') || input.routeStages == null) { + return defaultRouteStages(input) + } + if (!Array.isArray(input.routeStages) || input.routeStages.length === 0) { + throw new GraphQLError('Route must contain at least one stage') + } + const stages = input.routeStages + .map(item => buildRouteStageData(item as RouteStageInput)) + .sort((left, right) => left.sequence - right.sequence) + stages.forEach((stage, index) => { + if (stage.sequence !== index + 1) { + throw new GraphQLError('Route stage sequence must start at 1 and be continuous') + } + }) + return stages +} + +const quotationRouteKeys = new Set([ + 'transportTypeCode', + 'sourceHubUuid', + 'sourceLocationUuid', + 'sourceLocationName', + 'sourceLatitude', + 'sourceLongitude', + 'destinationHubUuid', + 'destinationLocationUuid', + 'destinationLocationName', + 'destinationLatitude', + 'destinationLongitude', + 'routeDistanceKm', + 'routeStages', +]) + function buildCreateQuotationData(input: CreateQuotationInput, ctx: { teamUuid: string; userId: string }): Prisma.QuotationCreateInput { return { teamUuid: ctx.teamUuid, @@ -826,6 +1022,9 @@ function buildCreateQuotationData(input: CreateQuotationInput, ctx: { teamUuid: unitsCount: normalizeInteger(input.unitsCount), routeDistanceKm: normalizeInteger(input.routeDistanceKm), notes: normalizeString(input.notes), + routeStages: { + create: buildRouteStages(input), + }, } } @@ -1370,11 +1569,26 @@ export const teamResolvers = { } const teamAccess = { teamUuid: current.teamUuid, userId: access.userId } const actor = { userId: access.userId, label: actorLabel(teamAccess) } + const routeChanged = Object.keys(args.input).some(key => quotationRouteKeys.has(key)) + const routeInput: Record = { ...current, ...args.input } + if (!hasOwn(args.input, 'routeStages')) { + delete routeInput.routeStages + } await prisma.quotation.update({ where: { id: current.id, }, - data: buildUpdateQuotationData(args.input), + data: { + ...buildUpdateQuotationData(args.input), + ...(routeChanged + ? { + routeStages: { + deleteMany: {}, + create: buildRouteStages(routeInput), + }, + } + : {}), + }, }) await addQuotationChange(current.id, actor, 'quotation', 'Quotation updated', { @@ -1498,18 +1712,40 @@ export const teamResolvers = { exchangeRateLockedAt: quotation.exchangeRateLockedAt, pricingCountryCode: quotation.pricingCountryCode, pricingKycProfileUuid: quotation.pricingKycProfileUuid, + sourceHubUuid: quotation.sourceHubUuid, sourceLocationUuid: quotation.sourceLocationUuid, sourceLocationName: quotation.sourceLocationName, sourceCountryCode: quotation.sourceCountryCode, sourceLatitude: quotation.sourceLatitude, sourceLongitude: quotation.sourceLongitude, destinationLocationUuid: quotation.destinationLocationUuid, + destinationHubUuid: quotation.destinationHubUuid, destinationLocationName: quotation.destinationLocationName, destinationCountryCode: quotation.destinationCountryCode, destinationLatitude: quotation.destinationLatitude, destinationLongitude: quotation.destinationLongitude, etaDays: quotation.etaDays, notes: quotation.notes, + routeStages: { + create: quotation.routeStages.map(stage => ({ + quotationRouteStageUuid: stage.uuid, + sequence: stage.sequence, + stageType: stage.stageType, + transportType: stage.transportType, + sourcePointUuid: stage.sourcePointUuid, + sourcePointType: stage.sourcePointType, + sourceLocationName: stage.sourceLocationName, + sourceLatitude: stage.sourceLatitude, + sourceLongitude: stage.sourceLongitude, + destinationPointUuid: stage.destinationPointUuid, + destinationPointType: stage.destinationPointType, + destinationLocationName: stage.destinationLocationName, + destinationLatitude: stage.destinationLatitude, + destinationLongitude: stage.destinationLongitude, + distanceKm: stage.distanceKm, + travelTimeSeconds: stage.travelTimeSeconds, + })), + }, }, include: orderInclude, })