|
|
import { Prisma } from "@prisma/client";
|
|
|
import { getL2 } from "@/modules/cache/redis-cache";
|
|
|
import { QUOTE_VALIDITY_MS } from "@/lib/constants/quote";
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
import { getConfidenceScore } from "@/modules/quote/confidence";
|
|
|
import {
|
|
|
applyMarkupToQuotes,
|
|
|
getMarkupRule,
|
|
|
type RawQuoteTier,
|
|
|
} from "@/modules/pricing/engine";
|
|
|
import {
|
|
|
checkIdempotency,
|
|
|
saveIdempotency,
|
|
|
} from "@/modules/quote/idempotency";
|
|
|
import { assertFourTiers } from "@/modules/quote/quote-completeness";
|
|
|
import {
|
|
|
generateQuoteId,
|
|
|
handleQuoteIdConflict,
|
|
|
isQuoteIdUniqueConflict,
|
|
|
} from "@/modules/quote/quote-id";
|
|
|
import { enqueueQuoteJob } from "@/modules/quote/rpa-queue";
|
|
|
import type {
|
|
|
L2CachePayload,
|
|
|
NormalizedCargo,
|
|
|
QuoteSubmitResult,
|
|
|
} from "@/modules/quote/types";
|
|
|
import {
|
|
|
markQuoteStarted,
|
|
|
recordCacheHit,
|
|
|
recordPostDone,
|
|
|
recordPostTotal,
|
|
|
recordRpaTriggered,
|
|
|
safeRecord,
|
|
|
} from "@/modules/metrics/collector";
|
|
|
import { validateQuoteInput } from "@/modules/quote/validation";
|
|
|
|
|
|
export type SubmitQuoteResponse = {
|
|
|
quote_id: string;
|
|
|
status: "processing" | "done";
|
|
|
source_type?: "cache" | "rpa" | "stale";
|
|
|
is_realtime?: boolean;
|
|
|
};
|
|
|
|
|
|
/** 询价主编排:校验 → L1 → L2 → 入队(task-014) */
|
|
|
export async function submitQuote(
|
|
|
body: unknown,
|
|
|
): Promise<SubmitQuoteResponse> {
|
|
|
const cargo = validateQuoteInput(body);
|
|
|
safeRecord(() => recordPostTotal());
|
|
|
|
|
|
const idem = await checkIdempotency(cargo.requestId);
|
|
|
if (idem.hit) {
|
|
|
safeRecord(() => recordCacheHit("l1"));
|
|
|
const cached = idem.response as SubmitQuoteResponse;
|
|
|
if (cached?.status === "done") {
|
|
|
safeRecord(() =>
|
|
|
recordPostDone({
|
|
|
quoteId: idem.quoteId,
|
|
|
sourceType: cached.source_type ?? "cache",
|
|
|
isRealtime: cached.is_realtime ?? true,
|
|
|
}),
|
|
|
);
|
|
|
}
|
|
|
return {
|
|
|
quote_id: idem.quoteId,
|
|
|
status: (cached?.status as "processing" | "done") ?? "done",
|
|
|
source_type: cached?.source_type,
|
|
|
is_realtime: cached?.is_realtime,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
const l2 = await getL2<L2CachePayload>(cargo.cargoHash);
|
|
|
if (l2?.quotes?.length) {
|
|
|
const result = await completeFromCache(cargo, l2);
|
|
|
safeRecord(() => recordCacheHit("l2"));
|
|
|
safeRecord(() =>
|
|
|
recordPostDone({
|
|
|
quoteId: result.quote_id,
|
|
|
sourceType: "cache",
|
|
|
isRealtime: true,
|
|
|
}),
|
|
|
);
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
const result = await enqueueForRpa(cargo);
|
|
|
safeRecord(() => recordRpaTriggered(result.quote_id));
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
async function completeFromCache(
|
|
|
cargo: NormalizedCargo,
|
|
|
l2: L2CachePayload,
|
|
|
): Promise<SubmitQuoteResponse> {
|
|
|
const quoteId = await generateQuoteId();
|
|
|
safeRecord(() => markQuoteStarted(quoteId));
|
|
|
|
|
|
assertFourTiers(l2.quotes);
|
|
|
|
|
|
const markupRule = await getMarkupRule(cargo.customerId);
|
|
|
const markedQuotes = applyMarkupToQuotes(
|
|
|
l2.quotes as RawQuoteTier[],
|
|
|
markupRule,
|
|
|
);
|
|
|
const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS);
|
|
|
|
|
|
const response: SubmitQuoteResponse = {
|
|
|
quote_id: quoteId,
|
|
|
status: "done",
|
|
|
source_type: "cache",
|
|
|
is_realtime: true,
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
await prisma.quoteRecord.create({
|
|
|
data: {
|
|
|
quoteId,
|
|
|
requestId: cargo.requestId,
|
|
|
customerId: cargo.customerId,
|
|
|
cargoHash: cargo.cargoHash,
|
|
|
status: "done",
|
|
|
sourceType: "cache",
|
|
|
isRealtime: true,
|
|
|
confidenceScore: getConfidenceScore("cache"),
|
|
|
pickupJson: cargo.pickupAddress,
|
|
|
deliveryJson: cargo.deliveryAddress,
|
|
|
weightLb: cargo.weightLb,
|
|
|
dimLIn: cargo.dimLIn,
|
|
|
dimWIn: cargo.dimWIn,
|
|
|
dimHIn: cargo.dimHIn,
|
|
|
palletCount: cargo.palletCount,
|
|
|
cargoType: cargo.cargoType,
|
|
|
quotesJson: markedQuotes as unknown as Prisma.InputJsonValue,
|
|
|
markupPercent:
|
|
|
markupRule.type === "percent" ? markupRule.percent : 0,
|
|
|
validUntil,
|
|
|
},
|
|
|
});
|
|
|
} catch (error) {
|
|
|
if (isQuoteIdUniqueConflict(error)) {
|
|
|
await handleQuoteIdConflict(quoteId, cargo.customerId);
|
|
|
}
|
|
|
throw error;
|
|
|
}
|
|
|
|
|
|
await saveIdempotency(
|
|
|
cargo.requestId,
|
|
|
cargo.customerId,
|
|
|
quoteId,
|
|
|
response,
|
|
|
);
|
|
|
|
|
|
return response;
|
|
|
}
|
|
|
|
|
|
async function enqueueForRpa(
|
|
|
cargo: NormalizedCargo,
|
|
|
): Promise<SubmitQuoteResponse> {
|
|
|
const quoteId = await generateQuoteId();
|
|
|
const markupRule = await getMarkupRule(cargo.customerId);
|
|
|
|
|
|
const response: SubmitQuoteResponse = {
|
|
|
quote_id: quoteId,
|
|
|
status: "processing",
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
await prisma.quoteRecord.create({
|
|
|
data: {
|
|
|
quoteId,
|
|
|
requestId: cargo.requestId,
|
|
|
customerId: cargo.customerId,
|
|
|
cargoHash: cargo.cargoHash,
|
|
|
status: "processing",
|
|
|
isRealtime: true,
|
|
|
pickupJson: cargo.pickupAddress,
|
|
|
deliveryJson: cargo.deliveryAddress,
|
|
|
weightLb: cargo.weightLb,
|
|
|
dimLIn: cargo.dimLIn,
|
|
|
dimWIn: cargo.dimWIn,
|
|
|
dimHIn: cargo.dimHIn,
|
|
|
palletCount: cargo.palletCount,
|
|
|
cargoType: cargo.cargoType,
|
|
|
markupPercent:
|
|
|
markupRule.type === "percent" ? markupRule.percent : 0,
|
|
|
},
|
|
|
});
|
|
|
} catch (error) {
|
|
|
if (isQuoteIdUniqueConflict(error)) {
|
|
|
await handleQuoteIdConflict(quoteId, cargo.customerId);
|
|
|
}
|
|
|
throw error;
|
|
|
}
|
|
|
|
|
|
await saveIdempotency(
|
|
|
cargo.requestId,
|
|
|
cargo.customerId,
|
|
|
quoteId,
|
|
|
response,
|
|
|
);
|
|
|
|
|
|
await enqueueQuoteJob({
|
|
|
quoteId,
|
|
|
requestId: cargo.requestId,
|
|
|
customerId: cargo.customerId,
|
|
|
cargoHash: cargo.cargoHash,
|
|
|
cargo,
|
|
|
});
|
|
|
|
|
|
return response;
|
|
|
}
|
|
|
|
|
|
export type { NormalizedCargo, QuoteSubmitResult };
|