|
|
import type { Prisma } from "@prisma/client";
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
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 {
|
|
|
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,
|
|
|
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;
|
|
|
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;
|
|
|
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 serializeQueryLog(row: {
|
|
|
quoteId: string;
|
|
|
requestId: string;
|
|
|
customerId: string;
|
|
|
outcome: string;
|
|
|
failureReason: string | null;
|
|
|
errorCode: string | null;
|
|
|
sourceType: string | null;
|
|
|
tierCount: number | null;
|
|
|
pickupJson: unknown;
|
|
|
deliveryJson: unknown;
|
|
|
cargoJson: unknown;
|
|
|
createdAt: Date;
|
|
|
completedAt: Date | null;
|
|
|
}): QuoteQueryLogDto {
|
|
|
const cargoRecord = cargoJsonToRecord(row.cargoJson);
|
|
|
return {
|
|
|
quote_id: row.quoteId,
|
|
|
request_id: row.requestId,
|
|
|
customer_id: row.customerId,
|
|
|
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),
|
|
|
created_at: row.createdAt.toISOString(),
|
|
|
completed_at: row.completedAt?.toISOString() ?? null,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export async function listQuoteQueryLogs(options: {
|
|
|
page: number;
|
|
|
size: number;
|
|
|
customerId?: string;
|
|
|
outcome?: QuoteQueryOutcome;
|
|
|
}): Promise<{
|
|
|
list: QuoteQueryLogDto[];
|
|
|
total: number;
|
|
|
page: number;
|
|
|
size: number;
|
|
|
}> {
|
|
|
const where = {
|
|
|
...(options.customerId ? { customerId: options.customerId } : {}),
|
|
|
...(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,
|
|
|
}),
|
|
|
]);
|
|
|
|
|
|
return {
|
|
|
list: rows.map((row) => serializeQueryLog(row)),
|
|
|
total,
|
|
|
page: options.page,
|
|
|
size: options.size,
|
|
|
};
|
|
|
}
|