|
|
import type { QuoteRequest } from "@/modules/providers/quote-provider";
|
|
|
import { pageLocator } from "@/lib/rpa/locator";
|
|
|
import { isRpaAddressMockMode } from "@/lib/rpa/address-mode";
|
|
|
import { isRpaVisualRushMode } from "@/lib/rpa/env";
|
|
|
import { RpaError } from "@/modules/rpa/errors";
|
|
|
import { assertRPAContext } from "@/workers/rpa/kernel/context-validator";
|
|
|
import type { RPAContext } from "@/workers/rpa/kernel/context";
|
|
|
import {
|
|
|
createAddressAdapter,
|
|
|
formatDiagnosticsFailure,
|
|
|
} from "@/workers/rpa/address-adapter";
|
|
|
import {
|
|
|
isAddressSuggestionDropdownOpen,
|
|
|
getAddressConfirmationSurfaceText,
|
|
|
} from "@/workers/rpa/address-suggestions";
|
|
|
import { openAddressSide } from "@/workers/rpa/address-side";
|
|
|
import {
|
|
|
assertDualAddressStable,
|
|
|
blurAddressEditing,
|
|
|
dismissAddressSuggestionDropdown,
|
|
|
ensureDualAddressCommitted,
|
|
|
isAddressesVisuallyCompleteForCargo,
|
|
|
isPickupReadyForDeliveryInput,
|
|
|
lockPickupIntoChip,
|
|
|
releasePickupFieldForDelivery,
|
|
|
settlePickupBeforeDelivery,
|
|
|
widgetContainsConfirmedAddress,
|
|
|
} from "@/workers/rpa/address-commit";
|
|
|
import { runKernelSubStep } from "@/workers/rpa/kernel/step-guard";
|
|
|
import { stabilizeQuoteLandingPage } from "@/workers/rpa/page-prep";
|
|
|
import {
|
|
|
assertBothAddressesLocked,
|
|
|
settleAddressFieldsForCargo,
|
|
|
} from "@/workers/rpa/cargo-lock";
|
|
|
import {
|
|
|
applyDeliveryDualAddressPatch,
|
|
|
applyPickupDualAddressPatch,
|
|
|
preservePickupInWidget,
|
|
|
releasePickupInputInWidget,
|
|
|
} from "@/workers/rpa/inject/dual-address-patch";
|
|
|
import { isDualAddressPatchEnabled } from "@/lib/rpa/env";
|
|
|
|
|
|
export { widgetContainsConfirmedAddress } from "@/workers/rpa/address-commit";
|
|
|
|
|
|
type AddressFields = QuoteRequest["pickup"];
|
|
|
|
|
|
async function assertConfirmedAddressesInWidget(
|
|
|
ctx: RPAContext,
|
|
|
widgetSelector: string,
|
|
|
pickup: AddressFields,
|
|
|
delivery: AddressFields,
|
|
|
): Promise<void> {
|
|
|
const surfaceText = await getAddressConfirmationSurfaceText(
|
|
|
ctx.page,
|
|
|
widgetSelector,
|
|
|
);
|
|
|
const fullText = await pageLocator(ctx.page, widgetSelector).innerText();
|
|
|
|
|
|
const widgetText = [surfaceText, fullText]
|
|
|
.map((part) => part.replace(/\s+/g, " ").trim())
|
|
|
.filter(Boolean)
|
|
|
.join(" ");
|
|
|
|
|
|
// 快速通道:如果widget已经包含两个地址,信任视觉状态,忽略dropdown检测
|
|
|
const pickupPresent = widgetContainsConfirmedAddress(widgetText, pickup);
|
|
|
const deliveryPresent = widgetContainsConfirmedAddress(widgetText, delivery);
|
|
|
|
|
|
if (pickupPresent && deliveryPresent) {
|
|
|
console.log("[rpa] assertConfirmedAddressesInWidget: 视觉快速通道 - 两地址已在widget中");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
!surfaceText.trim() &&
|
|
|
(await isAddressSuggestionDropdownOpen(ctx.page))
|
|
|
) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
"地址联想下拉仍打开,未完成点选锁定",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (!pickupPresent) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`widget 未体现已确认提货地址:${pickup.mothershipDisplayLabel}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
if (!deliveryPresent) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
`widget 未体现已确认派送地址:${delivery.mothershipDisplayLabel}`,
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 单侧填址 + commit 屏障(pickup 先锁定再开 delivery);业务流程仅调用 AddressAdapter */
|
|
|
export async function fillAddressWithCommitStep(
|
|
|
ctx: RPAContext,
|
|
|
side: "pickup" | "delivery",
|
|
|
address: AddressFields,
|
|
|
pickupAnchor?: AddressFields,
|
|
|
): Promise<void> {
|
|
|
const widgetSelector = ctx.selectors.resolve("RPA_SELECTOR_QUOTE_WIDGET");
|
|
|
const mockMode = isRpaAddressMockMode();
|
|
|
|
|
|
if (side === "delivery") {
|
|
|
if (!pickupAnchor) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
"填写派送地址前缺少提货锚点地址",
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
const pickupVaultReady =
|
|
|
isDualAddressPatchEnabled() &&
|
|
|
Boolean(ctx.state.axelPlaceLocations.pickup);
|
|
|
if (!isRpaVisualRushMode()) {
|
|
|
if (!pickupVaultReady) {
|
|
|
await runKernelSubStep("fillAddress", "settlePickupBeforeDelivery", () =>
|
|
|
settlePickupBeforeDelivery(ctx, widgetSelector, pickupAnchor),
|
|
|
);
|
|
|
await runKernelSubStep(
|
|
|
"fillAddress",
|
|
|
"assertPickupStableBeforeDelivery",
|
|
|
() =>
|
|
|
assertDualAddressStable(ctx, widgetSelector, pickupAnchor, null),
|
|
|
);
|
|
|
} else {
|
|
|
console.log(
|
|
|
"[rpa] address-step: vault pickup 已注入,跳过 settlePickupBeforeDelivery",
|
|
|
);
|
|
|
await preservePickupInWidget(ctx.page);
|
|
|
await releasePickupInputInWidget(ctx.page);
|
|
|
await ctx.page.waitForTimeout(400);
|
|
|
}
|
|
|
} else {
|
|
|
console.log("[rpa] visual-rush: 跳过 settlePickupBeforeDelivery");
|
|
|
}
|
|
|
}
|
|
|
|
|
|
ctx.state.markAddressSide(side);
|
|
|
const street = await runKernelSubStep(
|
|
|
"fillAddress",
|
|
|
`${side}/openAddressSide`,
|
|
|
() => openAddressSide(ctx, side),
|
|
|
);
|
|
|
|
|
|
if (side === "delivery" && pickupAnchor && !mockMode && !isRpaVisualRushMode()) {
|
|
|
const widgetText = await pageLocator(ctx.page, widgetSelector)
|
|
|
.innerText()
|
|
|
.catch(() => "");
|
|
|
if (!widgetContainsConfirmedAddress(widgetText, pickupAnchor)) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
"打开派送侧后提货地址已从 widget 消失",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (!(await street.isEditable().catch(() => false))) {
|
|
|
throw new RpaError(
|
|
|
"STRUCT_CHANGE",
|
|
|
`${side === "pickup" ? "提货" : "派送"}地址输入框不可编辑`,
|
|
|
{ retryable: false },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
await runKernelSubStep("fillAddress", `${side}/addressAdapter`, async () => {
|
|
|
const adapter = createAddressAdapter();
|
|
|
const rush = isRpaVisualRushMode();
|
|
|
|
|
|
let commit;
|
|
|
try {
|
|
|
await adapter.selectAddress({
|
|
|
ctx,
|
|
|
side,
|
|
|
address,
|
|
|
street,
|
|
|
widgetSelector,
|
|
|
pickupAnchor: side === "delivery" ? pickupAnchor : undefined,
|
|
|
});
|
|
|
commit = await adapter.waitForCommit();
|
|
|
} catch (error) {
|
|
|
if (
|
|
|
rush ||
|
|
|
(side === "delivery" &&
|
|
|
!mockMode &&
|
|
|
pickupAnchor &&
|
|
|
(await isAddressesVisuallyCompleteForCargo(
|
|
|
ctx.page,
|
|
|
widgetSelector,
|
|
|
pickupAnchor,
|
|
|
address,
|
|
|
)))
|
|
|
) {
|
|
|
if (rush) {
|
|
|
console.log(
|
|
|
`[rpa] visual-rush: ${side} adapter 异常已忽略,继续下一步`,
|
|
|
);
|
|
|
} else {
|
|
|
console.log(
|
|
|
"[rpa] address-step: delivery commit 未通过但视觉双地址已齐,继续货物步",
|
|
|
);
|
|
|
}
|
|
|
if (!rush) {
|
|
|
await dismissAddressSuggestionDropdown(ctx.page, street);
|
|
|
await blurAddressEditing(ctx, widgetSelector);
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
if (error instanceof RpaError) {
|
|
|
const diag = adapter.getDiagnostics();
|
|
|
if (!error.message.includes("commitState=")) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
formatDiagnosticsFailure(diag),
|
|
|
{ retryable: error.retryable },
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
throw error;
|
|
|
}
|
|
|
|
|
|
if (!commit.committed || adapter.getCommitState() !== "COMMITTED") {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
formatDiagnosticsFailure(adapter.getDiagnostics()),
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const diag = adapter.getDiagnostics();
|
|
|
if (!mockMode && !rush && !diag.placeRequestObserved) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
formatDiagnosticsFailure({
|
|
|
...diag,
|
|
|
status: "no_place_request",
|
|
|
}),
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (side === "pickup" && !mockMode && !rush) {
|
|
|
await lockPickupIntoChip(ctx, widgetSelector, street);
|
|
|
await ctx.page.waitForTimeout(1000);
|
|
|
let locked = await isPickupReadyForDeliveryInput(
|
|
|
ctx.page,
|
|
|
widgetSelector,
|
|
|
address,
|
|
|
);
|
|
|
if (!locked) {
|
|
|
locked = await releasePickupFieldForDelivery(
|
|
|
ctx,
|
|
|
widgetSelector,
|
|
|
address,
|
|
|
street,
|
|
|
);
|
|
|
}
|
|
|
if (!locked && diag.placeRequestObserved) {
|
|
|
console.log(
|
|
|
"[rpa] address-step: pickup place 200 已观测,chip 未就绪仍继续",
|
|
|
);
|
|
|
} else if (!locked) {
|
|
|
console.log("[rpa] pickup-commit-diagnostic", {
|
|
|
phase: "after-pickup-adapter",
|
|
|
placeId: diag.placeId,
|
|
|
commitState: diag.commitState,
|
|
|
placeRequestObserved: diag.placeRequestObserved,
|
|
|
method: diag.method,
|
|
|
});
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
"Pickup commit 后 widget 未锁定(无独立派送框)",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
if (!ctx.state.axelPlaceLocations.pickup) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
"Pickup commit 后 axel place 详情未写入状态",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
console.log("[rpa] address-step:pickup-locked", {
|
|
|
placeId: diag.placeId.slice(0, 24),
|
|
|
method: diag.method,
|
|
|
placeRequestObserved: diag.placeRequestObserved,
|
|
|
});
|
|
|
if (isDualAddressPatchEnabled() && ctx.state.axelPlaceLocations.pickup) {
|
|
|
await applyPickupDualAddressPatch(ctx.page, {
|
|
|
placeId: diag.placeId,
|
|
|
location: ctx.state.axelPlaceLocations.pickup,
|
|
|
description:
|
|
|
address.mothershipDisplayLabel?.trim() ||
|
|
|
address.formattedAddress?.trim() ||
|
|
|
address.street,
|
|
|
});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (side === "pickup" && rush) {
|
|
|
console.log("[rpa] visual-rush: pickup 已点选联想,跳过 chip 锁定轮询");
|
|
|
}
|
|
|
|
|
|
if (side === "delivery" && !mockMode && !rush) {
|
|
|
if (
|
|
|
isDualAddressPatchEnabled() &&
|
|
|
ctx.state.axelPlaceLocations.pickup &&
|
|
|
ctx.state.axelPlaceLocations.delivery &&
|
|
|
pickupAnchor
|
|
|
) {
|
|
|
await applyDeliveryDualAddressPatch(
|
|
|
ctx.page,
|
|
|
{
|
|
|
placeId: pickupAnchor.placeId ?? "",
|
|
|
location: ctx.state.axelPlaceLocations.pickup,
|
|
|
description:
|
|
|
pickupAnchor.mothershipDisplayLabel?.trim() ||
|
|
|
pickupAnchor.formattedAddress?.trim() ||
|
|
|
pickupAnchor.street,
|
|
|
},
|
|
|
{
|
|
|
placeId: diag.placeId,
|
|
|
location: ctx.state.axelPlaceLocations.delivery,
|
|
|
description:
|
|
|
address.mothershipDisplayLabel?.trim() ||
|
|
|
address.formattedAddress?.trim() ||
|
|
|
address.street,
|
|
|
},
|
|
|
);
|
|
|
}
|
|
|
if (diag.pickupLostAfterDelivery) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
"填派送后提货 place commit 丢失(pickupLostAfterDelivery=true)",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
if (!ctx.state.axelPlaceLocations.pickup || !ctx.state.axelPlaceLocations.delivery) {
|
|
|
throw new RpaError(
|
|
|
"ADDRESS_NOT_CONFIRMED",
|
|
|
"双地址 commit 后 axel place 状态不完整",
|
|
|
{ retryable: true },
|
|
|
);
|
|
|
}
|
|
|
console.log("[rpa] address-step:dual-address-committed", {
|
|
|
pickupPlaceId: diag.placeId.slice(0, 24),
|
|
|
pickupLostAfterDelivery: diag.pickupLostAfterDelivery ?? false,
|
|
|
placeRequestObserved: diag.placeRequestObserved,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
await dismissAddressSuggestionDropdown(ctx.page, street);
|
|
|
await blurAddressEditing(ctx, widgetSelector);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** Kernel 地址填表步骤(双地址分步 commit,禁止 quote_session 跳过) */
|
|
|
export async function fillAddressStep(
|
|
|
ctx: RPAContext,
|
|
|
req: QuoteRequest,
|
|
|
): Promise<void> {
|
|
|
assertRPAContext(ctx, "fillAddressStep");
|
|
|
|
|
|
const widgetSelector = ctx.selectors.resolve("RPA_SELECTOR_QUOTE_WIDGET");
|
|
|
const mockMode = isRpaAddressMockMode();
|
|
|
|
|
|
if (mockMode) {
|
|
|
console.log(
|
|
|
"[rpa] RPA_ADDRESS_MODE=mock:仅替代 MotherShip commit,cargo/submit/quote 仍真实执行",
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const rush = isRpaVisualRushMode();
|
|
|
if (rush) {
|
|
|
console.log("[rpa] visual-rush: 地址步仅填表+点选,跳过锁定/重填闸门");
|
|
|
}
|
|
|
|
|
|
await runKernelSubStep("fillAddress", "stabilizeLandingPage", () =>
|
|
|
stabilizeQuoteLandingPage(ctx),
|
|
|
);
|
|
|
|
|
|
await fillAddressWithCommitStep(ctx, "pickup", req.pickup);
|
|
|
if (!mockMode && !rush) {
|
|
|
const pickupVaultReady =
|
|
|
isDualAddressPatchEnabled() && Boolean(ctx.state.axelPlaceLocations.pickup);
|
|
|
if (isDualAddressPatchEnabled()) {
|
|
|
await preservePickupInWidget(ctx.page);
|
|
|
await releasePickupInputInWidget(ctx.page);
|
|
|
await ctx.page.waitForTimeout(600);
|
|
|
}
|
|
|
if (!pickupVaultReady) {
|
|
|
await runKernelSubStep("fillAddress", "assertPickupCommitted", () =>
|
|
|
assertDualAddressStable(ctx, widgetSelector, req.pickup, null),
|
|
|
);
|
|
|
} else {
|
|
|
console.log(
|
|
|
"[rpa] address-step: preknown pickup 已注入 vault,跳过 assertPickupCommitted",
|
|
|
);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
await fillAddressWithCommitStep(
|
|
|
ctx,
|
|
|
"delivery",
|
|
|
req.delivery,
|
|
|
req.pickup,
|
|
|
);
|
|
|
|
|
|
const visualReady =
|
|
|
!mockMode &&
|
|
|
(await isAddressesVisuallyCompleteForCargo(
|
|
|
ctx.page,
|
|
|
widgetSelector,
|
|
|
req.pickup,
|
|
|
req.delivery,
|
|
|
));
|
|
|
|
|
|
if (visualReady) {
|
|
|
console.log(
|
|
|
"[rpa] fillAddress: 视觉已确认双地址(widget/地图),直接进入货物步",
|
|
|
);
|
|
|
} else if (!mockMode && !rush) {
|
|
|
await runKernelSubStep("fillAddress", "ensureDualAddressCommitted", () =>
|
|
|
ensureDualAddressCommitted(
|
|
|
ctx,
|
|
|
widgetSelector,
|
|
|
req.pickup,
|
|
|
req.delivery,
|
|
|
async (refillSide, refillAddress) => {
|
|
|
await fillAddressWithCommitStep(
|
|
|
ctx,
|
|
|
refillSide,
|
|
|
refillAddress,
|
|
|
refillSide === "delivery" ? req.pickup : undefined,
|
|
|
);
|
|
|
},
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
await ctx.page.keyboard.press("Escape").catch(() => undefined);
|
|
|
if (!rush) {
|
|
|
await runKernelSubStep("fillAddress", "settleAddressEditing", () =>
|
|
|
ctx.state.settleAddressEditing(),
|
|
|
);
|
|
|
await runKernelSubStep("fillAddress", "settleAddressFieldsForCargo", () =>
|
|
|
settleAddressFieldsForCargo(ctx.page, widgetSelector),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
if (!mockMode && !visualReady && !rush) {
|
|
|
await runKernelSubStep("fillAddress", "assertConfirmedAddressesInWidget", () =>
|
|
|
assertConfirmedAddressesInWidget(
|
|
|
ctx,
|
|
|
widgetSelector,
|
|
|
req.pickup,
|
|
|
req.delivery,
|
|
|
),
|
|
|
);
|
|
|
await runKernelSubStep("fillAddress", "assertBothAddressesLocked", () =>
|
|
|
assertBothAddressesLocked(ctx.page, widgetSelector),
|
|
|
);
|
|
|
}
|
|
|
}
|