You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

201 lines
5.5 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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,
};
}