|
|
import { Prisma } from "@prisma/client";
|
|
|
import { isFlockRpaEnabled } from "@/lib/flock/env";
|
|
|
import { getFlockBatchExclusiveBusyMessage } from "@/lib/flock/exclusive-lock";
|
|
|
import { FLOCK_UI } from "@/lib/flock/ui-labels";
|
|
|
import { prisma } from "@/lib/prisma";
|
|
|
import { hashFlockInput } from "@/modules/flock/hash";
|
|
|
import { enqueueFlockQuoteJob } from "@/modules/flock/rpa-queue";
|
|
|
import { validateFlockQuoteInput } from "@/modules/flock/validation";
|
|
|
import {
|
|
|
generateQuoteId,
|
|
|
handleQuoteIdConflict,
|
|
|
isQuoteIdUniqueConflict,
|
|
|
} from "@/modules/quote/quote-id";
|
|
|
import { markQuoteStarted, safeRecord } from "@/modules/metrics/collector";
|
|
|
import { buildFlockProgressPayload } from "@/lib/flock/rpa-progress";
|
|
|
import type { FlockQuoteInput } from "@/workers/rpa/flock/types";
|
|
|
|
|
|
export class FlockEnqueueError extends Error {
|
|
|
readonly code = "QUOTE_UNAVAILABLE" as const;
|
|
|
|
|
|
constructor(message: string) {
|
|
|
super(message);
|
|
|
this.name = "FlockEnqueueError";
|
|
|
}
|
|
|
}
|
|
|
|
|
|
export type FlockSubmitResult = {
|
|
|
quote_id: string;
|
|
|
status: "processing";
|
|
|
};
|
|
|
|
|
|
function zipJson(
|
|
|
zip: string,
|
|
|
input: FlockQuoteInput,
|
|
|
side: "pickup" | "delivery",
|
|
|
): Prisma.InputJsonValue {
|
|
|
return {
|
|
|
street: "",
|
|
|
city: "",
|
|
|
state: "",
|
|
|
zip,
|
|
|
place_id: "",
|
|
|
formatted_address: zip,
|
|
|
selected_from_suggestions: false,
|
|
|
provider: "flock",
|
|
|
...(side === "pickup"
|
|
|
? {
|
|
|
flock_pickup_date: input.pickupDate,
|
|
|
flock_pallet_count: input.palletCount,
|
|
|
flock_total_weight_lb: input.totalWeightLb,
|
|
|
}
|
|
|
: {}),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/** Flock Freight 询价入队 */
|
|
|
export async function submitFlockQuote(body: unknown): Promise<FlockSubmitResult> {
|
|
|
if (!isFlockRpaEnabled()) {
|
|
|
throw new FlockEnqueueError(FLOCK_UI.disabledHint);
|
|
|
}
|
|
|
|
|
|
// 仅 batch 探针占锁时拒绝入队;Worker 查价中仍可排队(本机串行锁保证不互踩)
|
|
|
const busy = getFlockBatchExclusiveBusyMessage();
|
|
|
if (busy) {
|
|
|
throw new FlockEnqueueError(busy);
|
|
|
}
|
|
|
|
|
|
const parsed = validateFlockQuoteInput(body);
|
|
|
const { request_id, customer_id, ...input } = parsed;
|
|
|
const cargoHash = hashFlockInput(input);
|
|
|
let quoteId = await generateQuoteId();
|
|
|
|
|
|
const baseRecord = {
|
|
|
requestId: request_id,
|
|
|
customerId: customer_id,
|
|
|
cargoHash,
|
|
|
status: "processing" as const,
|
|
|
pickupJson: zipJson(input.pickupZip, input, "pickup"),
|
|
|
deliveryJson: zipJson(input.deliveryZip, input, "delivery"),
|
|
|
weightLb: input.totalWeightLb,
|
|
|
dimLIn: input.lengthIn,
|
|
|
dimWIn: input.widthIn,
|
|
|
dimHIn: input.heightIn,
|
|
|
palletCount: input.palletCount,
|
|
|
cargoType: "general_freight",
|
|
|
quotesJson: buildFlockProgressPayload(
|
|
|
"queued",
|
|
|
) as unknown as Prisma.InputJsonValue,
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
await prisma.quoteRecord.create({
|
|
|
data: { quoteId, ...baseRecord },
|
|
|
});
|
|
|
} catch (error) {
|
|
|
if (!isQuoteIdUniqueConflict(error)) {
|
|
|
throw error;
|
|
|
}
|
|
|
quoteId = await generateQuoteId();
|
|
|
try {
|
|
|
await prisma.quoteRecord.create({
|
|
|
data: { quoteId, ...baseRecord },
|
|
|
});
|
|
|
} catch (retryError) {
|
|
|
if (isQuoteIdUniqueConflict(retryError)) {
|
|
|
await handleQuoteIdConflict(quoteId, customer_id);
|
|
|
}
|
|
|
throw retryError;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
await enqueueFlockQuoteJob({
|
|
|
quoteId,
|
|
|
requestId: request_id,
|
|
|
customerId: customer_id,
|
|
|
cargoHash,
|
|
|
input,
|
|
|
});
|
|
|
} catch (error) {
|
|
|
console.error("[flock] 入队失败:", error);
|
|
|
await prisma.quoteRecord.update({
|
|
|
where: { quoteId },
|
|
|
data: {
|
|
|
status: "failed",
|
|
|
errorCode: "QUOTE_UNAVAILABLE",
|
|
|
errorMessage: "Flock 报价队列暂不可用",
|
|
|
},
|
|
|
});
|
|
|
throw new FlockEnqueueError("Flock 报价服务暂不可用,请稍后重试");
|
|
|
}
|
|
|
|
|
|
safeRecord(() => markQuoteStarted(quoteId));
|
|
|
return { quote_id: quoteId, status: "processing" };
|
|
|
}
|