Compare commits

...
7 Commits
Author SHA1 Message Date
Ruslan Bakiev 1811820fb7 Refresh demo route snapshots
Build and deploy Docker image / build (push) Successful in 43s
2026-08-24 21:50:38 +07:00
Ruslan Bakiev f1ecd83e9c Persist multimodal order routes
Build and deploy Docker image / build (push) Successful in 1m2s
2026-08-24 21:45:05 +07:00
Ruslan Bakiev c3464e5f3f Default transaction display currency
Build and deploy Docker image / build (push) Successful in 31s
2026-08-24 21:21:33 +07:00
Ruslan Bakiev e709c332a6 Expose live display currency for transactions
Build and deploy Docker image / build (push) Successful in 1m25s
2026-08-24 21:16:42 +07:00
Ruslan Bakiev 952253dceb Keep converted quotations immutable
Build and deploy Docker image / build (push) Successful in 39s
2026-08-24 21:14:08 +07:00
Ruslan Bakiev d34db71b51 Fix transaction currency on quotations
Build and deploy Docker image / build (push) Successful in 51s
2026-08-24 21:10:09 +07:00
Ruslan Bakiev 2f1aa30d6b Seed pricing context for demo orders
Build and deploy Docker image / build (push) Successful in 1m38s
2026-08-24 20:53:08 +07:00
6 changed files with 758 additions and 66 deletions
@@ -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;
+62
View File
@@ -95,12 +95,41 @@ model Quotation {
orders Order[] orders Order[]
changes QuotationChange[] changes QuotationChange[]
routeStages QuotationRouteStage[]
@@index([teamUuid, status, createdAt]) @@index([teamUuid, status, createdAt])
@@index([teamUuid, sourceCountryCode, destinationCountryCode]) @@index([teamUuid, sourceCountryCode, destinationCountryCode])
@@map("orders_quotation") @@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 { model QuotationChange {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uuid String @unique @default(uuid()) uuid String @unique @default(uuid())
@@ -138,6 +167,7 @@ model Order {
exchangeRateLockedAt DateTime? @map("exchange_rate_locked_at") exchangeRateLockedAt DateTime? @map("exchange_rate_locked_at")
pricingCountryCode String? @map("pricing_country_code") @db.VarChar(2) pricingCountryCode String? @map("pricing_country_code") @db.VarChar(2)
pricingKycProfileUuid String? @map("pricing_kyc_profile_uuid") @db.VarChar(100) 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) sourceLocationUuid String? @map("source_location_uuid") @db.VarChar(100)
sourceLocationName String? @map("source_location_name") @db.VarChar(255) sourceLocationName String? @map("source_location_name") @db.VarChar(255)
sourceCountryCode String? @map("source_country_code") @db.VarChar(10) sourceCountryCode String? @map("source_country_code") @db.VarChar(10)
@@ -146,6 +176,7 @@ model Order {
destinationLocationUuid String? @map("destination_location_uuid") @db.VarChar(100) destinationLocationUuid String? @map("destination_location_uuid") @db.VarChar(100)
destinationLocationName String? @map("destination_location_name") @db.VarChar(255) destinationLocationName String? @map("destination_location_name") @db.VarChar(255)
destinationCountryCode String? @map("destination_country_code") @db.VarChar(10) destinationCountryCode String? @map("destination_country_code") @db.VarChar(10)
destinationHubUuid String? @map("destination_hub_uuid") @db.VarChar(100)
destinationLatitude Float? @map("destination_latitude") destinationLatitude Float? @map("destination_latitude")
destinationLongitude Float? @map("destination_longitude") destinationLongitude Float? @map("destination_longitude")
etaDays Int? @map("eta_days") etaDays Int? @map("eta_days")
@@ -153,6 +184,37 @@ model Order {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
routeStages OrderRouteStage[]
@@index([teamUuid, createdAt]) @@index([teamUuid, createdAt])
@@map("orders_order") @@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")
}
+138 -6
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { prisma } from './db.js' import { prisma } from './db.js'
import { lockCurrencyPrice, resolveKycPricingContext } from './pricing-context.js' import { ensureDemoKycPricingContext, lockCurrencyPrice } from './pricing-context.js'
interface DemoWorkspaceAccess { interface DemoWorkspaceAccess {
teamUuid: string teamUuid: string
@@ -24,6 +24,26 @@ interface DemoRoute {
grossWeightKg: number grossWeightKg: number
volumeCbm: number volumeCbm: number
routeDistanceKm: 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[] = [ const demoRoutes: DemoRoute[] = [
@@ -43,6 +63,14 @@ const demoRoutes: DemoRoute[] = [
grossWeightKg: 18400, grossWeightKg: 18400,
volumeCbm: 68, volumeCbm: 68,
routeDistanceKm: 8240, 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', title: 'Casablanca → Moscow',
@@ -60,6 +88,14 @@ const demoRoutes: DemoRoute[] = [
grossWeightKg: 12600, grossWeightKg: 12600,
volumeCbm: 52, volumeCbm: 52,
routeDistanceKm: 5060, 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', title: 'Nairobi → Dubai',
@@ -77,6 +113,13 @@ const demoRoutes: DemoRoute[] = [
grossWeightKg: 4200, grossWeightKg: 4200,
volumeCbm: 18, volumeCbm: 18,
routeDistanceKm: 3560, 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', title: 'Johannesburg → Warsaw',
@@ -94,10 +137,19 @@ const demoRoutes: DemoRoute[] = [
grossWeightKg: 7600, grossWeightKg: 7600,
volumeCbm: 31, volumeCbm: 31,
routeDistanceKm: 8690, 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 },
],
}, },
] ]
const demoNote = JSON.stringify({ seed: 'empty-team-workspace' }) const demoNote = JSON.stringify({ seed: 'empty-team-workspace' })
const demoPricingCountryCode = process.env.DEMO_PRICING_COUNTRY_CODE ?? 'RU'
const demoClientCurrency = process.env.DEMO_CLIENT_CURRENCY ?? 'RUB'
function stableUuid(teamUuid: string, entity: string, index: number): string { function stableUuid(teamUuid: string, entity: string, index: number): string {
const hex = createHash('sha256') const hex = createHash('sha256')
@@ -112,7 +164,16 @@ function stableUuid(teamUuid: string, entity: string, index: number): string {
} }
export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Promise<void> { export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Promise<void> {
const [quotationCount, orderCount, demoQuotationPricingCount, demoOrderPricingCount] = await Promise.all([ const [
quotationCount,
orderCount,
demoQuotationPricingCount,
demoOrderPricingCount,
demoQuotationCount,
demoOrderCount,
demoQuotationRouteStageCount,
demoOrderRouteStageCount,
] = await Promise.all([
prisma.quotation.count({ where: { teamUuid: access.teamUuid } }), prisma.quotation.count({ where: { teamUuid: access.teamUuid } }),
prisma.order.count({ where: { teamUuid: access.teamUuid } }), prisma.order.count({ where: { teamUuid: access.teamUuid } }),
prisma.quotation.count({ prisma.quotation.count({
@@ -121,12 +182,38 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
prisma.order.count({ prisma.order.count({
where: { teamUuid: access.teamUuid, notes: demoNote, sourceAmount: null }, where: { teamUuid: access.teamUuid, notes: demoNote, sourceAmount: null },
}), }),
prisma.quotation.count({
where: { teamUuid: access.teamUuid, notes: demoNote },
}),
prisma.order.count({
where: { teamUuid: access.teamUuid, notes: demoNote },
}),
prisma.quotationRouteStage.count({
where: { quotation: { teamUuid: access.teamUuid, notes: demoNote } },
}),
prisma.orderRouteStage.count({
where: { order: { teamUuid: access.teamUuid, notes: demoNote } },
}),
]) ])
const needsCalculations = quotationCount === 0 const needsCalculations = quotationCount === 0
const needsOrders = orderCount === 0 const needsOrders = orderCount === 0
const needsPricingBackfill = demoQuotationPricingCount > 0 || demoOrderPricingCount > 0 const needsPricingBackfill = demoQuotationPricingCount > 0 || demoOrderPricingCount > 0
const kyc = needsCalculations || needsOrders || needsPricingBackfill const expectedQuotationRouteStageCount = demoRoutes.reduce(
? await resolveKycPricingContext(access.userId) (total, route) => total + route.stages.length,
0,
)
const expectedOrderRouteStageCount = demoRoutes
.slice(0, 2)
.reduce((total, route) => total + route.stages.length, 0)
const needsRouteBackfill =
(demoQuotationCount > 0 && demoQuotationRouteStageCount < expectedQuotationRouteStageCount) ||
(demoOrderCount > 0 && demoOrderRouteStageCount < expectedOrderRouteStageCount)
const kyc = needsCalculations || needsOrders || needsPricingBackfill || needsRouteBackfill
? await ensureDemoKycPricingContext({
teamUuid: access.teamUuid,
userId: access.userId,
countryCode: demoPricingCountryCode,
})
: null : null
for (const [index, route] of demoRoutes.entries()) { for (const [index, route] of demoRoutes.entries()) {
@@ -143,6 +230,8 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
status: 'active', status: 'active',
sourceCountryCode: route.sourceCountryCode, sourceCountryCode: route.sourceCountryCode,
destinationCountryCode: route.destinationCountryCode, destinationCountryCode: route.destinationCountryCode,
sourceHubUuid: route.sourceHubUuid,
destinationHubUuid: route.destinationHubUuid,
sourceAmount: new Prisma.Decimal(route.amountUsd), sourceAmount: new Prisma.Decimal(route.amountUsd),
minSourcePrice: new Prisma.Decimal(route.amountUsd), minSourcePrice: new Prisma.Decimal(route.amountUsd),
etaDays: route.etaDays, etaDays: route.etaDays,
@@ -161,6 +250,8 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
tareTypeCode: null, tareTypeCode: null,
sourceCountryCode: route.sourceCountryCode, sourceCountryCode: route.sourceCountryCode,
destinationCountryCode: route.destinationCountryCode, destinationCountryCode: route.destinationCountryCode,
sourceHubUuid: route.sourceHubUuid,
destinationHubUuid: route.destinationHubUuid,
sourceAmount: new Prisma.Decimal(route.amountUsd), sourceAmount: new Prisma.Decimal(route.amountUsd),
minSourcePrice: new Prisma.Decimal(route.amountUsd), minSourcePrice: new Prisma.Decimal(route.amountUsd),
etaDays: route.etaDays, etaDays: route.etaDays,
@@ -170,11 +261,12 @@ 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') if (kyc === null) throw new Error('KYC pricing context was not resolved for demo data')
const price = await lockCurrencyPrice({ const price = await lockCurrencyPrice({
sourceAmount: route.amountUsd, sourceAmount: route.amountUsd,
sourceCurrency: 'USD', sourceCurrency: 'USD',
clientCurrency: demoClientCurrency,
pricingCountryCode: kyc.countryCode, pricingCountryCode: kyc.countryCode,
}) })
const pricingEffectiveDate = new Date(`${price.rateEffectiveDate}T00:00:00.000Z`) const pricingEffectiveDate = new Date(`${price.rateEffectiveDate}T00:00:00.000Z`)
@@ -194,10 +286,12 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
incotermsCode: 'DAP', incotermsCode: 'DAP',
transportTypeCode: route.transportTypeCode, transportTypeCode: route.transportTypeCode,
sourceCountryCode: route.sourceCountryCode, sourceCountryCode: route.sourceCountryCode,
sourceHubUuid: route.sourceHubUuid,
sourceLocationName: route.sourceLocationName, sourceLocationName: route.sourceLocationName,
sourceLatitude: route.sourceLatitude, sourceLatitude: route.sourceLatitude,
sourceLongitude: route.sourceLongitude, sourceLongitude: route.sourceLongitude,
destinationCountryCode: route.destinationCountryCode, destinationCountryCode: route.destinationCountryCode,
destinationHubUuid: route.destinationHubUuid,
destinationLocationName: route.destinationLocationName, destinationLocationName: route.destinationLocationName,
destinationLatitude: route.destinationLatitude, destinationLatitude: route.destinationLatitude,
destinationLongitude: route.destinationLongitude, destinationLongitude: route.destinationLongitude,
@@ -222,6 +316,13 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
pricingKycProfileUuid: kyc.profileUuid, pricingKycProfileUuid: kyc.profileUuid,
etaDays: route.etaDays, etaDays: route.etaDays,
notes: demoNote, notes: demoNote,
routeStages: {
create: route.stages.map((stage, stageIndex) => ({
uuid: stableUuid(access.teamUuid, `demo-quotation-${index}-stage`, stageIndex),
sequence: stageIndex + 1,
...stage,
})),
},
}, },
update: { update: {
teamUuid: access.teamUuid, teamUuid: access.teamUuid,
@@ -241,10 +342,20 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
pricingKycProfileUuid: kyc.profileUuid, pricingKycProfileUuid: kyc.profileUuid,
etaDays: route.etaDays, etaDays: route.etaDays,
notes: demoNote, 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({ await prisma.order.upsert({
where: { uuid: stableUuid(access.teamUuid, 'demo-order', index) }, where: { uuid: stableUuid(access.teamUuid, 'demo-order', index) },
create: { create: {
@@ -266,16 +377,26 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
exchangeRateLockedAt: pricingLockedAt, exchangeRateLockedAt: pricingLockedAt,
pricingCountryCode: price.pricingCountryCode, pricingCountryCode: price.pricingCountryCode,
pricingKycProfileUuid: kyc.profileUuid, pricingKycProfileUuid: kyc.profileUuid,
sourceHubUuid: route.sourceHubUuid,
sourceLocationName: route.sourceLocationName, sourceLocationName: route.sourceLocationName,
sourceCountryCode: route.sourceCountryCode, sourceCountryCode: route.sourceCountryCode,
sourceLatitude: route.sourceLatitude, sourceLatitude: route.sourceLatitude,
sourceLongitude: route.sourceLongitude, sourceLongitude: route.sourceLongitude,
destinationLocationName: route.destinationLocationName, destinationLocationName: route.destinationLocationName,
destinationCountryCode: route.destinationCountryCode, destinationCountryCode: route.destinationCountryCode,
destinationHubUuid: route.destinationHubUuid,
destinationLatitude: route.destinationLatitude, destinationLatitude: route.destinationLatitude,
destinationLongitude: route.destinationLongitude, destinationLongitude: route.destinationLongitude,
etaDays: route.etaDays, etaDays: route.etaDays,
notes: demoNote, 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: { update: {
teamUuid: access.teamUuid, teamUuid: access.teamUuid,
@@ -294,6 +415,17 @@ export async function ensureDemoWorkspaceData(access: DemoWorkspaceAccess): Prom
pricingCountryCode: price.pricingCountryCode, pricingCountryCode: price.pricingCountryCode,
pricingKycProfileUuid: kyc.profileUuid, pricingKycProfileUuid: kyc.profileUuid,
notes: demoNote, 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,
})),
},
}, },
}) })
} }
+72 -10
View File
@@ -4,7 +4,7 @@ type GraphqlEnvelope<T> = {
} }
export type KycPricingContext = { export type KycPricingContext = {
userId: string teamUuid: string
profileUuid: string profileUuid: string
countryCode: string countryCode: string
resolvedAt: string resolvedAt: string
@@ -25,6 +25,18 @@ export type LockedCurrencyPrice = {
pricingCountryCode: string pricingCountryCode: string
} }
export type DisplayCurrencyConversion = {
sourceAmount: number
sourceCurrency: string
displayAmount: number
displayCurrency: string
exchangeRate: number
rateSnapshotUuid: string | null
rateEffectiveDate: string
rateQuotedAt: string
provider: string
}
const kycM2mEndpoint = const kycM2mEndpoint =
process.env.KYC_M2M_GRAPHQL_ENDPOINT ?? 'https://kyc.optovia.ru/graphql/m2m' process.env.KYC_M2M_GRAPHQL_ENDPOINT ?? 'https://kyc.optovia.ru/graphql/m2m'
const exchangeM2mEndpoint = const exchangeM2mEndpoint =
@@ -56,31 +68,54 @@ async function requestGraphql<T>(
} }
export async function resolveKycPricingContext( export async function resolveKycPricingContext(
userId: string, teamUuid: string,
): Promise<KycPricingContext> { ): Promise<KycPricingContext> {
const data = await requestGraphql<{ const data = await requestGraphql<{
pricingContextForUser: KycPricingContext | null pricingContextForTeam: KycPricingContext | null
}>( }>(
kycM2mEndpoint, kycM2mEndpoint,
`query OrdersKycPricingContext($userId: String!) { `query OrdersKycPricingContext($teamUuid: String!) {
pricingContextForUser(userId: $userId) { pricingContextForTeam(teamUuid: $teamUuid) {
userId teamUuid
profileUuid profileUuid
countryCode countryCode
resolvedAt resolvedAt
} }
}`, }`,
{ userId }, { teamUuid },
) )
if (data.pricingContextForUser === null) { if (data.pricingContextForTeam === null) {
throw new Error(`Active KYC pricing context is required for user ${userId}`) throw new Error(`Active KYC pricing context is required for team ${teamUuid}`)
} }
return data.pricingContextForUser return data.pricingContextForTeam
}
export async function ensureDemoKycPricingContext(input: {
teamUuid: string
userId: string
countryCode: string
}): Promise<KycPricingContext> {
const data = await requestGraphql<{
ensureDemoPricingContextForTeam: KycPricingContext
}>(
kycM2mEndpoint,
`mutation OrdersEnsureDemoKycPricingContext($teamUuid: String!, $userId: String!, $countryCode: String!) {
ensureDemoPricingContextForTeam(teamUuid: $teamUuid, userId: $userId, countryCode: $countryCode) {
teamUuid
profileUuid
countryCode
resolvedAt
}
}`,
input,
)
return data.ensureDemoPricingContextForTeam
} }
export async function lockCurrencyPrice(input: { export async function lockCurrencyPrice(input: {
sourceAmount: number sourceAmount: number
sourceCurrency: string sourceCurrency: string
clientCurrency: string
pricingCountryCode: string pricingCountryCode: string
rateDate?: string rateDate?: string
}): Promise<LockedCurrencyPrice> { }): Promise<LockedCurrencyPrice> {
@@ -108,3 +143,30 @@ export async function lockCurrencyPrice(input: {
) )
return data.lockCurrencyPrice return data.lockCurrencyPrice
} }
export async function convertCurrencyForDisplay(input: {
amount: number
sourceCurrency: string
displayCurrency: string
}): Promise<DisplayCurrencyConversion> {
const data = await requestGraphql<{
convertCurrencyForDisplay: DisplayCurrencyConversion
}>(
exchangeM2mEndpoint,
`query OrdersConvertCurrencyForDisplay($input: ConvertCurrencyForDisplayInput!) {
convertCurrencyForDisplay(input: $input) {
sourceAmount
sourceCurrency
displayAmount
displayCurrency
exchangeRate
rateSnapshotUuid
rateEffectiveDate
rateQuotedAt
provider
}
}`,
{ input },
)
return data.convertCurrencyForDisplay
}
+344 -49
View File
@@ -11,13 +11,20 @@ import { prisma } from '../db.js'
import { ensureDemoWorkspaceData } from '../demo-workspace.js' import { ensureDemoWorkspaceData } from '../demo-workspace.js'
import { import {
lockCurrencyPrice, lockCurrencyPrice,
convertCurrencyForDisplay,
resolveKycPricingContext, resolveKycPricingContext,
type DisplayCurrencyConversion,
type KycPricingContext, type KycPricingContext,
type LockedCurrencyPrice, type LockedCurrencyPrice,
} from '../pricing-context.js' } from '../pricing-context.js'
const quotationInclude = { const quotationInclude = {
selectedTariff: true, selectedTariff: true,
routeStages: {
orderBy: {
sequence: 'asc' as const,
},
},
changes: { changes: {
orderBy: { orderBy: {
createdAt: 'desc' as const, createdAt: 'desc' as const,
@@ -27,6 +34,11 @@ const quotationInclude = {
const orderInclude = { const orderInclude = {
quotation: true, quotation: true,
routeStages: {
orderBy: {
sequence: 'asc' as const,
},
},
} satisfies Prisma.OrderInclude } satisfies Prisma.OrderInclude
type QuotationWithRelations = Prisma.QuotationGetPayload<{ include: typeof quotationInclude }> type QuotationWithRelations = Prisma.QuotationGetPayload<{ include: typeof quotationInclude }>
@@ -44,6 +56,7 @@ type CreateTariffReferenceInput = Record<string, unknown>
type UpdateTariffReferenceInput = Record<string, unknown> type UpdateTariffReferenceInput = Record<string, unknown>
type CreateQuotationInput = Record<string, unknown> type CreateQuotationInput = Record<string, unknown>
type UpdateQuotationInput = Record<string, unknown> type UpdateQuotationInput = Record<string, unknown>
type RouteStageInput = Record<string, unknown>
export const teamTypeDefs = `#graphql export const teamTypeDefs = `#graphql
type Company { type Company {
@@ -76,12 +89,18 @@ export const teamTypeDefs = `#graphql
sequence: Int sequence: Int
stageType: String stageType: String
transportType: String transportType: String
sourcePointUuid: String
sourcePointType: String
sourceLocationName: String sourceLocationName: String
sourceLatitude: Float sourceLatitude: Float
sourceLongitude: Float sourceLongitude: Float
destinationPointUuid: String
destinationPointType: String
destinationLocationName: String destinationLocationName: String
destinationLatitude: Float destinationLatitude: Float
destinationLongitude: Float destinationLongitude: Float
distanceKm: Float
travelTimeSeconds: Int
locationName: String locationName: String
locationLatitude: Float locationLatitude: Float
locationLongitude: Float locationLongitude: Float
@@ -112,6 +131,8 @@ export const teamTypeDefs = `#graphql
sourceCurrency: String sourceCurrency: String
clientAmount: Float! clientAmount: Float!
clientCurrency: String! clientCurrency: String!
displayAmount: Float!
displayCurrency: String!
exchangeRate: Float exchangeRate: Float
exchangeRateSnapshotUuid: String exchangeRateSnapshotUuid: String
exchangeRateProvider: String exchangeRateProvider: String
@@ -123,13 +144,17 @@ export const teamTypeDefs = `#graphql
totalAmount: Float @deprecated(reason: "Use clientAmount") totalAmount: Float @deprecated(reason: "Use clientAmount")
currency: String @deprecated(reason: "Use clientCurrency") currency: String @deprecated(reason: "Use clientCurrency")
sourceCountryCode: String sourceCountryCode: String
sourceHubUuid: String
sourceLocationUuid: String sourceLocationUuid: String
sourceLocationName: String sourceLocationName: String
sourceLatitude: Float sourceLatitude: Float
sourceLongitude: Float sourceLongitude: Float
destinationCountryCode: String destinationCountryCode: String
destinationHubUuid: String
destinationLocationUuid: String destinationLocationUuid: String
destinationLocationName: String destinationLocationName: String
destinationLatitude: Float
destinationLongitude: Float
etaDays: Int etaDays: Int
createdAt: String createdAt: String
updatedAt: String updatedAt: String
@@ -216,6 +241,8 @@ export const teamTypeDefs = `#graphql
sourceCurrency: String sourceCurrency: String
clientAmount: Float! clientAmount: Float!
clientCurrency: String! clientCurrency: String!
displayAmount: Float!
displayCurrency: String!
exchangeRate: Float exchangeRate: Float
exchangeRateSnapshotUuid: String exchangeRateSnapshotUuid: String
exchangeRateProvider: String exchangeRateProvider: String
@@ -231,6 +258,7 @@ export const teamTypeDefs = `#graphql
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
changes: [QuotationChange!]! changes: [QuotationChange!]!
routeStages: [Stage!]!
} }
type QuotationTariffMatch { type QuotationTariffMatch {
@@ -306,6 +334,7 @@ export const teamTypeDefs = `#graphql
} }
input CreateQuotationInput { input CreateQuotationInput {
clientCurrency: String
title: String title: String
status: String status: String
operationCode: String operationCode: String
@@ -329,10 +358,12 @@ export const teamTypeDefs = `#graphql
volumeCbm: Float volumeCbm: Float
unitsCount: Int unitsCount: Int
routeDistanceKm: Int routeDistanceKm: Int
routeStages: [RouteStageInput!]
notes: String notes: String
} }
input UpdateQuotationInput { input UpdateQuotationInput {
clientCurrency: String
title: String title: String
status: String status: String
operationCode: String operationCode: String
@@ -356,14 +387,33 @@ export const teamTypeDefs = `#graphql
volumeCbm: Float volumeCbm: Float
unitsCount: Int unitsCount: Int
routeDistanceKm: Int routeDistanceKm: Int
routeStages: [RouteStageInput!]
notes: String 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 { type Query {
getTeamOrders: [Order] getTeamOrders(displayCurrency: String! = "USD"): [Order]
getOrder(orderUuid: String!): Order getOrder(orderUuid: String!, displayCurrency: String! = "USD"): Order
quotations(status: String): [Quotation!]! quotations(status: String, displayCurrency: String! = "USD"): [Quotation!]!
quotation(quotationUuid: String!): Quotation quotation(quotationUuid: String!, displayCurrency: String! = "USD"): Quotation
tariffReferences(status: String): [TariffReference!]! tariffReferences(status: String): [TariffReference!]!
tariffReference(tariffReferenceUuid: String!): TariffReference tariffReference(tariffReferenceUuid: String!): TariffReference
quotationTariffMatches(quotationUuid: String!): [QuotationTariffMatch!]! quotationTariffMatches(quotationUuid: String!): [QuotationTariffMatch!]!
@@ -545,7 +595,12 @@ function mapQuotationChange(item: PrismaQuotationChange) {
} }
} }
function mapQuotation(item: QuotationWithRelations) { function mapQuotation(
item: QuotationWithRelations,
display?: DisplayCurrencyConversion,
) {
const displayAmount = display?.displayAmount ?? Number(item.clientAmount)
const displayCurrency = display?.displayCurrency ?? item.clientCurrency
return { return {
uuid: item.uuid, uuid: item.uuid,
teamUuid: item.teamUuid, teamUuid: item.teamUuid,
@@ -580,6 +635,8 @@ function mapQuotation(item: QuotationWithRelations) {
sourceCurrency: item.sourceCurrency, sourceCurrency: item.sourceCurrency,
clientAmount: Number(item.clientAmount), clientAmount: Number(item.clientAmount),
clientCurrency: item.clientCurrency, clientCurrency: item.clientCurrency,
displayAmount,
displayCurrency,
exchangeRate: numberFromDecimal(item.exchangeRate), exchangeRate: numberFromDecimal(item.exchangeRate),
exchangeRateSnapshotUuid: item.exchangeRateSnapshotUuid, exchangeRateSnapshotUuid: item.exchangeRateSnapshotUuid,
exchangeRateProvider: item.exchangeRateProvider, exchangeRateProvider: item.exchangeRateProvider,
@@ -595,13 +652,73 @@ function mapQuotation(item: QuotationWithRelations) {
createdAt: item.createdAt.toISOString(), createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(), updatedAt: item.updatedAt.toISOString(),
changes: item.changes.map(mapQuotationChange), changes: item.changes.map(mapQuotationChange),
routeStages: item.routeStages.map(stage => mapRouteStage(stage)),
} }
} }
function mapLocalOrder(item: OrderWithRelations) { 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: [],
}
}
function mapLocalOrder(
item: OrderWithRelations,
display?: DisplayCurrencyConversion,
) {
const displayAmount = display?.displayAmount ?? Number(item.clientAmount)
const displayCurrency = display?.displayCurrency ?? item.clientCurrency
const progress = orderLocationProgress(item.status) const progress = orderLocationProgress(item.status)
const locationLatitude = interpolateCoordinate(item.sourceLatitude, item.destinationLatitude, progress) const stageCount = item.routeStages.length
const locationLongitude = interpolateCoordinate(item.sourceLongitude, item.destinationLongitude, progress)
return { return {
uuid: item.uuid, uuid: item.uuid,
quotationUuid: item.quotation?.uuid ?? null, quotationUuid: item.quotation?.uuid ?? null,
@@ -613,6 +730,8 @@ function mapLocalOrder(item: OrderWithRelations) {
sourceCurrency: item.sourceCurrency, sourceCurrency: item.sourceCurrency,
clientAmount: Number(item.clientAmount), clientAmount: Number(item.clientAmount),
clientCurrency: item.clientCurrency, clientCurrency: item.clientCurrency,
displayAmount,
displayCurrency,
exchangeRate: numberFromDecimal(item.exchangeRate), exchangeRate: numberFromDecimal(item.exchangeRate),
exchangeRateSnapshotUuid: item.exchangeRateSnapshotUuid, exchangeRateSnapshotUuid: item.exchangeRateSnapshotUuid,
exchangeRateProvider: item.exchangeRateProvider, exchangeRateProvider: item.exchangeRateProvider,
@@ -624,38 +743,34 @@ function mapLocalOrder(item: OrderWithRelations) {
totalAmount: Number(item.clientAmount), totalAmount: Number(item.clientAmount),
currency: item.clientCurrency, currency: item.clientCurrency,
sourceCountryCode: item.sourceCountryCode, sourceCountryCode: item.sourceCountryCode,
sourceHubUuid: item.sourceHubUuid,
sourceLocationUuid: item.sourceLocationUuid, sourceLocationUuid: item.sourceLocationUuid,
sourceLocationName: item.sourceLocationName, sourceLocationName: item.sourceLocationName,
sourceLatitude: item.sourceLatitude, sourceLatitude: item.sourceLatitude,
sourceLongitude: item.sourceLongitude, sourceLongitude: item.sourceLongitude,
destinationCountryCode: item.destinationCountryCode, destinationCountryCode: item.destinationCountryCode,
destinationHubUuid: item.destinationHubUuid,
destinationLocationUuid: item.destinationLocationUuid, destinationLocationUuid: item.destinationLocationUuid,
destinationLocationName: item.destinationLocationName, destinationLocationName: item.destinationLocationName,
destinationLatitude: item.destinationLatitude,
destinationLongitude: item.destinationLongitude,
etaDays: item.etaDays, etaDays: item.etaDays,
createdAt: item.createdAt.toISOString(), createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(), updatedAt: item.updatedAt.toISOString(),
notes: item.notes ?? '', notes: item.notes ?? '',
orderLines: [], orderLines: [],
stages: [ stages: item.routeStages.map((stage, index) => {
{ const stageProgress = Math.max(0, Math.min(1, progress * stageCount - index))
uuid: `${item.uuid}-route`, return mapRouteStage(stage, {
name: item.name, name: stageProgress >= 1
sequence: 1, ? stage.destinationLocationName
stageType: 'route', : stageProgress <= 0
transportType: item.quotation?.transportTypeCode ?? null, ? stage.sourceLocationName
sourceLocationName: item.sourceLocationName, : orderLocationName(item, progress),
sourceLatitude: item.sourceLatitude, latitude: interpolateCoordinate(stage.sourceLatitude, stage.destinationLatitude, stageProgress),
sourceLongitude: item.sourceLongitude, longitude: interpolateCoordinate(stage.sourceLongitude, stage.destinationLongitude, stageProgress),
destinationLocationName: item.destinationLocationName, })
destinationLatitude: item.destinationLatitude, }),
destinationLongitude: item.destinationLongitude,
locationName: orderLocationName(item, progress),
locationLatitude,
locationLongitude,
selectedCompany: null,
trips: [],
},
],
} }
} }
@@ -775,10 +890,114 @@ function buildUpdateTariffData(input: UpdateTariffReferenceInput): Prisma.Tariff
return data 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<string, unknown>): 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<string, unknown>): 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 { function buildCreateQuotationData(input: CreateQuotationInput, ctx: { teamUuid: string; userId: string }): Prisma.QuotationCreateInput {
return { return {
teamUuid: ctx.teamUuid, teamUuid: ctx.teamUuid,
createdByUserId: ctx.userId, createdByUserId: ctx.userId,
clientCurrency: normalizeUpper(input.clientCurrency) ?? 'USD',
title: normalizeString(input.title) ?? 'Quotation', title: normalizeString(input.title) ?? 'Quotation',
status: normalizeString(input.status) ?? 'draft', status: normalizeString(input.status) ?? 'draft',
operationCode: normalizeUpper(input.operationCode), operationCode: normalizeUpper(input.operationCode),
@@ -803,12 +1022,18 @@ function buildCreateQuotationData(input: CreateQuotationInput, ctx: { teamUuid:
unitsCount: normalizeInteger(input.unitsCount), unitsCount: normalizeInteger(input.unitsCount),
routeDistanceKm: normalizeInteger(input.routeDistanceKm), routeDistanceKm: normalizeInteger(input.routeDistanceKm),
notes: normalizeString(input.notes), notes: normalizeString(input.notes),
routeStages: {
create: buildRouteStages(input),
},
} }
} }
function buildUpdateQuotationData(input: UpdateQuotationInput): Prisma.QuotationUpdateInput { function buildUpdateQuotationData(input: UpdateQuotationInput): Prisma.QuotationUpdateInput {
const data: Prisma.QuotationUpdateInput = {} const data: Prisma.QuotationUpdateInput = {}
if (hasOwn(input, 'clientCurrency')) {
data.clientCurrency = normalizeRequiredUpdateUpper(input.clientCurrency)
}
if (hasOwn(input, 'title')) data.title = normalizeRequiredUpdateString(input.title) if (hasOwn(input, 'title')) data.title = normalizeRequiredUpdateString(input.title)
if (hasOwn(input, 'status')) data.status = normalizeRequiredUpdateString(input.status) if (hasOwn(input, 'status')) data.status = normalizeRequiredUpdateString(input.status)
if (hasOwn(input, 'operationCode')) data.operationCode = normalizeUpper(input.operationCode) if (hasOwn(input, 'operationCode')) data.operationCode = normalizeUpper(input.operationCode)
@@ -975,24 +1200,19 @@ function actorLabel(ctx: { userId: string; teamUuid: string }): string {
async function lockTariffPrice( async function lockTariffPrice(
tariff: PrismaTariffReference, tariff: PrismaTariffReference,
sourceAmount: number, sourceAmount: number,
userId: string, teamUuid: string,
clientCurrency: string,
): Promise<{ kyc: KycPricingContext; price: LockedCurrencyPrice }> { ): Promise<{ kyc: KycPricingContext; price: LockedCurrencyPrice }> {
const kyc = await resolveKycPricingContext(userId) const kyc = await resolveKycPricingContext(teamUuid)
const price = await lockCurrencyPrice({ const price = await lockCurrencyPrice({
sourceAmount, sourceAmount,
sourceCurrency: tariff.sourceCurrency, sourceCurrency: tariff.sourceCurrency,
clientCurrency,
pricingCountryCode: kyc.countryCode, pricingCountryCode: kyc.countryCode,
}) })
return { kyc, price } return { kyc, price }
} }
function quotationPricingUserId(quotation: PrismaQuotation): string {
if (!quotation.createdByUserId) {
throw new GraphQLError('Quotation owner is required to resolve KYC pricing currency')
}
return quotation.createdByUserId
}
function lockedQuotationPriceData( function lockedQuotationPriceData(
locked: { kyc: KycPricingContext; price: LockedCurrencyPrice } | null, locked: { kyc: KycPricingContext; price: LockedCurrencyPrice } | null,
currentClientCurrency: string, currentClientCurrency: string,
@@ -1053,7 +1273,8 @@ async function refreshQuotationSelection(quotationUuid: string, teamUuid: string
? await lockTariffPrice( ? await lockTariffPrice(
bestMatch.tariff, bestMatch.tariff,
bestMatch.projectedSourceAmount, bestMatch.projectedSourceAmount,
quotationPricingUserId(quotation), quotation.teamUuid,
quotation.clientCurrency,
) )
: null : null
@@ -1131,7 +1352,7 @@ async function refreshTeamQuotations(teamUuid: string, actor: { userId: string;
export const teamResolvers = { export const teamResolvers = {
Query: { Query: {
getTeamOrders: async (_: unknown, __: unknown, ctx: AuthContext) => { getTeamOrders: async (_: unknown, args: { displayCurrency: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
await ensureClientWorkspaceData(access) await ensureClientWorkspaceData(access)
@@ -1145,10 +1366,17 @@ export const teamResolvers = {
}, },
}) })
return localOrders.map(mapLocalOrder) return Promise.all(localOrders.map(async item => mapLocalOrder(
item,
await convertCurrencyForDisplay({
amount: Number(item.clientAmount),
sourceCurrency: item.clientCurrency,
displayCurrency: args.displayCurrency,
}),
)))
}, },
getOrder: async (_: unknown, args: { orderUuid: string }, ctx: AuthContext) => { getOrder: async (_: unknown, args: { orderUuid: string; displayCurrency: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const order = await prisma.order.findFirst({ const order = await prisma.order.findFirst({
@@ -1159,10 +1387,16 @@ export const teamResolvers = {
include: orderInclude, include: orderInclude,
}) })
return order ? mapLocalOrder(order) : null if (order === null) return null
const display = await convertCurrencyForDisplay({
amount: Number(order.clientAmount),
sourceCurrency: order.clientCurrency,
displayCurrency: args.displayCurrency,
})
return mapLocalOrder(order, display)
}, },
quotations: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => { quotations: async (_: unknown, args: { status?: string | null; displayCurrency: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
await ensureClientWorkspaceData(access) await ensureClientWorkspaceData(access)
@@ -1177,10 +1411,17 @@ export const teamResolvers = {
}, },
}) })
return items.map(mapQuotation) return Promise.all(items.map(async item => mapQuotation(
item,
await convertCurrencyForDisplay({
amount: Number(item.clientAmount),
sourceCurrency: item.clientCurrency,
displayCurrency: args.displayCurrency,
}),
)))
}, },
quotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { quotation: async (_: unknown, args: { quotationUuid: string; displayCurrency: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const item = await prisma.quotation.findFirst({ const item = await prisma.quotation.findFirst({
where: { where: {
@@ -1189,7 +1430,13 @@ export const teamResolvers = {
}, },
include: quotationInclude, include: quotationInclude,
}) })
return item ? mapQuotation(item) : null if (item === null) return null
const display = await convertCurrencyForDisplay({
amount: Number(item.clientAmount),
sourceCurrency: item.clientCurrency,
displayCurrency: args.displayCurrency,
})
return mapQuotation(item, display)
}, },
tariffReferences: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => { tariffReferences: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => {
@@ -1231,12 +1478,13 @@ export const teamResolvers = {
}) })
const matches = computeTariffMatches(quotation, tariffs) const matches = computeTariffMatches(quotation, tariffs)
const kyc = await resolveKycPricingContext(quotationPricingUserId(quotation)) const kyc = await resolveKycPricingContext(quotation.teamUuid)
return Promise.all( return Promise.all(
matches.map(async item => { matches.map(async item => {
const price = await lockCurrencyPrice({ const price = await lockCurrencyPrice({
sourceAmount: item.projectedSourceAmount, sourceAmount: item.projectedSourceAmount,
sourceCurrency: item.tariff.sourceCurrency, sourceCurrency: item.tariff.sourceCurrency,
clientCurrency: quotation.clientCurrency,
pricingCountryCode: kyc.countryCode, pricingCountryCode: kyc.countryCode,
}) })
return { return {
@@ -1316,13 +1564,31 @@ export const teamResolvers = {
updateQuotation: async (_: unknown, args: { quotationUuid: string; input: UpdateQuotationInput }, ctx: AuthContext) => { updateQuotation: async (_: unknown, args: { quotationUuid: string; input: UpdateQuotationInput }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
if (current.status === 'converted') {
throw new GraphQLError('Converted quotation is fixed')
}
const teamAccess = { teamUuid: current.teamUuid, userId: access.userId } const teamAccess = { teamUuid: current.teamUuid, userId: access.userId }
const actor = { userId: access.userId, label: actorLabel(teamAccess) } const actor = { userId: access.userId, label: actorLabel(teamAccess) }
const routeChanged = Object.keys(args.input).some(key => quotationRouteKeys.has(key))
const routeInput: Record<string, unknown> = { ...current, ...args.input }
if (!hasOwn(args.input, 'routeStages')) {
delete routeInput.routeStages
}
await prisma.quotation.update({ await prisma.quotation.update({
where: { where: {
id: current.id, 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', { await addQuotationChange(current.id, actor, 'quotation', 'Quotation updated', {
@@ -1337,6 +1603,9 @@ export const teamResolvers = {
refreshQuotationTariff: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { refreshQuotationTariff: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
if (current.status === 'converted') {
throw new GraphQLError('Converted quotation is fixed')
}
const teamAccess = { teamUuid: current.teamUuid, userId: access.userId } const teamAccess = { teamUuid: current.teamUuid, userId: access.userId }
const actor = { userId: access.userId, label: actorLabel(teamAccess) } const actor = { userId: access.userId, label: actorLabel(teamAccess) }
const refreshed = await refreshQuotationSelection(current.uuid, current.teamUuid, actor) const refreshed = await refreshQuotationSelection(current.uuid, current.teamUuid, actor)
@@ -1346,6 +1615,9 @@ export const teamResolvers = {
selectQuotationTariff: async (_: unknown, args: { quotationUuid: string; tariffReferenceUuid: string }, ctx: AuthContext) => { selectQuotationTariff: async (_: unknown, args: { quotationUuid: string; tariffReferenceUuid: string }, ctx: AuthContext) => {
const access = assertTeamAccess(ctx) const access = assertTeamAccess(ctx)
const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid)
if (quotation.status === 'converted') {
throw new GraphQLError('Converted quotation is fixed')
}
const teamAccess = { teamUuid: quotation.teamUuid, userId: access.userId } const teamAccess = { teamUuid: quotation.teamUuid, userId: access.userId }
const actor = { userId: access.userId, label: actorLabel(teamAccess) } const actor = { userId: access.userId, label: actorLabel(teamAccess) }
const tariff = await loadTariffOrThrow(args.tariffReferenceUuid, quotation.teamUuid) const tariff = await loadTariffOrThrow(args.tariffReferenceUuid, quotation.teamUuid)
@@ -1357,7 +1629,8 @@ export const teamResolvers = {
const locked = await lockTariffPrice( const locked = await lockTariffPrice(
tariff, tariff,
projectedSourceAmount, projectedSourceAmount,
quotationPricingUserId(quotation), quotation.teamUuid,
quotation.clientCurrency,
) )
await prisma.quotation.update({ await prisma.quotation.update({
where: { where: {
@@ -1439,18 +1712,40 @@ export const teamResolvers = {
exchangeRateLockedAt: quotation.exchangeRateLockedAt, exchangeRateLockedAt: quotation.exchangeRateLockedAt,
pricingCountryCode: quotation.pricingCountryCode, pricingCountryCode: quotation.pricingCountryCode,
pricingKycProfileUuid: quotation.pricingKycProfileUuid, pricingKycProfileUuid: quotation.pricingKycProfileUuid,
sourceHubUuid: quotation.sourceHubUuid,
sourceLocationUuid: quotation.sourceLocationUuid, sourceLocationUuid: quotation.sourceLocationUuid,
sourceLocationName: quotation.sourceLocationName, sourceLocationName: quotation.sourceLocationName,
sourceCountryCode: quotation.sourceCountryCode, sourceCountryCode: quotation.sourceCountryCode,
sourceLatitude: quotation.sourceLatitude, sourceLatitude: quotation.sourceLatitude,
sourceLongitude: quotation.sourceLongitude, sourceLongitude: quotation.sourceLongitude,
destinationLocationUuid: quotation.destinationLocationUuid, destinationLocationUuid: quotation.destinationLocationUuid,
destinationHubUuid: quotation.destinationHubUuid,
destinationLocationName: quotation.destinationLocationName, destinationLocationName: quotation.destinationLocationName,
destinationCountryCode: quotation.destinationCountryCode, destinationCountryCode: quotation.destinationCountryCode,
destinationLatitude: quotation.destinationLatitude, destinationLatitude: quotation.destinationLatitude,
destinationLongitude: quotation.destinationLongitude, destinationLongitude: quotation.destinationLongitude,
etaDays: quotation.etaDays, etaDays: quotation.etaDays,
notes: quotation.notes, 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, include: orderInclude,
}) })