import { Prisma } from "@prisma/client"; import { getL2, setL2, setL3 } from "@/modules/cache/redis-cache"; import { QUOTE_VALIDITY_MS } from "@/lib/constants/quote"; import { prisma } from "@/lib/prisma"; import { fetchAxelQuoteItems } from "@/lib/axel/quote-from-request"; import { fetchMothershipLoggedInDirectQuote, isMsDashboardDirectQuoteEnabled, readMothershipIdToken, resolveLoggedInStoragePath, type MsStorageState, } from "@/lib/mothership/dashboard-direct-quote"; import { isAxelDirectQuoteMode, isInlineDirectQuoteEnabled } from "@/lib/rpa/env"; import fs from "node:fs"; import { runWithMothershipLoginContext, } from "@/lib/rpa/mothership-login-context"; import { getConfidenceScore } from "@/modules/quote/confidence"; import { applyMarkupToQuotes, getMarkupRule, type RawQuoteTier, } from "@/modules/pricing/engine"; import { checkIdempotency, saveIdempotency, } from "@/modules/quote/idempotency"; import { prepareMotherShipStorageQuotes } from "@/modules/quote/quote-completeness"; import { generateQuoteId, handleQuoteIdConflict, isQuoteIdUniqueConflict, } from "@/modules/quote/quote-id"; import { enqueueQuoteJob } from "@/modules/quote/rpa-queue"; import { createMsRefineHold } from "@/lib/mothership/refine-hold-store"; import { recordQuoteQueryOutcome, recordQuoteQueryStart, } from "@/modules/quote/query-log"; import type { L2CachePayload, NormalizedCargo, QuoteSubmitResult, } from "@/modules/quote/types"; import { markQuoteStarted, recordCacheHit, recordPostDone, recordPostTotal, recordRpaTriggered, safeRecord, } from "@/modules/metrics/collector"; import { cargoToQuoteRequest, quoteItemsToRawTiers } from "@/modules/rpa/quote-mapper"; import { validateQuoteInput } from "@/modules/quote/validation"; import { assertBusinessCustomerBelongsToTenant } from "@/modules/customer/business-customer-service"; import { resolveBusinessCustomerIdForQuote } from "@/modules/customer/business-customer-user-service"; import { getCustomerProviderLogin, hasCustomerProviderCredential } from "@/modules/customer/provider-credentials"; 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 { const cargo = validateQuoteInput(body); cargo.businessCustomerId = await resolveBusinessCustomerIdForQuote({ customerId: cargo.customerId, businessCustomerId: cargo.businessCustomerId, businessUserAccount: cargo.businessUserAccount, }); await assertBusinessCustomerBelongsToTenant( cargo.customerId, cargo.businessCustomerId, ); 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 hasMsCredInDb = await hasCustomerProviderCredential( cargo.customerId, "mothership", ); const customerMsLogin = hasMsCredInDb ? await getCustomerProviderLogin(cargo.customerId, "mothership") : null; const envMsLogin = Boolean(process.env.MOTHERSHIP_EMAIL?.trim()) && Boolean(process.env.MOTHERSHIP_PASSWORD?.trim()); // 库中已绑账密或 env 账密 → 强制登录态查价(禁止匿名 Direct / L2) const forceLoggedInQuote = hasMsCredInDb || envMsLogin; if (hasMsCredInDb && !customerMsLogin) { console.error( `[quote] customerId=${cargo.customerId} 已配置 MotherShip 账密但解密失败,仍入队登录态路径(禁止 Direct)`, ); } else if (forceLoggedInQuote) { console.log( `[quote] customerId=${cargo.customerId} MotherShip 登录态查价 source=${customerMsLogin ? "customer" : "env"} email=${(customerMsLogin?.email ?? process.env.MOTHERSHIP_EMAIL ?? "").slice(0, 2)}***`, ); } // 有账密:禁止命中匿名 Direct 缓存价(TForce 档 ≠ dashboard 承运商价) const l2 = forceLoggedInQuote ? null : await getL2(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; } if ( !forceLoggedInQuote && isAxelDirectQuoteMode() && isInlineDirectQuoteEnabled() ) { const inline = await tryInlineDirectQuote(cargo); if (inline) { safeRecord(() => recordPostDone({ quoteId: inline.quote_id, sourceType: "rpa", isRealtime: true, }), ); return inline; } } // 登录态:若已有 idToken,API 内联 Direct(不入队、不开浏览器);失败再入队 if ( forceLoggedInQuote && isMsDashboardDirectQuoteEnabled() && isInlineDirectQuoteEnabled() ) { const inlineLoggedIn = await tryInlineLoggedInDirectQuote(cargo, { email: customerMsLogin?.email ?? process.env.MOTHERSHIP_EMAIL?.trim() ?? "", password: customerMsLogin?.password ?? process.env.MOTHERSHIP_PASSWORD?.trim() ?? "", }); if (inlineLoggedIn) { safeRecord(() => recordPostDone({ quoteId: inlineLoggedIn.quote_id, sourceType: "rpa", isRealtime: true, }), ); return inlineLoggedIn; } } const result = await enqueueForRpa(cargo); safeRecord(() => recordRpaTriggered(result.quote_id)); return result; } async function completeFromCache( cargo: NormalizedCargo, l2: L2CachePayload, ): Promise { const quoteId = await generateQuoteId(); safeRecord(() => markQuoteStarted(quoteId)); const uiQuotes = prepareMotherShipStorageQuotes(l2.quotes as RawQuoteTier[]); const markupRule = await getMarkupRule( cargo.customerId, cargo.businessCustomerId, ); const markedQuotes = applyMarkupToQuotes(uiQuotes, 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, businessCustomerId: cargo.businessCustomerId ?? null, 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, cargo.businessCustomerId ?? null, quoteId, response, ); await recordQuoteQueryStart(cargo, quoteId); await recordQuoteQueryOutcome(quoteId, "success", { sourceType: "cache", tierCount: markedQuotes.length, }); return response; } async function tryInlineDirectQuote( cargo: NormalizedCargo, ): Promise { const quoteId = await generateQuoteId(); safeRecord(() => markQuoteStarted(quoteId)); try { // 匿名 inline Direct 仅无匿名会话;有账密已在 submitQuote 跳过本路径 const items = await fetchAxelQuoteItems(cargoToQuoteRequest(cargo)); const rawQuotes = quoteItemsToRawTiers(items); const uiQuotes = prepareMotherShipStorageQuotes(rawQuotes); const markupRule = await getMarkupRule( cargo.customerId, cargo.businessCustomerId, ); const markedQuotes = applyMarkupToQuotes(uiQuotes, markupRule); const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS); const cachePayload = { quotes: uiQuotes }; await setL2(cargo.cargoHash, cachePayload); await setL3(cargo.cargoHash, cachePayload); const response: SubmitQuoteResponse = { quote_id: quoteId, status: "done", source_type: "rpa", is_realtime: true, }; try { await prisma.quoteRecord.create({ data: { quoteId, requestId: cargo.requestId, customerId: cargo.customerId, businessCustomerId: cargo.businessCustomerId ?? null, cargoHash: cargo.cargoHash, status: "done", sourceType: "rpa", isRealtime: true, confidenceScore: getConfidenceScore("rpa"), 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, cargo.businessCustomerId ?? null, quoteId, response, ); await recordQuoteQueryStart(cargo, quoteId); await recordQuoteQueryOutcome(quoteId, "success", { sourceType: "rpa", tierCount: markedQuotes.length, }); return response; } catch (error) { const brief = error instanceof Error ? error.message : String(error); console.warn( `[quote] inline-direct 失败,回退队列 cargo_hash=${cargo.cargoHash} reason=${brief.slice(0, 200)}`, ); return null; } } function hasUsableLoggedInIdToken(): boolean { try { const path = resolveLoggedInStoragePath(); if (!fs.existsSync(path)) return false; const state = JSON.parse(fs.readFileSync(path, "utf8")) as MsStorageState; return Boolean(readMothershipIdToken(state)); } catch { return false; } } /** 登录态 inline:仅在已有 idToken 时直连(禁止在 Next API 内 bootstrap 浏览器) */ async function tryInlineLoggedInDirectQuote( cargo: NormalizedCargo, login: { email: string; password: string }, ): Promise { if (!login.email || !login.password) return null; if (!hasUsableLoggedInIdToken()) { console.log( "[quote] inline-ms-direct 跳过:无可用 idToken,入队由 Worker Direct/DOM", ); return null; } const quoteId = await generateQuoteId(); safeRecord(() => markQuoteStarted(quoteId)); try { const items = await runWithMothershipLoginContext( { email: login.email, password: login.password }, "env", () => fetchMothershipLoggedInDirectQuote(cargoToQuoteRequest(cargo), { allowBootstrap: false, }), ); const rawQuotes = quoteItemsToRawTiers(items); const uiQuotes = prepareMotherShipStorageQuotes(rawQuotes); const markupRule = await getMarkupRule( cargo.customerId, cargo.businessCustomerId, ); const markedQuotes = applyMarkupToQuotes(uiQuotes, markupRule); const validUntil = new Date(Date.now() + QUOTE_VALIDITY_MS); const response: SubmitQuoteResponse = { quote_id: quoteId, status: "done", source_type: "rpa", is_realtime: true, }; try { await prisma.quoteRecord.create({ data: { quoteId, requestId: cargo.requestId, customerId: cargo.customerId, businessCustomerId: cargo.businessCustomerId ?? null, cargoHash: cargo.cargoHash, status: "done", sourceType: "rpa", isRealtime: true, confidenceScore: getConfidenceScore("rpa"), 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, cargo.businessCustomerId ?? null, quoteId, response, ); await recordQuoteQueryStart(cargo, quoteId); await recordQuoteQueryOutcome(quoteId, "success", { sourceType: "rpa", tierCount: markedQuotes.length, }); console.log( `[quote] inline-ms-direct OK quote_id=${quoteId} tiers=${markedQuotes.length}`, ); const sessionId = cargo.quoteSessionId?.trim(); if (sessionId) { await createMsRefineHold({ quoteId, sessionId, customerId: cargo.customerId, }).catch((err) => { const brief = err instanceof Error ? err.message : String(err); console.warn( `[quote] inline-ms-direct hold 写入失败 quote_id=${quoteId} ${brief.slice(0, 160)}`, ); }); } return response; } catch (error) { const brief = error instanceof Error ? error.message : String(error); console.warn( `[quote] inline-ms-direct 失败,回退队列 cargo_hash=${cargo.cargoHash} reason=${brief.slice(0, 200)}`, ); return null; } } async function enqueueForRpa( cargo: NormalizedCargo, ): Promise { const quoteId = await generateQuoteId(); const markupRule = await getMarkupRule( cargo.customerId, cargo.businessCustomerId, ); const response: SubmitQuoteResponse = { quote_id: quoteId, status: "processing", }; try { await prisma.quoteRecord.create({ data: { quoteId, requestId: cargo.requestId, customerId: cargo.customerId, businessCustomerId: cargo.businessCustomerId ?? null, 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, cargo.businessCustomerId ?? null, quoteId, response, ); await enqueueQuoteJob({ quoteId, requestId: cargo.requestId, customerId: cargo.customerId, businessCustomerId: cargo.businessCustomerId, cargoHash: cargo.cargoHash, cargo, }); await recordQuoteQueryStart(cargo, quoteId); return response; } export type { NormalizedCargo, QuoteSubmitResult };