import { GraphQLError } from 'graphql' import { Prisma, type Order as PrismaOrder, type Quotation as PrismaQuotation, type QuotationChange as PrismaQuotationChange, type TariffReference as PrismaTariffReference, } from '@prisma/client' import { requireScopes, type AuthContext } from '../auth.js' import { prisma } from '../db.js' const quotationInclude = { selectedTariff: true, changes: { orderBy: { createdAt: 'desc' as const, }, }, } satisfies Prisma.QuotationInclude const orderInclude = { quotation: true, } satisfies Prisma.OrderInclude type QuotationWithRelations = Prisma.QuotationGetPayload<{ include: typeof quotationInclude }> type OrderWithRelations = Prisma.OrderGetPayload<{ include: typeof orderInclude }> interface TariffMatchResult { tariff: PrismaTariffReference score: number reasons: string[] projectedAmount: number projectedEtaDays: number | null } type CreateTariffReferenceInput = Record type UpdateTariffReferenceInput = Record type CreateQuotationInput = Record type UpdateQuotationInput = Record export const teamTypeDefs = `#graphql type Company { uuid: String name: String taxId: String country: String countryCode: String active: Boolean } type Trip { uuid: String name: String sequence: Int company: Company plannedLoadingDate: String actualLoadingDate: String realLoadingDate: String plannedUnloadingDate: String actualUnloadingDate: String plannedWeight: Float weightAtLoading: Float weightAtUnloading: Float } type Stage { uuid: String name: String sequence: Int stageType: String transportType: String sourceLocationName: String sourceLatitude: Float sourceLongitude: Float destinationLocationName: String destinationLatitude: Float destinationLongitude: Float locationName: String locationLatitude: Float locationLongitude: Float selectedCompany: Company trips: [Trip] } type OrderLine { uuid: String productUuid: String productName: String quantity: Float unit: String priceUnit: Float subtotal: Float currency: String notes: String } type Order { uuid: String quotationUuid: String name: String teamUuid: String userId: String status: String totalAmount: Float currency: String sourceCountryCode: String sourceLocationUuid: String sourceLocationName: String sourceLatitude: Float sourceLongitude: Float destinationCountryCode: String destinationLocationUuid: String destinationLocationName: String etaDays: Int createdAt: String updatedAt: String notes: String orderLines: [OrderLine] stages: [Stage] } type TariffReference { uuid: String! teamUuid: String! name: String! status: String! operationCode: String incotermsCode: String transportTypeCode: String tareTypeCode: String sourceCountryCode: String destinationCountryCode: String sourceHubUuid: String destinationHubUuid: String minWeightKg: Float maxWeightKg: Float minVolumeCbm: Float maxVolumeCbm: Float minDistanceKm: Int maxDistanceKm: Int amountUsd: Float! minPriceUsd: Float etaDays: Int priority: Int! currency: String! dmnExpression: String notes: String createdByUserId: String createdAt: String! updatedAt: String! } type QuotationChange { uuid: String! actorUserId: String actorLabel: String source: String! summary: String payloadJson: String createdAt: String! } type Quotation { uuid: String! teamUuid: String! createdByUserId: String title: String! status: String! operationCode: String incotermsCode: String transportTypeCode: String tareTypeCode: String sourceCountryCode: String sourceHubUuid: String sourceLocationUuid: String sourceLocationName: String sourceLatitude: Float sourceLongitude: Float destinationCountryCode: String destinationHubUuid: String destinationLocationUuid: String destinationLocationName: String destinationLatitude: Float destinationLongitude: Float chargeableWeightKg: Float grossWeightKg: Float volumeCbm: Float unitsCount: Int routeDistanceKm: Int selectedTariff: TariffReference tariffMatchSummary: String tariffSnapshot: String totalAmount: Float! currency: String! etaDays: Int notes: String createdAt: String! updatedAt: String! changes: [QuotationChange!]! } type QuotationTariffMatch { tariffReference: TariffReference! score: Int! reasons: [String!]! projectedAmount: Float! projectedEtaDays: Int isSelected: Boolean! } input CreateTariffReferenceInput { name: String! status: String operationCode: String incotermsCode: String transportTypeCode: String tareTypeCode: String sourceCountryCode: String destinationCountryCode: String sourceHubUuid: String destinationHubUuid: String minWeightKg: Float maxWeightKg: Float minVolumeCbm: Float maxVolumeCbm: Float minDistanceKm: Int maxDistanceKm: Int amountUsd: Float! minPriceUsd: Float etaDays: Int priority: Int currency: String dmnExpression: String notes: String } input UpdateTariffReferenceInput { name: String status: String operationCode: String incotermsCode: String transportTypeCode: String tareTypeCode: String sourceCountryCode: String destinationCountryCode: String sourceHubUuid: String destinationHubUuid: String minWeightKg: Float maxWeightKg: Float minVolumeCbm: Float maxVolumeCbm: Float minDistanceKm: Int maxDistanceKm: Int amountUsd: Float minPriceUsd: Float etaDays: Int priority: Int currency: String dmnExpression: String notes: String } input CreateQuotationInput { title: String status: String operationCode: String incotermsCode: String transportTypeCode: String tareTypeCode: String sourceCountryCode: String sourceHubUuid: String sourceLocationUuid: String sourceLocationName: String sourceLatitude: Float sourceLongitude: Float destinationCountryCode: String destinationHubUuid: String destinationLocationUuid: String destinationLocationName: String destinationLatitude: Float destinationLongitude: Float chargeableWeightKg: Float grossWeightKg: Float volumeCbm: Float unitsCount: Int routeDistanceKm: Int notes: String } input UpdateQuotationInput { title: String status: String operationCode: String incotermsCode: String transportTypeCode: String tareTypeCode: String sourceCountryCode: String sourceHubUuid: String sourceLocationUuid: String sourceLocationName: String sourceLatitude: Float sourceLongitude: Float destinationCountryCode: String destinationHubUuid: String destinationLocationUuid: String destinationLocationName: String destinationLatitude: Float destinationLongitude: Float chargeableWeightKg: Float grossWeightKg: Float volumeCbm: Float unitsCount: Int routeDistanceKm: Int notes: String } type Query { getTeamOrders: [Order] getOrder(orderUuid: String!): Order quotations(status: String): [Quotation!]! quotation(quotationUuid: String!): Quotation tariffReferences(status: String): [TariffReference!]! tariffReference(tariffReferenceUuid: String!): TariffReference quotationTariffMatches(quotationUuid: String!): [QuotationTariffMatch!]! } type Mutation { createTariffReference(input: CreateTariffReferenceInput!): TariffReference! updateTariffReference(tariffReferenceUuid: String!, input: UpdateTariffReferenceInput!): TariffReference! deleteTariffReference(tariffReferenceUuid: String!): Boolean! createQuotation(input: CreateQuotationInput!): Quotation! updateQuotation(quotationUuid: String!, input: UpdateQuotationInput!): Quotation! refreshQuotationTariff(quotationUuid: String!): Quotation! selectQuotationTariff(quotationUuid: String!, tariffReferenceUuid: String!): Quotation! createOrderFromQuotation(quotationUuid: String!): Order! } ` function hasOwn(input: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(input, key) } function normalizeString(value: unknown): string | null { if (value == null) return null const next = String(value).trim() return next ? next : null } function normalizeUpper(value: unknown): string | null { const next = normalizeString(value) return next ? next.toUpperCase() : null } function normalizeNumber(value: unknown): number | null { if (value == null || value === '') return null const next = Number(value) if (!Number.isFinite(next)) { throw new GraphQLError('Numeric field contains invalid value') } return next } function normalizeInteger(value: unknown): number | null { const next = normalizeNumber(value) if (next == null) return null return Math.round(next) } function normalizeRequiredUpdateString(value: unknown): string | undefined { const next = normalizeString(value) return next ?? undefined } function normalizeRequiredUpdateUpper(value: unknown): string | undefined { const next = normalizeUpper(value) return next ?? undefined } function normalizeRequiredUpdateDecimal(value: unknown): Prisma.Decimal | undefined { const next = normalizeDecimal(value) return next ?? undefined } function normalizeRequiredUpdateInteger(value: unknown): number | undefined { const next = normalizeInteger(value) return next ?? undefined } function normalizeDecimal(value: unknown): Prisma.Decimal | null { const next = normalizeNumber(value) if (next == null) return null return new Prisma.Decimal(next) } function numberFromDecimal(value: Prisma.Decimal | number | null | undefined): number | null { if (value == null) return null return Number(value) } function isoString(value: Date | null | undefined): string | null { return value ? value.toISOString() : null } function assertTeamAccess(ctx: AuthContext): { teamUuid?: string; userId: string; isManager: boolean } { if (ctx.scopes.includes('manager')) { if (!ctx.userId) { throw new GraphQLError('User not authenticated') } return { teamUuid: ctx.teamUuid, userId: ctx.userId, isManager: true, } } requireScopes(ctx, 'teams:member') if (!ctx.teamUuid || !ctx.userId) { throw new GraphQLError('User not authenticated') } return { teamUuid: ctx.teamUuid, userId: ctx.userId, isManager: false, } } function assertScopedTeamAccess(ctx: AuthContext): { teamUuid: string; userId: string } { const access = assertTeamAccess(ctx) if (!access.teamUuid) { throw new GraphQLError('Team context is required') } return { teamUuid: access.teamUuid, userId: access.userId, } } function teamAccessWhere(access: { teamUuid?: string }): { teamUuid?: string } { return access.teamUuid ? { teamUuid: access.teamUuid } : {} } function mapTariffReference(item: PrismaTariffReference) { return { uuid: item.uuid, teamUuid: item.teamUuid, name: item.name, status: item.status, operationCode: item.operationCode, incotermsCode: item.incotermsCode, transportTypeCode: item.transportTypeCode, tareTypeCode: item.tareTypeCode, sourceCountryCode: item.sourceCountryCode, destinationCountryCode: item.destinationCountryCode, sourceHubUuid: item.sourceHubUuid, destinationHubUuid: item.destinationHubUuid, minWeightKg: numberFromDecimal(item.minWeightKg), maxWeightKg: numberFromDecimal(item.maxWeightKg), minVolumeCbm: numberFromDecimal(item.minVolumeCbm), maxVolumeCbm: numberFromDecimal(item.maxVolumeCbm), minDistanceKm: item.minDistanceKm, maxDistanceKm: item.maxDistanceKm, amountUsd: Number(item.amountUsd), minPriceUsd: numberFromDecimal(item.minPriceUsd), etaDays: item.etaDays, priority: item.priority, currency: item.currency, dmnExpression: item.dmnExpression, notes: item.notes, createdByUserId: item.createdByUserId, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), } } function mapQuotationChange(item: PrismaQuotationChange) { return { uuid: item.uuid, actorUserId: item.actorUserId, actorLabel: item.actorLabel, source: item.source, summary: item.summary, payloadJson: item.payloadJson, createdAt: item.createdAt.toISOString(), } } function mapQuotation(item: QuotationWithRelations) { return { uuid: item.uuid, teamUuid: item.teamUuid, createdByUserId: item.createdByUserId, title: item.title, status: item.status, operationCode: item.operationCode, incotermsCode: item.incotermsCode, transportTypeCode: item.transportTypeCode, tareTypeCode: item.tareTypeCode, 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, chargeableWeightKg: numberFromDecimal(item.chargeableWeightKg), grossWeightKg: numberFromDecimal(item.grossWeightKg), volumeCbm: numberFromDecimal(item.volumeCbm), unitsCount: item.unitsCount, routeDistanceKm: item.routeDistanceKm, selectedTariff: item.selectedTariff ? mapTariffReference(item.selectedTariff) : null, tariffMatchSummary: item.tariffMatchSummary, tariffSnapshot: item.tariffSnapshot, totalAmount: Number(item.totalAmount), currency: item.currency, etaDays: item.etaDays, notes: item.notes, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), changes: item.changes.map(mapQuotationChange), } } function mapLocalOrder(item: OrderWithRelations) { return { uuid: item.uuid, quotationUuid: item.quotation?.uuid ?? null, name: item.name, teamUuid: item.teamUuid, userId: item.createdByUserId, status: item.status, totalAmount: Number(item.totalAmount), currency: item.currency, sourceCountryCode: item.sourceCountryCode, sourceLocationUuid: item.sourceLocationUuid, sourceLocationName: item.sourceLocationName, sourceLatitude: item.sourceLatitude, sourceLongitude: item.sourceLongitude, destinationCountryCode: item.destinationCountryCode, destinationLocationUuid: item.destinationLocationUuid, destinationLocationName: item.destinationLocationName, etaDays: item.etaDays, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), notes: item.notes ?? '', orderLines: [], stages: [], } } function requiredString(value: unknown, label: string): string { const next = normalizeString(value) if (!next) { throw new GraphQLError(`${label} is required`) } return next } function buildCreateTariffData(input: CreateTariffReferenceInput, ctx: { teamUuid: string; userId: string }): Prisma.TariffReferenceCreateInput { return { teamUuid: ctx.teamUuid, createdByUserId: ctx.userId, name: requiredString(input.name, 'Tariff name'), status: normalizeString(input.status) ?? 'active', operationCode: normalizeUpper(input.operationCode), incotermsCode: normalizeUpper(input.incotermsCode), transportTypeCode: normalizeUpper(input.transportTypeCode), tareTypeCode: normalizeUpper(input.tareTypeCode), sourceCountryCode: normalizeUpper(input.sourceCountryCode), destinationCountryCode: normalizeUpper(input.destinationCountryCode), sourceHubUuid: normalizeString(input.sourceHubUuid), destinationHubUuid: normalizeString(input.destinationHubUuid), minWeightKg: normalizeDecimal(input.minWeightKg), maxWeightKg: normalizeDecimal(input.maxWeightKg), minVolumeCbm: normalizeDecimal(input.minVolumeCbm), maxVolumeCbm: normalizeDecimal(input.maxVolumeCbm), minDistanceKm: normalizeInteger(input.minDistanceKm), maxDistanceKm: normalizeInteger(input.maxDistanceKm), amountUsd: normalizeDecimal(input.amountUsd) ?? new Prisma.Decimal(0), minPriceUsd: normalizeDecimal(input.minPriceUsd), etaDays: normalizeInteger(input.etaDays), priority: normalizeInteger(input.priority) ?? 100, currency: normalizeUpper(input.currency) ?? 'USD', dmnExpression: normalizeString(input.dmnExpression), notes: normalizeString(input.notes), } } function buildUpdateTariffData(input: UpdateTariffReferenceInput): Prisma.TariffReferenceUpdateInput { const data: Prisma.TariffReferenceUpdateInput = {} if (hasOwn(input, 'name')) data.name = normalizeRequiredUpdateString(input.name) if (hasOwn(input, 'status')) data.status = normalizeRequiredUpdateString(input.status) if (hasOwn(input, 'operationCode')) data.operationCode = normalizeUpper(input.operationCode) if (hasOwn(input, 'incotermsCode')) data.incotermsCode = normalizeUpper(input.incotermsCode) if (hasOwn(input, 'transportTypeCode')) data.transportTypeCode = normalizeUpper(input.transportTypeCode) if (hasOwn(input, 'tareTypeCode')) data.tareTypeCode = normalizeUpper(input.tareTypeCode) if (hasOwn(input, 'sourceCountryCode')) data.sourceCountryCode = normalizeUpper(input.sourceCountryCode) if (hasOwn(input, 'destinationCountryCode')) data.destinationCountryCode = normalizeUpper(input.destinationCountryCode) if (hasOwn(input, 'sourceHubUuid')) data.sourceHubUuid = normalizeString(input.sourceHubUuid) if (hasOwn(input, 'destinationHubUuid')) data.destinationHubUuid = normalizeString(input.destinationHubUuid) if (hasOwn(input, 'minWeightKg')) data.minWeightKg = normalizeDecimal(input.minWeightKg) if (hasOwn(input, 'maxWeightKg')) data.maxWeightKg = normalizeDecimal(input.maxWeightKg) if (hasOwn(input, 'minVolumeCbm')) data.minVolumeCbm = normalizeDecimal(input.minVolumeCbm) if (hasOwn(input, 'maxVolumeCbm')) data.maxVolumeCbm = normalizeDecimal(input.maxVolumeCbm) if (hasOwn(input, 'minDistanceKm')) data.minDistanceKm = normalizeInteger(input.minDistanceKm) if (hasOwn(input, 'maxDistanceKm')) data.maxDistanceKm = normalizeInteger(input.maxDistanceKm) if (hasOwn(input, 'amountUsd')) data.amountUsd = normalizeRequiredUpdateDecimal(input.amountUsd) if (hasOwn(input, 'minPriceUsd')) data.minPriceUsd = normalizeDecimal(input.minPriceUsd) if (hasOwn(input, 'etaDays')) data.etaDays = normalizeInteger(input.etaDays) if (hasOwn(input, 'priority')) data.priority = normalizeRequiredUpdateInteger(input.priority) if (hasOwn(input, 'currency')) data.currency = normalizeRequiredUpdateUpper(input.currency) if (hasOwn(input, 'dmnExpression')) data.dmnExpression = normalizeString(input.dmnExpression) if (hasOwn(input, 'notes')) data.notes = normalizeString(input.notes) return data } function buildCreateQuotationData(input: CreateQuotationInput, ctx: { teamUuid: string; userId: string }): Prisma.QuotationCreateInput { return { teamUuid: ctx.teamUuid, createdByUserId: ctx.userId, title: normalizeString(input.title) ?? 'Quotation', status: normalizeString(input.status) ?? 'draft', operationCode: normalizeUpper(input.operationCode), incotermsCode: normalizeUpper(input.incotermsCode), transportTypeCode: normalizeUpper(input.transportTypeCode), tareTypeCode: normalizeUpper(input.tareTypeCode), sourceCountryCode: normalizeUpper(input.sourceCountryCode), sourceHubUuid: normalizeString(input.sourceHubUuid), sourceLocationUuid: normalizeString(input.sourceLocationUuid), sourceLocationName: normalizeString(input.sourceLocationName), sourceLatitude: normalizeNumber(input.sourceLatitude), sourceLongitude: normalizeNumber(input.sourceLongitude), destinationCountryCode: normalizeUpper(input.destinationCountryCode), destinationHubUuid: normalizeString(input.destinationHubUuid), destinationLocationUuid: normalizeString(input.destinationLocationUuid), destinationLocationName: normalizeString(input.destinationLocationName), destinationLatitude: normalizeNumber(input.destinationLatitude), destinationLongitude: normalizeNumber(input.destinationLongitude), chargeableWeightKg: normalizeDecimal(input.chargeableWeightKg), grossWeightKg: normalizeDecimal(input.grossWeightKg), volumeCbm: normalizeDecimal(input.volumeCbm), unitsCount: normalizeInteger(input.unitsCount), routeDistanceKm: normalizeInteger(input.routeDistanceKm), notes: normalizeString(input.notes), } } function buildUpdateQuotationData(input: UpdateQuotationInput): Prisma.QuotationUpdateInput { const data: Prisma.QuotationUpdateInput = {} if (hasOwn(input, 'title')) data.title = normalizeRequiredUpdateString(input.title) if (hasOwn(input, 'status')) data.status = normalizeRequiredUpdateString(input.status) if (hasOwn(input, 'operationCode')) data.operationCode = normalizeUpper(input.operationCode) if (hasOwn(input, 'incotermsCode')) data.incotermsCode = normalizeUpper(input.incotermsCode) if (hasOwn(input, 'transportTypeCode')) data.transportTypeCode = normalizeUpper(input.transportTypeCode) if (hasOwn(input, 'tareTypeCode')) data.tareTypeCode = normalizeUpper(input.tareTypeCode) if (hasOwn(input, 'sourceCountryCode')) data.sourceCountryCode = normalizeUpper(input.sourceCountryCode) if (hasOwn(input, 'sourceHubUuid')) data.sourceHubUuid = normalizeString(input.sourceHubUuid) if (hasOwn(input, 'sourceLocationUuid')) data.sourceLocationUuid = normalizeString(input.sourceLocationUuid) if (hasOwn(input, 'sourceLocationName')) data.sourceLocationName = normalizeString(input.sourceLocationName) if (hasOwn(input, 'sourceLatitude')) data.sourceLatitude = normalizeNumber(input.sourceLatitude) if (hasOwn(input, 'sourceLongitude')) data.sourceLongitude = normalizeNumber(input.sourceLongitude) if (hasOwn(input, 'destinationCountryCode')) data.destinationCountryCode = normalizeUpper(input.destinationCountryCode) if (hasOwn(input, 'destinationHubUuid')) data.destinationHubUuid = normalizeString(input.destinationHubUuid) if (hasOwn(input, 'destinationLocationUuid')) data.destinationLocationUuid = normalizeString(input.destinationLocationUuid) if (hasOwn(input, 'destinationLocationName')) data.destinationLocationName = normalizeString(input.destinationLocationName) if (hasOwn(input, 'destinationLatitude')) data.destinationLatitude = normalizeNumber(input.destinationLatitude) if (hasOwn(input, 'destinationLongitude')) data.destinationLongitude = normalizeNumber(input.destinationLongitude) if (hasOwn(input, 'chargeableWeightKg')) data.chargeableWeightKg = normalizeDecimal(input.chargeableWeightKg) if (hasOwn(input, 'grossWeightKg')) data.grossWeightKg = normalizeDecimal(input.grossWeightKg) if (hasOwn(input, 'volumeCbm')) data.volumeCbm = normalizeDecimal(input.volumeCbm) if (hasOwn(input, 'unitsCount')) data.unitsCount = normalizeInteger(input.unitsCount) if (hasOwn(input, 'routeDistanceKm')) data.routeDistanceKm = normalizeInteger(input.routeDistanceKm) if (hasOwn(input, 'notes')) data.notes = normalizeString(input.notes) return data } function matchesExact(ruleValue: string | null, actualValue: string | null, weight: number, reasons: string[], label: string): number | null { if (!ruleValue) return 0 if (!actualValue) return null if (ruleValue !== actualValue) return null reasons.push(`${label}: ${ruleValue}`) return weight } function matchesRange( min: number | null, max: number | null, actual: number | null, weight: number, reasons: string[], label: string, ): number | null { if (min == null && max == null) return 0 if (actual == null) return null if (min != null && actual < min) return null if (max != null && actual > max) return null reasons.push(label) return weight } function computeTariffMatches( quotation: PrismaQuotation, tariffs: PrismaTariffReference[], ): TariffMatchResult[] { const matches: TariffMatchResult[] = [] for (const tariff of tariffs) { if (tariff.status !== 'active') continue const reasons: string[] = [] let score = 0 const exactChecks = [ matchesExact(tariff.operationCode, quotation.operationCode, 30, reasons, 'operation'), matchesExact(tariff.incotermsCode, quotation.incotermsCode, 25, reasons, 'incoterms'), matchesExact(tariff.transportTypeCode, quotation.transportTypeCode, 20, reasons, 'transport'), matchesExact(tariff.tareTypeCode, quotation.tareTypeCode, 15, reasons, 'tare'), matchesExact(tariff.sourceCountryCode, quotation.sourceCountryCode, 15, reasons, 'origin country'), matchesExact(tariff.destinationCountryCode, quotation.destinationCountryCode, 15, reasons, 'destination country'), matchesExact(tariff.sourceHubUuid, quotation.sourceHubUuid, 25, reasons, 'origin hub'), matchesExact(tariff.destinationHubUuid, quotation.destinationHubUuid, 25, reasons, 'destination hub'), ] if (exactChecks.some(item => item == null)) continue const exactScoreParts = exactChecks.filter((item): item is number => item != null) score += exactScoreParts.reduce((sum, item) => sum + item, 0) const rangeChecks = [ matchesRange(numberFromDecimal(tariff.minWeightKg), numberFromDecimal(tariff.maxWeightKg), numberFromDecimal(quotation.chargeableWeightKg), 20, reasons, 'chargeable weight'), matchesRange(numberFromDecimal(tariff.minVolumeCbm), numberFromDecimal(tariff.maxVolumeCbm), numberFromDecimal(quotation.volumeCbm), 12, reasons, 'volume'), matchesRange(tariff.minDistanceKm, tariff.maxDistanceKm, quotation.routeDistanceKm, 12, reasons, 'distance'), ] if (rangeChecks.some(item => item == null)) continue const rangeScoreParts = rangeChecks.filter((item): item is number => item != null) score += rangeScoreParts.reduce((sum, item) => sum + item, 0) const projectedAmount = Math.max(Number(tariff.amountUsd), numberFromDecimal(tariff.minPriceUsd) ?? 0) matches.push({ tariff, score, reasons, projectedAmount, projectedEtaDays: tariff.etaDays, }) } return matches.sort((left, right) => { if (right.score !== left.score) return right.score - left.score if (left.tariff.priority !== right.tariff.priority) return left.tariff.priority - right.tariff.priority return right.tariff.updatedAt.getTime() - left.tariff.updatedAt.getTime() }) } async function loadQuotationOrThrow(quotationUuid: string, teamUuid?: string) { const item = await prisma.quotation.findFirst({ where: { uuid: quotationUuid, ...teamAccessWhere({ teamUuid }), }, include: quotationInclude, }) if (!item) { throw new GraphQLError('Quotation not found') } return item } async function loadTariffOrThrow(tariffReferenceUuid: string, teamUuid?: string) { const item = await prisma.tariffReference.findFirst({ where: { uuid: tariffReferenceUuid, ...teamAccessWhere({ teamUuid }), }, }) if (!item) { throw new GraphQLError('Tariff reference not found') } return item } async function addQuotationChange( quotationId: number, actor: { userId: string; label: string }, source: string, summary: string, payload?: Record, ) { await prisma.quotationChange.create({ data: { quotationId, actorUserId: actor.userId, actorLabel: actor.label, source, summary, payloadJson: payload ? JSON.stringify(payload) : null, }, }) } function actorLabel(ctx: { userId: string; teamUuid: string }): string { return `user:${ctx.userId}@${ctx.teamUuid}` } async function refreshQuotationSelection(quotationUuid: string, teamUuid: string, actor: { userId: string; label: string }) { const quotation = await prisma.quotation.findFirst({ where: { uuid: quotationUuid, teamUuid, }, }) if (!quotation) { throw new GraphQLError('Quotation not found') } const tariffs = await prisma.tariffReference.findMany({ where: { teamUuid, }, }) const matches = computeTariffMatches(quotation, tariffs) const bestMatch = matches[0] ?? null const tariffMatchSummary = bestMatch ? `Matched ${bestMatch.tariff.name}; ${bestMatch.reasons.join(', ')}` : 'No active tariff reference matched this quotation' const tariffSnapshot = JSON.stringify({ quotationUuid, matchedAt: new Date().toISOString(), matchCount: matches.length, selectedTariffUuid: bestMatch?.tariff.uuid ?? null, matches: matches.slice(0, 10).map(item => ({ tariffReferenceUuid: item.tariff.uuid, score: item.score, reasons: item.reasons, projectedAmount: item.projectedAmount, projectedEtaDays: item.projectedEtaDays, })), }) const updated = await prisma.quotation.update({ where: { id: quotation.id, }, data: { selectedTariffId: bestMatch?.tariff.id ?? null, totalAmount: new Prisma.Decimal(bestMatch?.projectedAmount ?? 0), currency: bestMatch?.tariff.currency ?? quotation.currency, etaDays: bestMatch?.projectedEtaDays ?? null, tariffMatchSummary, tariffSnapshot, status: quotation.status === 'converted' ? quotation.status : bestMatch ? 'priced' : 'draft', }, include: quotationInclude, }) await addQuotationChange( quotation.id, actor, 'tariff-engine', bestMatch ? `Tariff refreshed: ${bestMatch.tariff.name}` : 'Tariff refresh found no matching tariff', { selectedTariffUuid: bestMatch?.tariff.uuid ?? null, matchCount: matches.length, }, ) return await loadQuotationOrThrow(updated.uuid, teamUuid) } async function refreshTeamQuotations(teamUuid: string, actor: { userId: string; label: string }) { const items = await prisma.quotation.findMany({ where: { teamUuid, status: { not: 'converted', }, }, select: { uuid: true, }, }) for (const item of items) { await refreshQuotationSelection(item.uuid, teamUuid, actor) } } export const teamResolvers = { Query: { getTeamOrders: async (_: unknown, __: unknown, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const localOrders = await prisma.order.findMany({ where: { ...teamAccessWhere(access), }, include: orderInclude, orderBy: { createdAt: 'desc', }, }) return localOrders.map(mapLocalOrder) }, getOrder: async (_: unknown, args: { orderUuid: string }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const order = await prisma.order.findFirst({ where: { uuid: args.orderUuid, ...teamAccessWhere(access), }, include: orderInclude, }) return order ? mapLocalOrder(order) : null }, quotations: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const items = await prisma.quotation.findMany({ where: { ...teamAccessWhere(access), status: normalizeString(args.status) ?? undefined, }, include: quotationInclude, orderBy: { createdAt: 'desc', }, }) return items.map(mapQuotation) }, quotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const item = await prisma.quotation.findFirst({ where: { uuid: args.quotationUuid, ...teamAccessWhere(access), }, include: quotationInclude, }) return item ? mapQuotation(item) : null }, tariffReferences: async (_: unknown, args: { status?: string | null }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const items = await prisma.tariffReference.findMany({ where: { ...teamAccessWhere(access), status: normalizeString(args.status) ?? undefined, }, orderBy: [ { priority: 'asc' }, { updatedAt: 'desc' }, ], }) return items.map(mapTariffReference) }, tariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const item = await prisma.tariffReference.findFirst({ where: { uuid: args.tariffReferenceUuid, ...teamAccessWhere(access), }, }) return item ? mapTariffReference(item) : null }, quotationTariffMatches: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) const tariffs = await prisma.tariffReference.findMany({ where: { teamUuid: quotation.teamUuid, status: 'active', }, }) const matches = computeTariffMatches(quotation, tariffs) return matches.map(item => ({ tariffReference: mapTariffReference(item.tariff), score: item.score, reasons: item.reasons, projectedAmount: item.projectedAmount, projectedEtaDays: item.projectedEtaDays, isSelected: quotation.selectedTariff?.uuid === item.tariff.uuid, })) }, }, Mutation: { createTariffReference: async (_: unknown, args: { input: CreateTariffReferenceInput }, ctx: AuthContext) => { const access = assertScopedTeamAccess(ctx) const actor = { userId: access.userId, label: actorLabel(access) } const item = await prisma.tariffReference.create({ data: buildCreateTariffData(args.input, access), }) await refreshTeamQuotations(access.teamUuid, actor) return mapTariffReference(item) }, updateTariffReference: async (_: unknown, args: { tariffReferenceUuid: string; input: UpdateTariffReferenceInput }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const item = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid) const teamAccess = { teamUuid: item.teamUuid, userId: access.userId } const actor = { userId: access.userId, label: actorLabel(teamAccess) } const updated = await prisma.tariffReference.update({ where: { id: item.id, }, data: buildUpdateTariffData(args.input), }) await refreshTeamQuotations(item.teamUuid, actor) return mapTariffReference(updated) }, deleteTariffReference: async (_: unknown, args: { tariffReferenceUuid: string }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const item = await loadTariffOrThrow(args.tariffReferenceUuid, access.teamUuid) const teamAccess = { teamUuid: item.teamUuid, userId: access.userId } const actor = { userId: access.userId, label: actorLabel(teamAccess) } await prisma.tariffReference.delete({ where: { id: item.id, }, }) await refreshTeamQuotations(item.teamUuid, actor) return true }, createQuotation: async (_: unknown, args: { input: CreateQuotationInput }, ctx: AuthContext) => { const access = assertScopedTeamAccess(ctx) const actor = { userId: access.userId, label: actorLabel(access) } const created = await prisma.quotation.create({ data: buildCreateQuotationData(args.input, access), }) await addQuotationChange(created.id, actor, 'quotation', 'Quotation created', { quotationUuid: created.uuid, }) const refreshed = await refreshQuotationSelection(created.uuid, access.teamUuid, actor) return mapQuotation(refreshed) }, updateQuotation: async (_: unknown, args: { quotationUuid: string; input: UpdateQuotationInput }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) const teamAccess = { teamUuid: current.teamUuid, userId: access.userId } const actor = { userId: access.userId, label: actorLabel(teamAccess) } await prisma.quotation.update({ where: { id: current.id, }, data: buildUpdateQuotationData(args.input), }) await addQuotationChange(current.id, actor, 'quotation', 'Quotation updated', { quotationUuid: current.uuid, updatedFields: Object.keys(args.input), }) const refreshed = await refreshQuotationSelection(current.uuid, current.teamUuid, actor) return mapQuotation(refreshed) }, refreshQuotationTariff: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const current = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) const teamAccess = { teamUuid: current.teamUuid, userId: access.userId } const actor = { userId: access.userId, label: actorLabel(teamAccess) } const refreshed = await refreshQuotationSelection(current.uuid, current.teamUuid, actor) return mapQuotation(refreshed) }, selectQuotationTariff: async (_: unknown, args: { quotationUuid: string; tariffReferenceUuid: string }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) const teamAccess = { teamUuid: quotation.teamUuid, userId: access.userId } const actor = { userId: access.userId, label: actorLabel(teamAccess) } const tariff = await loadTariffOrThrow(args.tariffReferenceUuid, quotation.teamUuid) const projectedAmount = Math.max(Number(tariff.amountUsd), numberFromDecimal(tariff.minPriceUsd) ?? 0) await prisma.quotation.update({ where: { id: quotation.id, }, data: { selectedTariffId: tariff.id, totalAmount: new Prisma.Decimal(projectedAmount), currency: tariff.currency, etaDays: tariff.etaDays, tariffMatchSummary: `Manually selected ${tariff.name}`, tariffSnapshot: JSON.stringify({ quotationUuid: quotation.uuid, selectedTariffUuid: tariff.uuid, selectedAt: new Date().toISOString(), selectionMode: 'manual', }), status: quotation.status === 'converted' ? quotation.status : 'priced', }, }) await addQuotationChange(quotation.id, actor, 'tariff-selection', `Tariff selected manually: ${tariff.name}`, { tariffReferenceUuid: tariff.uuid, }) const updated = await loadQuotationOrThrow(quotation.uuid, access.teamUuid) return mapQuotation(updated) }, createOrderFromQuotation: async (_: unknown, args: { quotationUuid: string }, ctx: AuthContext) => { const access = assertTeamAccess(ctx) const quotation = await loadQuotationOrThrow(args.quotationUuid, access.teamUuid) const teamAccess = { teamUuid: quotation.teamUuid, userId: access.userId } const actor = { userId: access.userId, label: actorLabel(teamAccess) } const existingOrder = await prisma.order.findFirst({ where: { quotationId: quotation.id, teamUuid: quotation.teamUuid, }, include: orderInclude, }) if (existingOrder) { return mapLocalOrder(existingOrder) } if (!quotation.selectedTariff) { throw new GraphQLError('Quotation must have a selected tariff before order creation') } const order = await prisma.order.create({ data: { teamUuid: quotation.teamUuid, quotationId: quotation.id, createdByUserId: access.userId, name: quotation.title.startsWith('Order') ? quotation.title : `Order for ${quotation.title}`, status: 'created', totalAmount: quotation.totalAmount, currency: quotation.currency, sourceLocationUuid: quotation.sourceLocationUuid, sourceLocationName: quotation.sourceLocationName, sourceCountryCode: quotation.sourceCountryCode, sourceLatitude: quotation.sourceLatitude, sourceLongitude: quotation.sourceLongitude, destinationLocationUuid: quotation.destinationLocationUuid, destinationLocationName: quotation.destinationLocationName, destinationCountryCode: quotation.destinationCountryCode, destinationLatitude: quotation.destinationLatitude, destinationLongitude: quotation.destinationLongitude, etaDays: quotation.etaDays, notes: quotation.notes, }, include: orderInclude, }) await prisma.quotation.update({ where: { id: quotation.id, }, data: { status: 'converted', }, }) await addQuotationChange(quotation.id, actor, 'order', 'Order created from quotation', { orderUuid: order.uuid, }) return mapLocalOrder(order) }, }, }