feat(trading): support multi-item purchase intents

This commit is contained in:
2026-08-31 11:41:39 +08:00
parent 865bbe3724
commit f76c0a1518
7 changed files with 291 additions and 57 deletions
+78
View File
@@ -11,6 +11,7 @@ clear_cart 与 _dump_debug_snapshot 用不依赖 Playwright 的 fake page 覆盖
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from typing import Any
@@ -39,6 +40,7 @@ from app.trading.worker.site_interact import (
_DELETE_BUTTON_SELECTOR,
_extract_error_message,
_extract_purchase_fields,
_normalize_intent_items,
_OrderListAccumulator,
_parse_checkout_summary,
_parse_initial_state,
@@ -70,6 +72,82 @@ def _wrap_state(state: dict[str, Any]) -> str:
)
# ---- 下单意图兼容 ----
def test_normalize_intent_items_accepts_legacy_single_item():
assert _normalize_intent_items({
"item_url": "https://item.rakuten.co.jp/shop/x/",
"quantity": 2,
}) == [{
"item_url": "https://item.rakuten.co.jp/shop/x/",
"quantity": 2,
"variant_id": None,
"choice": None,
}]
def test_normalize_intent_items_accepts_multiple_items_and_string_urls():
assert _normalize_intent_items({
"items": [
{"item_url": "https://item.rakuten.co.jp/shop/x/", "quantity": 2},
"https://item.rakuten.co.jp/shop/y/",
]
}) == [
{
"item_url": "https://item.rakuten.co.jp/shop/x/",
"quantity": 2,
"variant_id": None,
"choice": None,
},
{
"item_url": "https://item.rakuten.co.jp/shop/y/",
"quantity": 1,
"variant_id": None,
"choice": None,
},
]
def test_normalize_intent_items_rejects_empty_items():
with pytest.raises(InvalidRequestError):
_normalize_intent_items({"items": []})
async def test_add_to_cart_processes_all_items_and_keeps_legacy_state():
site = SiteInteractor.__new__(SiteInteractor)
site._lock = asyncio.Lock()
site._per_task_state = {}
calls: list[dict[str, Any]] = []
async def fake_add(**kwargs):
calls.append(kwargs)
index = len(calls)
return {
"item_id": str(index),
"shop_bid": "shop",
"basket_domain": "https://basket",
"response_html": f"html-{index}",
"screenshot": b"",
}
site._add_to_cart_with_fields = fake_add
snapshot = await site.add_to_cart(_make_task(intent={
"items": [
{"item_url": "https://item.rakuten.co.jp/shop/x/"},
{"item_url": "https://item.rakuten.co.jp/shop/y/", "quantity": 3},
]
}))
assert [call["item_url"] for call in calls] == [
"https://item.rakuten.co.jp/shop/x/",
"https://item.rakuten.co.jp/shop/y/",
]
assert calls[1]["quantity"] == 3
assert site._per_task_state["t1"]["item_id"] == "1"
assert site._per_task_state["t1"]["item_ids"] == ["1", "2"]
assert snapshot.html == "html-2"
# ---- _parse_initial_state ----
+48
View File
@@ -134,6 +134,54 @@ async def test_match_ignores_query_string_difference():
assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED
async def test_multiple_item_intent_requires_all_items_in_same_order():
gateway = FakeGateway()
site = FakeSite(
window=OrderListWindow(
entries=[
OrderListEntry(
order_number="o1",
order_date="2026-08-10T00:00:00Z",
items=[
OrderListItem(item_url="https://item.rakuten.co.jp/shop/x/"),
OrderListItem(item_url="https://item.rakuten.co.jp/shop/y/"),
],
)
],
window_fully_covered=True,
)
)
task = _make_task()
task.intent = {
"items": [
{"item_url": "https://item.rakuten.co.jp/shop/x/"},
{"item_url": "https://item.rakuten.co.jp/shop/y/"},
]
}
result = await verify.verify_on_site(task, gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.ALREADY_ORDERED
assert result.site_order_id == "o1"
async def test_multiple_item_intent_partial_order_match_is_not_ordered():
gateway = FakeGateway()
site = FakeSite(
window=OrderListWindow(
entries=[_entry("o1", "https://item.rakuten.co.jp/shop/x/")],
window_fully_covered=True,
)
)
task = _make_task()
task.intent = {
"items": [
{"item_url": "https://item.rakuten.co.jp/shop/x/"},
{"item_url": "https://item.rakuten.co.jp/shop/y/"},
]
}
result = await verify.verify_on_site(task, gateway=gateway, site=site)
assert result.verdict == verify.VerifyVerdict.NOT_ORDERED
# ---- 命中 0 笔且窗口确认覆盖完:未下单 ----