|
|
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<void> {
|
|
|
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<void> {
|
|
|
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<void> {
|
|
|
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<string, unknown>;
|
|
|
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<string, unknown> => {
|
|
|
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<typeof extractQuotedList>,
|
|
|
): { 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,
|
|
|
};
|
|
|
}
|