import type { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { filterQuotesForMotherShipUiDisplay } from "@/lib/constants/mothership-ui-tiers"; import { formatCustomerAddressForAdmin, formatSelectedAddressForAdmin, formatCargoForAdmin, } from "@/modules/alert/alert-presentation"; import type { NormalizedCargo } from "@/modules/quote/types"; export const QUOTE_QUERY_LOG_MAX_ROWS = 1000; export type QuoteQueryOutcome = "processing" | "success" | "failed" | "stale"; function buildCargoJson(cargo: NormalizedCargo): Prisma.InputJsonValue { return { business_customer_id: cargo.businessCustomerId ?? null, weight_lb: cargo.weightLb, dim_l_in: cargo.dimLIn, dim_w_in: cargo.dimWIn, dim_h_in: cargo.dimHIn, pallet_count: cargo.palletCount, cargo_type: cargo.cargoType, }; } async function trimQuoteQueryLogs(): Promise { const total = await prisma.quoteQueryLog.count(); if (total <= QUOTE_QUERY_LOG_MAX_ROWS) { return; } const excess = total - QUOTE_QUERY_LOG_MAX_ROWS; const oldest = await prisma.quoteQueryLog.findMany({ orderBy: { createdAt: "asc" }, take: excess, select: { id: true }, }); if (oldest.length === 0) { return; } await prisma.quoteQueryLog.deleteMany({ where: { id: { in: oldest.map((row) => row.id) } }, }); } /** 询价提交时写入查询审计(processing) */ export async function recordQuoteQueryStart( cargo: NormalizedCargo, quoteId: string, outcome: QuoteQueryOutcome = "processing", ): Promise { try { await prisma.quoteQueryLog.upsert({ where: { quoteId }, create: { quoteId, requestId: cargo.requestId, customerId: cargo.customerId, businessCustomerId: cargo.businessCustomerId ?? null, pickupJson: cargo.pickupAddress as Prisma.InputJsonValue, deliveryJson: cargo.deliveryAddress as Prisma.InputJsonValue, cargoJson: buildCargoJson(cargo), outcome, }, update: { outcome, }, }); await trimQuoteQueryLogs(); } catch (error) { console.error("[quote-query-log] 写入失败:", error); } } /** 询价完成或失败时更新审计 */ export async function recordQuoteQueryOutcome( quoteId: string, outcome: QuoteQueryOutcome, options?: { failureReason?: string | null; errorCode?: string | null; sourceType?: string | null; tierCount?: number | null; }, ): Promise { try { await prisma.quoteQueryLog.updateMany({ where: { quoteId }, data: { outcome, failureReason: options?.failureReason ?? null, errorCode: options?.errorCode ?? null, sourceType: options?.sourceType ?? null, tierCount: options?.tierCount ?? null, completedAt: outcome === "processing" ? null : new Date(), }, }); } catch (error) { console.error("[quote-query-log] 更新失败:", error); } } export type QuoteQueryLogDto = { quote_id: string; request_id: string; customer_id: string; business_customer_id: string | null; outcome: QuoteQueryOutcome; failure_reason: string | null; error_code: string | null; source_type: string | null; tier_count: number | null; pickup_customer: string; pickup_selected: string; delivery_customer: string; delivery_selected: string; cargo_summary: string; quotes: Array<{ carrier: string; service_level: string; rate_option: string; final_total: number | null; }>; quoted_carrier: string | null; quoted_total: number | null; created_at: string; completed_at: string | null; }; function cargoJsonToRecord(cargoJson: unknown) { if (!cargoJson || typeof cargoJson !== "object") { return null; } const c = cargoJson as Record; return { weightLb: c.weight_lb as number | undefined, palletCount: c.pallet_count as number | undefined, cargoType: c.cargo_type as string | undefined, dimLIn: c.dim_l_in as number | undefined, dimWIn: c.dim_w_in as number | undefined, dimHIn: c.dim_h_in as number | undefined, }; } function toFiniteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } export function extractQuotedList(quotesJson: unknown): Array<{ carrier: string; service_level: string; rate_option: string; final_total: number | null; }> { if (!Array.isArray(quotesJson) || quotesJson.length === 0) { return []; } const quotes = filterQuotesForMotherShipUiDisplay( quotesJson.filter((item): item is Record => { return !!item && typeof item === "object"; }) as Array<{ service_level: string; rate_option: string; carrier?: string; final_total?: number; raw_total?: number; }>, ); return quotes.map((q) => ({ carrier: typeof q.carrier === "string" && q.carrier.trim() ? q.carrier.trim() : "", service_level: String(q.service_level ?? ""), rate_option: String(q.rate_option ?? ""), final_total: toFiniteNumber(q.final_total) ?? toFiniteNumber(q.raw_total), })); } function pickLowestQuote( quotes: ReturnType, ): { quotedCarrier: string | null; quotedTotal: number | null } { let lowest: (typeof quotes)[number] | null = null; for (const q of quotes) { if (q.final_total == null) continue; if (lowest?.final_total == null || q.final_total < lowest.final_total) { lowest = q; } } if (!lowest) { return { quotedCarrier: null, quotedTotal: null }; } return { quotedCarrier: lowest.carrier || null, quotedTotal: lowest.final_total, }; } function serializeQueryLog(row: { quoteId: string; requestId: string; customerId: string; businessCustomerId?: string | null; outcome: string; failureReason: string | null; errorCode: string | null; sourceType: string | null; tierCount: number | null; pickupJson: unknown; deliveryJson: unknown; cargoJson: unknown; quotesJson?: unknown; createdAt: Date; completedAt: Date | null; }): QuoteQueryLogDto { const cargoRecord = cargoJsonToRecord(row.cargoJson); const quotes = extractQuotedList(row.quotesJson); const quoted = pickLowestQuote(quotes); return { quote_id: row.quoteId, request_id: row.requestId, customer_id: row.customerId, business_customer_id: row.businessCustomerId ?? null, outcome: row.outcome as QuoteQueryOutcome, failure_reason: row.failureReason, error_code: row.errorCode, source_type: row.sourceType, tier_count: row.tierCount, pickup_customer: formatCustomerAddressForAdmin(row.pickupJson), pickup_selected: formatSelectedAddressForAdmin(row.pickupJson), delivery_customer: formatCustomerAddressForAdmin(row.deliveryJson), delivery_selected: formatSelectedAddressForAdmin(row.deliveryJson), cargo_summary: formatCargoForAdmin(row.cargoJson, cargoRecord as never), quotes, quoted_carrier: quoted.quotedCarrier, quoted_total: quoted.quotedTotal, created_at: row.createdAt.toISOString(), completed_at: row.completedAt?.toISOString() ?? null, }; } export async function listQuoteQueryLogs(options: { page: number; size: number; customerId?: string; businessCustomerId?: string; outcome?: QuoteQueryOutcome; }): Promise<{ list: QuoteQueryLogDto[]; total: number; page: number; size: number; }> { const where = { ...(options.customerId ? { customerId: options.customerId } : {}), ...(options.businessCustomerId ? { businessCustomerId: options.businessCustomerId } : {}), ...(options.outcome ? { outcome: options.outcome } : {}), }; const [total, rows] = await Promise.all([ prisma.quoteQueryLog.count({ where }), prisma.quoteQueryLog.findMany({ where, orderBy: { createdAt: "desc" }, skip: (options.page - 1) * options.size, take: options.size, }), ]); const quoteIds = rows.map((row) => row.quoteId); const quoteRecords = quoteIds.length > 0 ? await prisma.quoteRecord.findMany({ where: { quoteId: { in: quoteIds } }, select: { quoteId: true, quotesJson: true }, }) : []; const quotesById = new Map( quoteRecords.map((record) => [record.quoteId, record.quotesJson]), ); return { list: rows.map((row) => serializeQueryLog({ ...row, quotesJson: quotesById.get(row.quoteId), }), ), total, page: options.page, size: options.size, }; }