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.
177 lines
4.7 KiB
177 lines
4.7 KiB
import { Prisma } from "@prisma/client";
|
|
import { parseAdminAuth } from "@/lib/api/admin-auth-context";
|
|
import { QUOTE_VALIDITY_MS } from "@/lib/constants/quote";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { fail, ok } from "@/lib/response";
|
|
import { writeAlert } from "@/modules/alert/service";
|
|
import { writeAudit } from "@/modules/audit/service";
|
|
import { AuthError } from "@/modules/auth/errors";
|
|
import { requirePermission } from "@/modules/auth/rbac";
|
|
import {
|
|
applyMarkupToQuotes,
|
|
getMarkupRule,
|
|
type RawQuoteTier,
|
|
} from "@/modules/pricing/engine";
|
|
import { getConfidenceScore } from "@/modules/quote/confidence";
|
|
import { assertFourTiers } from "@/modules/quote/quote-completeness";
|
|
import { overwriteL1, setL3 } from "@/modules/cache/redis-cache";
|
|
|
|
type TierInput = {
|
|
service_level?: unknown;
|
|
rate_option?: unknown;
|
|
carrier?: unknown;
|
|
transit_days?: unknown;
|
|
transit_description?: unknown;
|
|
raw_freight?: unknown;
|
|
surcharges?: unknown;
|
|
raw_total?: unknown;
|
|
};
|
|
|
|
type ManualFallbackBody = {
|
|
quote_id?: unknown;
|
|
quotes?: unknown;
|
|
};
|
|
|
|
function parseTier(raw: TierInput): RawQuoteTier | null {
|
|
if (
|
|
typeof raw.service_level !== "string" ||
|
|
typeof raw.rate_option !== "string" ||
|
|
typeof raw.carrier !== "string" ||
|
|
typeof raw.transit_days !== "string" ||
|
|
typeof raw.transit_description !== "string" ||
|
|
typeof raw.raw_freight !== "number" ||
|
|
typeof raw.surcharges !== "number" ||
|
|
typeof raw.raw_total !== "number"
|
|
) {
|
|
return null;
|
|
}
|
|
if (raw.raw_freight <= 0 || raw.raw_total <= 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
service_level: raw.service_level,
|
|
rate_option: raw.rate_option,
|
|
carrier: raw.carrier,
|
|
transit_days: raw.transit_days,
|
|
transit_description: raw.transit_description,
|
|
raw_freight: raw.raw_freight,
|
|
surcharges: raw.surcharges,
|
|
raw_total: raw.raw_total,
|
|
};
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
let auth;
|
|
try {
|
|
auth = parseAdminAuth(request);
|
|
requirePermission(auth, "rpa:operate");
|
|
} catch (error) {
|
|
if (error instanceof AuthError) {
|
|
return fail(error.code, error.message, error.httpStatus);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
let body: ManualFallbackBody;
|
|
try {
|
|
body = (await request.json()) as ManualFallbackBody;
|
|
} catch {
|
|
return fail("VALIDATION_FAILED", "请求体格式无效", 400);
|
|
}
|
|
|
|
const quoteId =
|
|
typeof body.quote_id === "string" ? body.quote_id.trim() : "";
|
|
if (!quoteId) {
|
|
return fail("VALIDATION_FAILED", "请填写 quote_id", 400);
|
|
}
|
|
|
|
if (!Array.isArray(body.quotes) || body.quotes.length !== 4) {
|
|
return fail("VALIDATION_FAILED", "请提供完整四档报价", 400);
|
|
}
|
|
|
|
const rawQuotes: RawQuoteTier[] = [];
|
|
for (const item of body.quotes) {
|
|
const tier = parseTier(item as TierInput);
|
|
if (!tier) {
|
|
return fail("VALIDATION_FAILED", "报价格式无效或金额须大于 0", 400);
|
|
}
|
|
rawQuotes.push(tier);
|
|
}
|
|
|
|
try {
|
|
assertFourTiers(rawQuotes);
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "四档报价不完整";
|
|
return fail("VALIDATION_FAILED", message, 400);
|
|
}
|
|
|
|
const record = await prisma.quoteRecord.findUnique({
|
|
where: { quoteId },
|
|
select: {
|
|
quoteId: true,
|
|
requestId: true,
|
|
customerId: true,
|
|
cargoHash: true,
|
|
status: true,
|
|
},
|
|
});
|
|
|
|
if (!record) {
|
|
return fail("QUOTE_NOT_FOUND", "询价记录不存在", 404);
|
|
}
|
|
|
|
const markupRule = await getMarkupRule(record.customerId);
|
|
const markedQuotes = applyMarkupToQuotes(rawQuotes, markupRule);
|
|
const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS);
|
|
const cachePayload = { quotes: rawQuotes };
|
|
|
|
await setL3(record.cargoHash, cachePayload);
|
|
|
|
await prisma.quoteRecord.update({
|
|
where: { quoteId },
|
|
data: {
|
|
status: "done",
|
|
sourceType: "stale",
|
|
isRealtime: false,
|
|
confidenceScore: getConfidenceScore("stale"),
|
|
markupPercent:
|
|
markupRule.type === "percent" ? markupRule.percent : 0,
|
|
quotesJson: markedQuotes as unknown as Prisma.InputJsonValue,
|
|
validUntil,
|
|
errorCode: null,
|
|
},
|
|
});
|
|
|
|
await overwriteL1(record.requestId, {
|
|
quoteId: record.quoteId,
|
|
response: {
|
|
quote_id: record.quoteId,
|
|
status: "done",
|
|
source_type: "stale",
|
|
is_realtime: false,
|
|
},
|
|
});
|
|
|
|
await writeAudit("quotes:manual_fallback", auth.userId, quoteId, {
|
|
cargo_hash: record.cargoHash,
|
|
customer_id: record.customerId,
|
|
});
|
|
|
|
await writeAlert("MANUAL_FALLBACK", {
|
|
quoteId,
|
|
cargoHash: record.cargoHash,
|
|
detail: {
|
|
operator_id: auth.userId,
|
|
previous_status: record.status,
|
|
},
|
|
});
|
|
|
|
return ok({
|
|
quote_id: quoteId,
|
|
status: "done",
|
|
source_type: "stale",
|
|
is_realtime: false,
|
|
});
|
|
}
|