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.

1136 lines
34 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 { RpaError } from "@/modules/rpa/errors";
import {
captureSelectionTrace,
drainSelectionTraces,
formatSelectionTraceEvent,
logSelectionTrace,
} from "@/workers/rpa/selection-trace";
import {
AxelSelectionBridge,
formatPlaceCommitFailure,
normalizeAxelPlaceId,
} from "@/workers/rpa/axel-selection-bridge";
import { commitPlaceWithStrategy } from "@/workers/rpa/fix-place";
import {
clickMothershipStyledSuggestion,
reopenStyledSuggestionDropdown,
tryStyledKeyboardCommit,
waitForStyledSuggestionRows,
} from "@/workers/rpa/mothership-styled-suggestion-click";
import {
dismissCookieConsent,
scrollQuoteWidgetIntoView,
} from "@/workers/rpa/page-prep";
import { isRpaVisualRushMode, isDualAddressPatchEnabled } from "@/lib/rpa/env";
import { commitViaAxelPlacePrefetch, fetchAxelPlaceLocation } from "@/workers/rpa/axel-place-prefetch";
import {
applyDeliveryDualAddressPatch,
applyPickupDualAddressPatch,
preservePickupInWidget,
releasePickupInputInWidget,
} from "@/workers/rpa/inject/dual-address-patch";
import {
dismissAddressSuggestionDropdown,
isAddressPresentInWidget,
isPickupReadyForDeliveryInput,
lockPickupIntoChip,
readWidgetCombinedText,
releasePickupFieldForDelivery,
widgetContainsConfirmedAddress,
} from "@/workers/rpa/address-commit";
import {
buildAddressBindingLabels,
clickSuggestionByLabel,
ensureAddressSuggestions,
ensureSuggestionDropdownOpen,
selectAddressWithPlaceBinding,
} from "@/workers/rpa/address-suggestions";
import { AddressCommitStateMachine } from "@/workers/rpa/address-adapter/commit-state-machine";
import {
createEmptyDiagnostics,
buildNetworkDiagnostics,
formatDiagnosticsFailure,
logAddressSelectDiagnostics,
resolvePlaceFailureStatus,
} from "@/workers/rpa/address-adapter/diagnostics";
import {
isAddressSuggestionDropdownOpen,
waitForAddressSuggestionLocked,
} from "@/workers/rpa/address-suggestions";
import type {
AddressAdapter,
AddressCommitResult,
AddressSelectDiagnostics,
SelectAddressParams,
} from "@/workers/rpa/address-adapter/types";
export class MotherShipAddressAdapter implements AddressAdapter {
private readonly stateMachine = new AddressCommitStateMachine();
private bridge: AxelSelectionBridge | null = null;
private result: AddressCommitResult | null = null;
private diagnostics: AddressSelectDiagnostics = createEmptyDiagnostics("");
getCommitState() {
return this.stateMachine.getState();
}
getCommittedAddress(): AddressCommitResult | null {
return this.result;
}
getDiagnostics(): AddressSelectDiagnostics {
return {
...this.diagnostics,
commitState: this.stateMachine.getState(),
};
}
async selectAddress(params: SelectAddressParams): Promise<AddressCommitResult> {
const { ctx, side, address, street } = params;
const page = ctx.page;
const label = address.mothershipDisplayLabel?.trim();
if (!label) {
this.fail(
params,
"selection_failed",
"须在 MotherShip 多候选地址中由用户确认后再询价",
);
}
const queryHint = {
street: address.street,
city: address.city ?? "",
state: address.state ?? "",
zip: address.zip ?? "",
};
this.diagnostics = createEmptyDiagnostics(label!, "EMPTY");
this.diagnostics.selectionTraces = [];
this.stateMachine.transition("TYPING");
this.bridge = new AxelSelectionBridge();
this.bridge.attach(page);
this.bridge.clearCandidates();
this.diagnostics.events.push("bridge-attached");
try {
await dismissCookieConsent(page);
await scrollQuoteWidgetIntoView(page);
await ensureAddressSuggestions(page, street, queryHint, {
mothershipLabel: label!,
preferConfirmedLabel: true,
maxQueries:
params.side === "delivery" && params.pickupAnchor ? 1 : undefined,
});
this.diagnostics.events.push("suggestions-typed");
await this.recordSelectionTrace(page, params.side, "after-suggestions-typed");
if (!this.bridge.hasSearchResults()) {
this.fail(
params,
"selection_failed",
"axel/location/search 未返回 googlePlacesResults",
);
}
await this.bridge.waitForSearchResults(
params.side === "delivery" && params.pickupAnchor ? 1_500 : 8_000,
);
this.stateMachine.transition("SUGGESTIONS_READY");
const preknownPlaceId = params.address.selectedFromMothership
? (params.address.mothershipOptionId ?? params.address.placeId)?.trim()
: "";
const preknownLabel =
params.address.mothershipDisplayLabel?.trim() || label!;
let binding = null;
if (preknownPlaceId) {
binding = {
label: preknownLabel,
placeId: preknownPlaceId,
description: preknownLabel,
};
this.diagnostics.events.push(
`binding-preknown:${preknownPlaceId.slice(0, 16)}`,
);
} else {
for (const probeLabel of buildAddressBindingLabels(queryHint, label!)) {
binding = this.bridge.resolveSelection(probeLabel, queryHint);
if (binding?.placeId?.trim()) {
break;
}
}
}
if (!binding?.placeId?.trim()) {
this.fail(
params,
"selection_failed",
`search 响应中未找到与确认项绑定的 placeId${label}`,
);
}
this.diagnostics.placeId = binding!.placeId;
this.diagnostics.address = label!;
this.diagnostics.events.push(
`binding-resolved:${binding!.placeId.slice(0, 16)}`,
);
await this.recordSelectionTrace(page, params.side, "after-binding-resolved");
console.log(
`[rpa] axel-bridge:${side} placeId=${binding!.placeId.slice(0, 16)}… desc=${binding!.description.slice(0, 48)}`,
);
this.stateMachine.transition("SELECTING");
this.diagnostics.method = "mothership-place-binding";
this.stateMachine.transition("PLACE_LOADING");
const deliveryFast =
params.side === "delivery" && Boolean(params.pickupAnchor?.placeId);
const rush = isRpaVisualRushMode();
if (preknownPlaceId && !rush) {
const syntheticFirst = await this.trySyntheticPreknownCommit(
params,
binding!,
street,
"preknown-first",
);
if (syntheticFirst) {
return syntheticFirst;
}
const deliveryPickupInfoEarly =
params.side === "delivery" && params.pickupAnchor?.placeId
? {
placeId: params.pickupAnchor.placeId,
description: params.pickupAnchor.mothershipDisplayLabel ?? "",
location:
params.ctx.state.axelPlaceLocations.pickup ??
this.bridge!.getPlaceDetails(params.pickupAnchor.placeId) ??
{},
}
: undefined;
const earlyFix = await commitPlaceWithStrategy(
page,
this.bridge!,
binding!.placeId,
binding!.description,
params.side,
deliveryPickupInfoEarly,
{ input: street, query: queryHint },
);
if (earlyFix.ok) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
earlyFix.method,
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
earlyFix.method,
);
}
console.log(
"[rpa] preknown-place: fix-place 未锁定,继续 styled 主路径",
);
const syntheticEarly = await this.trySyntheticPreknownCommit(
params,
binding!,
street,
"preknown",
);
if (syntheticEarly) {
return syntheticEarly;
}
const keyboardPreknown = await tryStyledKeyboardCommit(
page,
street,
this.bridge!,
binding!.placeId,
this.bridge!.getCandidateIndex(binding!.placeId),
);
if (keyboardPreknown) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"preknown-keyboard",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"preknown-keyboard",
);
}
const finalSynthetic = await this.trySyntheticPreknownCommit(
params,
binding!,
street,
"preknown-final",
);
if (finalSynthetic) {
return finalSynthetic;
}
this.fail(
params,
"selection_failed",
`preknown 地址 commit 失败(已跳过误点路径):${label}`,
);
}
const styledRowMs = deliveryFast ? 350 : 2_500;
const styledRowRetryMs = deliveryFast ? 1_000 : 8_000;
const styledRowFinalMs = deliveryFast ? 1_500 : 10_000;
let rowsReady = await waitForStyledSuggestionRows(
page,
[],
queryHint,
styledRowMs,
);
if (!rowsReady) {
await ensureSuggestionDropdownOpen(
page,
street,
queryHint,
binding!.description,
);
rowsReady = await waitForStyledSuggestionRows(
page,
[],
queryHint,
styledRowRetryMs,
);
}
if (!rowsReady && !deliveryFast) {
console.log("[rpa] styled-rows-miss: retry reopen before click");
await reopenStyledSuggestionDropdown(
page,
street,
queryHint,
binding!.description,
);
rowsReady = await waitForStyledSuggestionRows(
page,
[],
queryHint,
styledRowFinalMs,
);
}
if (!rowsReady) {
console.log("[rpa] styled-rows-miss: dropdown still empty before click");
}
let styledClick: {
clicked: boolean;
reason?: string;
matchedText?: string;
method?: string;
};
if (preknownPlaceId && !rush) {
console.log(
"[rpa] preknown-place: 跳过 legacyOnly 鼠标点选,走 keyboard/prefetch",
);
styledClick = { clicked: false, reason: "preknown-skip-legacy-click" };
} else {
styledClick = await clickMothershipStyledSuggestion(
page,
binding!.description,
queryHint,
{
input: street,
candidateIndex: this.bridge!.getCandidateIndex(binding!.placeId),
skipReopen: true,
side: params.side,
bridge: this.bridge!,
placeId: binding!.placeId,
legacyOnly: true,
fastClick: true,
placeCommitTimeoutMs: deliveryFast ? 2_000 : 8_000,
},
);
}
if (rush && styledClick.clicked) {
return this.succeed(
params,
binding!.placeId,
binding!.description,
styledClick.method ?? "mothership-styled-click-rush",
);
}
if (!styledClick.clicked) {
console.log(
`[rpa] mothership-styled-click: 未点选 reason=${styledClick.reason ?? "unknown"}`,
);
if (rush) {
const prefetch = await commitViaAxelPlacePrefetch(
page,
this.bridge!,
binding!.placeId,
binding!.description,
street,
{
query: queryHint,
candidateIndex: this.bridge!.getCandidateIndex(binding!.placeId),
},
);
if (
prefetch.ok &&
(await this.bridge!.waitForPlaceCommit(binding!.placeId, 8_000))
) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"axel-place-prefetch-rush",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"axel-place-prefetch-rush",
);
}
const labelClicked = await clickSuggestionByLabel(
page,
binding!.description,
queryHint,
);
if (labelClicked) {
return this.succeed(
params,
binding!.placeId,
binding!.description,
"click-suggestion-by-label-rush",
);
}
this.fail(
params,
"selection_failed",
"visual-rush地址联想未点中",
);
}
await dismissCookieConsent(page);
await scrollQuoteWidgetIntoView(page, { force: true });
await ensureSuggestionDropdownOpen(
page,
street,
queryHint,
binding!.description,
);
const keyboardCommitted = await tryStyledKeyboardCommit(
page,
street,
this.bridge!,
binding!.placeId,
this.bridge!.getCandidateIndex(binding!.placeId),
);
if (!keyboardCommitted) {
console.log("[rpa] mothership-styled-keyboard:miss");
const prefetch = await commitViaAxelPlacePrefetch(
page,
this.bridge!,
binding!.placeId,
binding!.description,
street,
{
query: queryHint,
candidateIndex: this.bridge!.getCandidateIndex(binding!.placeId),
},
);
if (
prefetch.ok &&
(await this.bridge!.waitForPlaceCommit(binding!.placeId, 8_000))
) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"axel-place-prefetch",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"axel-place-prefetch",
);
}
}
if (keyboardCommitted) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"mothership-styled-keyboard",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"mothership-styled-keyboard",
);
}
const labelClicked = await clickSuggestionByLabel(
page,
binding!.description,
queryHint,
);
if (labelClicked) {
const labelCommitted = await this.bridge!.waitForPlaceCommit(
binding!.placeId,
8_000,
);
if (labelCommitted) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"click-suggestion-by-label",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"click-suggestion-by-label",
);
}
}
}
if (styledClick.clicked) {
const styledCommitted = await this.bridge!.waitForPlaceCommit(
binding!.placeId,
10_000,
);
if (styledCommitted) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"mothership-styled-click",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"mothership-styled-click",
);
}
console.log(
`[rpa] mothership-styled-click: place 请求未观测到,检查视觉状态…`,
);
const allowVisualBypass = !params.address.selectedFromMothership;
if (allowVisualBypass) {
const visuallyConfirmed = await isAddressPresentInWidget(
params.ctx.page,
params.widgetSelector,
params.address,
);
if (visuallyConfirmed) {
console.log(
`[rpa] mothership-styled-click: 视觉已确认「${params.address.mothershipDisplayLabel?.slice(0, 32)}」,跳过网络等待`,
);
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
"mothership-styled-click-visual",
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
"mothership-styled-click-visual",
);
}
}
console.log(
`[rpa] mothership-styled-click: 视觉也未确认,降级 fix-place/legacy`,
);
}
const deliveryPickupInfo =
params.side === "delivery" && params.pickupAnchor?.placeId
? {
placeId: params.pickupAnchor.placeId,
description: params.pickupAnchor.mothershipDisplayLabel ?? "",
location:
params.ctx.state.axelPlaceLocations.pickup ??
this.bridge!.getPlaceDetails(params.pickupAnchor.placeId) ??
{},
}
: undefined;
const fixResult = await commitPlaceWithStrategy(
page,
this.bridge!,
binding!.placeId,
binding!.description,
params.side,
deliveryPickupInfo,
{ input: street, query: queryHint },
);
if (fixResult.ok) {
await this.assertWidgetLockedAfterCommit(
params,
binding!.placeId,
fixResult.method,
);
return this.succeed(
params,
binding!.placeId,
binding!.description,
fixResult.method,
);
}
console.log(
`[rpa] fix-place: ${fixResult.method} 未通过 widget 锁定闸门,继续 legacy`,
);
try {
await selectAddressWithPlaceBinding(
page,
this.bridge,
binding!,
queryHint,
street,
{
side: params.side,
widgetSelector: params.widgetSelector,
pickupAnchor:
params.side === "delivery" ? params.pickupAnchor : undefined,
confirmedAddress: params.address,
ctx: params.ctx,
},
);
} catch (error) {
if (error instanceof RpaError) {
const diag = this.bridge.getPlaceCommitDiagnostics(binding!.placeId);
this.diagnostics.placeRequestObserved =
diag.placeNetworkLog.length > 0;
const status = error.message.includes("未发起 GET place 请求")
? "no_place_request"
: error.message.includes("placeId 不匹配")
? "place_id_mismatch"
: error.message.includes("HTTP")
? "place_http_error"
: "timeout";
this.fail(
params,
status,
formatDiagnosticsFailure({
...this.diagnostics,
status,
commitState: "FAILED",
}),
);
}
throw error;
}
if (this.bridge.hasPlaceCommit(binding!.placeId)) {
await this.assertWidgetLockedAfterCommit(params, binding!.placeId, "legacy");
return this.succeed(
params,
binding!.placeId,
binding!.description,
"legacy",
);
}
const diag = this.bridge.getPlaceCommitDiagnostics(binding!.placeId);
this.diagnostics.placeRequestObserved = diag.placeNetworkLog.length > 0;
const placeLog = diag.placeNetworkLog;
const acceptedIds = this.collectAcceptedPlaceIds(params, binding!.placeId);
const { httpOk: httpOkForAccepted } =
this.bridgeCommitForAcceptedIds(acceptedIds);
const status = resolvePlaceFailureStatus(
placeLog.length > 0,
placeLog.some((e) => e.status >= 200) && !httpOkForAccepted,
placeLog.some((e) => e.status >= 400),
true,
);
const reason = formatPlaceCommitFailure(binding!.placeId, [], diag);
console.log(`[rpa] axel-place-gate: ${reason}`);
const syntheticFallback = await this.trySyntheticPreknownCommit(
params,
binding!,
street,
"legacy-fallback",
);
if (syntheticFallback) {
return syntheticFallback;
}
this.fail(
params,
status,
formatDiagnosticsFailure({
...this.diagnostics,
status,
commitState: "FAILED",
}),
);
} finally {
this.bridge?.detach();
this.bridge = null;
}
return this.result!;
}
async waitForCommit(): Promise<AddressCommitResult> {
if (!this.result?.committed) {
throw new RpaError(
"ADDRESS_NOT_CONFIRMED",
formatDiagnosticsFailure(this.getDiagnostics()),
{ retryable: true },
);
}
return this.result;
}
private async trySyntheticPreknownCommit(
params: SelectAddressParams,
binding: { placeId: string; description: string },
street: import("playwright").Locator,
methodTag: string,
): Promise<AddressCommitResult | null> {
if (!params.address.selectedFromMothership) {
return null;
}
const preknown =
params.address.mothershipOptionId?.trim() ||
params.address.placeId?.trim();
if (!preknown) {
return null;
}
const fetched = await fetchAxelPlaceLocation(
params.ctx.page,
binding.placeId,
);
if (!fetched.ok || !fetched.location) {
return null;
}
const normalized = normalizeAxelPlaceId(binding.placeId);
this.bridge!.setPlaceDetails(normalized, fetched.location);
this.bridge!.recordSyntheticPlaceCommit(normalized, fetched.status);
params.ctx.state.axelPlaceLocations[params.side] = fetched.location;
if (isDualAddressPatchEnabled()) {
if (params.side === "pickup") {
await applyPickupDualAddressPatch(params.ctx.page, {
placeId: binding.placeId,
location: fetched.location,
description: binding.description,
});
await preservePickupInWidget(params.ctx.page);
await releasePickupInputInWidget(params.ctx.page);
await lockPickupIntoChip(
params.ctx,
params.widgetSelector,
params.street,
);
await params.ctx.page.waitForTimeout(600);
await releasePickupFieldForDelivery(
params.ctx,
params.widgetSelector,
params.address,
params.street,
);
} else if (
params.side === "delivery" &&
params.pickupAnchor?.placeId &&
params.ctx.state.axelPlaceLocations.pickup
) {
await applyDeliveryDualAddressPatch(
params.ctx.page,
{
placeId: params.pickupAnchor.placeId,
location: params.ctx.state.axelPlaceLocations.pickup,
description:
params.pickupAnchor.mothershipDisplayLabel?.trim() ||
params.pickupAnchor.formattedAddress?.trim() ||
params.pickupAnchor.street,
},
{
placeId: binding.placeId,
location: fetched.location,
description: binding.description,
},
);
}
} else {
await street.fill(binding.description).catch(() => undefined);
await params.ctx.page.keyboard.press("Escape").catch(() => undefined);
await params.ctx.page.keyboard.press("Tab").catch(() => undefined);
await params.ctx.page.waitForTimeout(400);
}
console.log(
`[rpa] preknown-synthetic: GET place/${normalized.slice(0, 16)} 合成 commit`,
);
await this.assertWidgetLockedAfterCommit(
params,
binding.placeId,
`${methodTag}-synthetic`,
);
return this.succeed(
params,
binding.placeId,
binding.description,
`${methodTag}-synthetic`,
);
}
private collectAcceptedPlaceIds(
params: SelectAddressParams,
bindingPlaceId: string,
): string[] {
const ids = new Set<string>();
const add = (raw?: string) => {
const normalized = normalizeAxelPlaceId(raw?.trim() ?? "");
if (normalized) {
ids.add(normalized);
}
};
add(bindingPlaceId);
add(params.address.mothershipOptionId);
add(params.address.placeId);
for (const candidate of this.bridge?.getCandidates() ?? []) {
add(candidate.placeId);
}
return [...ids];
}
private bridgeCommitForAcceptedIds(acceptedIds: string[]): {
observed: boolean;
httpOk: boolean;
} {
const bridge = this.bridge;
if (!bridge || acceptedIds.length === 0) {
return { observed: false, httpOk: false };
}
return {
observed: acceptedIds.some((id) => bridge.hasPlaceCommit(id)),
httpOk: acceptedIds.some((id) =>
bridge.hasSuccessfulPlaceNetworkRequest(id),
),
};
}
private async assertWidgetLockedAfterCommit(
params: SelectAddressParams,
placeId: string,
fixMethod: string,
): Promise<void> {
const bridge = this.bridge;
const acceptedIds = this.collectAcceptedPlaceIds(params, placeId);
const networkDiag = bridge
? buildNetworkDiagnostics(
bridge,
placeId,
params.side === "delivery"
? params.pickupAnchor?.placeId
: undefined,
)
: null;
const commitGate = this.bridgeCommitForAcceptedIds(acceptedIds);
this.diagnostics.placeNetworkEvents = networkDiag?.placeNetworkEvents;
this.diagnostics.pickupLostAfterDelivery =
networkDiag?.pickupLostAfterDelivery;
this.diagnostics.placeRequestObserved = commitGate.observed;
const observedPlaceIds = networkDiag?.observedPlaceIds ?? [];
const placeRequestObserved = commitGate.observed;
const placeRequestHttpOk = commitGate.httpOk;
console.log("[rpa] place-commit-diagnostic", {
side: params.side,
placeId,
commitState: this.stateMachine.getState(),
placeRequestObserved,
placeRequestHttpOk,
observedPlaceIds: observedPlaceIds.map((id) => id.slice(0, 24)),
fixMethod,
pickupLostAfterDelivery: networkDiag?.pickupLostAfterDelivery ?? false,
});
// 快速通道visual / syntheticdual-patch vault放宽网络 place 闸门
const isVisualConfirmed = fixMethod.includes("-visual");
const isSyntheticCommit = fixMethod.includes("-synthetic");
const relaxedGate = isVisualConfirmed || isSyntheticCommit;
if (!placeRequestObserved && !relaxedGate) {
this.fail(
params,
"no_place_request",
formatDiagnosticsFailure({
...this.diagnostics,
status: "no_place_request",
commitState: "FAILED",
}),
);
}
if (!placeRequestHttpOk && !relaxedGate) {
this.fail(
params,
"place_http_error",
formatDiagnosticsFailure({
...this.diagnostics,
status: "place_http_error",
commitState: "FAILED",
}),
);
}
if (relaxedGate) {
console.log(
`[rpa] place-commit: ${isSyntheticCommit ? "synthetic" : "视觉"}确认模式,跳过网络请求检查 (observed=${placeRequestObserved}, ok=${placeRequestHttpOk})`,
);
}
let dropdownClosed = await waitForAddressSuggestionLocked(
params.ctx.page,
2_000,
);
let dropdownOpen = await isAddressSuggestionDropdownOpen(params.ctx.page);
if (!dropdownClosed || dropdownOpen) {
await dismissAddressSuggestionDropdown(params.ctx.page, params.street).catch(
() => undefined,
);
await params.ctx.page.keyboard.press("Escape").catch(() => undefined);
await params.ctx.page.waitForTimeout(400);
dropdownClosed = await waitForAddressSuggestionLocked(params.ctx.page, 2_000);
dropdownOpen = await isAddressSuggestionDropdownOpen(params.ctx.page);
}
if ((!dropdownClosed || dropdownOpen) && !placeRequestHttpOk && !relaxedGate) {
this.fail(
params,
"selection_failed",
formatDiagnosticsFailure({
...this.diagnostics,
status: "selection_failed",
commitState: "FAILED",
method: fixMethod,
}) + ";联想下拉未收起",
);
} else if (dropdownOpen) {
console.log(
"[rpa] place-commit: place 200 已观测,忽略 styled 下拉误报",
);
}
if (params.side === "delivery" && params.pickupAnchor) {
const pickupStillInWidget = await isAddressPresentInWidget(
params.ctx.page,
params.widgetSelector,
params.pickupAnchor,
);
this.diagnostics.pickupLostAfterDelivery = !pickupStillInWidget;
if (!pickupStillInWidget) {
this.fail(
params,
"selection_failed",
formatDiagnosticsFailure({
...this.diagnostics,
status: "selection_failed",
commitState: "FAILED",
pickupLostAfterDelivery: true,
}) + ";填派送后提货地址从 widget 消失",
);
}
} else if (networkDiag?.pickupLostAfterDelivery) {
this.diagnostics.pickupLostAfterDelivery = false;
}
if (params.side === "pickup") {
let locked = await isPickupReadyForDeliveryInput(
params.ctx.page,
params.widgetSelector,
params.address,
);
if (!locked && placeRequestHttpOk) {
await dismissAddressSuggestionDropdown(params.ctx.page).catch(
() => undefined,
);
await lockPickupIntoChip(
params.ctx,
params.widgetSelector,
params.street,
);
await params.ctx.page.waitForTimeout(600);
locked = await isPickupReadyForDeliveryInput(
params.ctx.page,
params.widgetSelector,
params.address,
);
}
if (!locked && placeRequestHttpOk) {
locked = await releasePickupFieldForDelivery(
params.ctx,
params.widgetSelector,
params.address,
params.street,
);
}
if (!locked && placeRequestHttpOk) {
const widgetText = await readWidgetCombinedText(
params.ctx.page,
params.widgetSelector,
);
if (widgetContainsConfirmedAddress(widgetText, params.address)) {
console.log(
"[rpa] place-commit: place 200 且 widget 已含提货地址,放行 chip 闸门",
);
return;
}
}
if (!locked && placeRequestHttpOk) {
console.log(
"[rpa] place-commit: place 200 已观测,放行 pickup后续 settle 打开派送侧)",
);
return;
}
if (!locked) {
this.fail(
params,
"selection_failed",
formatDiagnosticsFailure({
...this.diagnostics,
status: "selection_failed",
commitState: "FAILED",
method: fixMethod,
}) +
"widget 未锁定(单框仍含提货全文,无独立派送框)",
);
}
return;
}
if (
!(await isAddressPresentInWidget(
params.ctx.page,
params.widgetSelector,
params.address,
))
) {
if (placeRequestHttpOk) {
await dismissAddressSuggestionDropdown(params.ctx.page).catch(
() => undefined,
);
await params.ctx.page.keyboard.press("Escape").catch(() => undefined);
await params.ctx.page.keyboard.press("Tab").catch(() => undefined);
const deadline = Date.now() + 4_000;
while (Date.now() < deadline) {
if (
await isAddressPresentInWidget(
params.ctx.page,
params.widgetSelector,
params.address,
)
) {
return;
}
await params.ctx.page.waitForTimeout(200);
}
if (placeRequestHttpOk) {
console.log(
"[rpa] place-commit: delivery place 200 已观测widget 文案未同步仍接受网络 commit",
);
return;
}
}
this.fail(
params,
"selection_failed",
formatDiagnosticsFailure({
...this.diagnostics,
status: "selection_failed",
commitState: "FAILED",
}) + ";派送地址未写入 widget",
);
}
}
private succeed(
params: SelectAddressParams,
placeId: string,
formattedAddress: string,
method?: string,
): AddressCommitResult {
this.mergeBufferedSelectionTraces();
this.stateMachine.transition("COMMITTED");
this.diagnostics.status = "ok";
this.diagnostics.method = method ?? this.diagnostics.method;
this.diagnostics.placeRequestObserved =
this.bridge?.hasSuccessfulPlaceNetworkRequest(placeId) ?? false;
this.diagnostics.commitState = "COMMITTED";
const details = this.bridge?.getPlaceDetails(placeId);
params.ctx.state.axelPlaceLocations[params.side] = details ?? {
placeId,
formattedAddress,
};
this.result = {
success: true,
placeId,
formattedAddress,
committed: true,
};
logAddressSelectDiagnostics(params.side, this.getDiagnostics());
return this.result;
}
private async recordSelectionTrace(
page: import("playwright").Page,
side: "pickup" | "delivery",
label: string,
): Promise<void> {
const snapshot = await captureSelectionTrace(page, label);
this.diagnostics.selectionTraces = [
...(this.diagnostics.selectionTraces ?? []),
snapshot,
];
this.diagnostics.events.push(formatSelectionTraceEvent(snapshot));
logSelectionTrace(side, snapshot);
}
private mergeBufferedSelectionTraces(): void {
const drained = drainSelectionTraces();
if (!drained.length) {
return;
}
this.diagnostics.selectionTraces = [
...(this.diagnostics.selectionTraces ?? []),
...drained,
];
for (const snapshot of drained) {
this.diagnostics.events.push(formatSelectionTraceEvent(snapshot));
}
}
private fail(
params: SelectAddressParams,
status: AddressSelectDiagnostics["status"],
detail: string,
): never {
this.mergeBufferedSelectionTraces();
this.stateMachine.transition("FAILED");
this.diagnostics.status = status;
this.diagnostics.commitState = "FAILED";
this.diagnostics.events.push(`failed:${status}`);
logAddressSelectDiagnostics(params.side, this.getDiagnostics());
throw new RpaError("ADDRESS_NOT_CONFIRMED", detail, {
retryable: status !== "selection_failed",
});
}
}