Files
orders/src/schemas/team.ts
T
Ruslan Bakiev 2a0fb9936d
Build and deploy Docker image / build (push) Successful in 2m4s
Allow manager orders access without team claim
2026-06-05 12:29:38 +07:00

1205 lines
41 KiB
TypeScript

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<string, unknown>
type UpdateTariffReferenceInput = Record<string, unknown>
type CreateQuotationInput = Record<string, unknown>
type UpdateQuotationInput = Record<string, unknown>
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<string, unknown>, 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<string, unknown>,
) {
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)
},
},
}