1205 lines
41 KiB
TypeScript
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)
|
|
},
|
|
},
|
|
}
|