feat: 安全应用采购规格解析结果 (#257)

This commit is contained in:
chengma
2026-08-17 17:43:25 +08:00
parent 484ce4e8ec
commit afc1cdf719
19 changed files with 1669 additions and 24 deletions
+288 -3
View File
@@ -3,21 +3,30 @@
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from src.admin_gateway import (
AdminTask,
AndroidDeviceInfo,
ClaimCapabilities,
ClientInfo,
SpecResolutionMatch,
SpecResolutionReceipt,
)
from src.mock_admin_gateway import MockAdminGateway
from src.db import open_database
from src.pdd_purchase_adapter import (
PddLivePurchaseAdapter,
PddPurchaseAdapter,
PddPurchaseError,
PddPurchaseSpecResolutionRequired,
PurchaseSizeObservation,
PurchaseSpecCandidateSnapshot,
PurchasePageState,
)
from src.pdd_purchase_reconcile_adapter import PurchaseOrderCandidate
from src.purchase_task_service import PurchaseTaskService
from src.purchase_reconcile_service import PurchaseReconcileService
from src.task_models import NewClaimedTask, TaskStatus, TaskType
from src.task_repository import TaskRepository
@@ -135,12 +144,65 @@ class UnavailableGoodsAdapter(RecordingDryRunAdapter):
)
class ResolvingDryRunAdapter(RecordingDryRunAdapter):
"""首次选择报告尺码不存在,随后记录审计候选的精确应用。"""
def __init__(self) -> None:
super().__init__()
self.snapshot = PurchaseSpecCandidateSnapshot.build(
goods_id=self.goods_id,
selected_color="黑色",
target_size="L",
dimension_name="尺码",
observations=(
PurchaseSizeObservation("120斤", True),
PurchaseSizeObservation("130斤", True),
),
observed_at="2026-08-17T08:00:00Z",
)
def select_options(self, options) -> None:
self.calls.append(("select_options", dict(options)))
self.options = {
key: value for key, value in options.items() if key != "size"
}
raise PddPurchaseSpecResolutionRequired(self.snapshot)
def apply_resolved_size(self, expected_snapshot, candidate) -> None:
self.calls.append(
("apply_resolved_size", expected_snapshot.candidate_snapshot_hash,
candidate.candidate_id, candidate.raw_text)
)
self.options["size"] = candidate.raw_text
class ResolvingLiveAdapter(ResolvingDryRunAdapter, PddLivePurchaseAdapter):
def __init__(self) -> None:
super().__init__()
self.submit_count = 0
def update_shipping_address(self, purchase_number: str) -> None:
self.calls.append(("update_shipping_address", purchase_number))
def submit_order_once(self) -> None:
self.submit_count += 1
self.calls.append(("submit_order_once",))
class ChangedAfterResolutionAdapter(ResolvingDryRunAdapter):
def apply_resolved_size(self, expected_snapshot, candidate) -> None:
raise PddPurchaseError(
"PURCHASE_SPEC_CANDIDATES_CHANGED",
"等待期间候选变化",
step="purchase_resolve_options",
)
class PurchaseTaskServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.repository = TaskRepository(
Path(self.temp_dir.name) / "client.db"
)
self.db_path = Path(self.temp_dir.name) / "client.db"
self.repository = TaskRepository(self.db_path)
self.gateway = MockAdminGateway()
self.client = ClientInfo("CLIENT-001", "测试客户端")
@@ -233,6 +295,229 @@ class PurchaseTaskServiceTest(unittest.TestCase):
self.assertFalse(purchase["order_submitted"])
self.assertFalse(purchase["payment_attempted"])
def test_matched_spec_resolution_is_persisted_and_existing_flow_continues(self):
self._prepare_task(task_id="PUR-RESOLVE")
adapter = ResolvingDryRunAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-001",
"matched",
"rule",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
10000,
"唯一重量等价",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-RESOLVE")
self.assertEqual(outcome.kind, "succeeded")
self.assertTrue(any(call[0] == "apply_resolved_size" for call in adapter.calls))
detail = self.repository.get_task("PUR-RESOLVE")
purchase = detail.pdd_data["purchase"]
self.assertEqual(purchase["requested"]["options"], OPTIONS)
self.assertEqual(purchase["confirmed"]["options"]["size"], "120斤")
self.assertEqual(
purchase["spec_resolution"]["resolution_id"], "psr-001"
)
connection = open_database(self.db_path)
try:
row = connection.execute(
"SELECT status, resolution_id, outcome"
" FROM purchase_spec_resolutions"
).fetchone()
finally:
connection.close()
self.assertEqual(tuple(row), ("resolved", "psr-001", "matched"))
def test_uncertain_spec_resolution_stops_before_quantity_and_submission(self):
self._prepare_task(task_id="PUR-UNCERTAIN")
adapter = ResolvingDryRunAdapter()
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-uncertain",
"uncertain",
"ai",
adapter.snapshot.candidate_snapshot_hash,
None,
7000,
"无法唯一判断",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-UNCERTAIN")
self.assertEqual(outcome.kind, "task_failed")
self.assertFalse(any(call[0] == "apply_resolved_size" for call in adapter.calls))
self.assertNotIn(("set_quantity", 2), adapter.calls)
detail = self.repository.get_task("PUR-UNCERTAIN")
self.assertEqual(
detail.last_error_code, "PURCHASE_SPEC_RESOLUTION_UNCERTAIN"
)
def test_rejected_and_failed_spec_resolution_use_stable_failure_codes(self):
for outcome, expected_code in (
("rejected", "PURCHASE_SPEC_RESOLUTION_REJECTED"),
("failed", "PURCHASE_SPEC_RESOLUTION_FAILED"),
):
with self.subTest(outcome=outcome):
task_id = f"PUR-{outcome.upper()}"
self._prepare_task(task_id=task_id)
adapter = ResolvingDryRunAdapter()
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
f"psr-{outcome}",
outcome,
None,
adapter.snapshot.candidate_snapshot_hash,
None,
None,
"没有可安全采用的候选",
"2026-08-17T08:00:01Z",
)
)
result = self._service(adapter).execute_selected(task_id)
self.assertEqual(result.kind, "task_failed")
self.assertEqual(
self.repository.get_task(task_id).last_error_code,
expected_code,
)
self.assertFalse(
any(call[0] == "apply_resolved_size" for call in adapter.calls)
)
def test_live_resolution_keeps_original_request_and_existing_safety_gates(self):
self._prepare_task(task_id="PUR-RESOLVE-LIVE", execution_mode="live")
adapter = ResolvingLiveAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-live",
"matched",
"ai",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
9300,
"唯一候选",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected("PUR-RESOLVE-LIVE")
self.assertEqual(outcome.kind, "reconcile_pending")
self.assertEqual(adapter.submit_count, 1)
task = self.repository.get_task("PUR-RESOLVE-LIVE")
run = self.repository.latest_task_run("PUR-RESOLVE-LIVE")
self.assertIsNotNone(run.irreversible_action_at)
self.assertEqual(
run.diagnostics_json["final_confirmation"]["options"]["size"],
"120斤",
)
self.assertEqual(
run.diagnostics_json["final_confirmation"]["spec_resolution"]
["resolution_id"],
"psr-live",
)
query = PurchaseReconcileService._query(task, run)
self.assertEqual(query.original_options, OPTIONS)
self.assertEqual(query.options["size"], "120斤")
self.assertEqual(query.spec_resolution["resolution_id"], "psr-live")
result = PurchaseReconcileService._result_data(
SimpleNamespace(_device_address="127.0.0.1:5555"),
task,
query,
PurchaseOrderCandidate(
order_no="ORDER-001",
ordered_at="2026-08-17T08:00:03Z",
ordered_at_raw="2026-08-17 16:00:03",
payment_status="unpaid",
),
)
self.assertEqual(result["purchase"]["requested"]["options"], OPTIONS)
self.assertEqual(
result["purchase"]["confirmed"]["options"]["size"], "120斤"
)
self.assertEqual(
result["purchase"]["spec_resolution"]["resolution_id"],
"psr-live",
)
def test_spec_resolution_timeout_keeps_persisted_request_and_does_not_retry(self):
self._prepare_task(task_id="PUR-RESOLVE-TIMEOUT")
adapter = ResolvingDryRunAdapter()
self.gateway.timeout_next_call()
outcome = self._service(adapter).execute_selected(
"PUR-RESOLVE-TIMEOUT"
)
self.assertEqual(outcome.kind, "task_failed")
self.assertEqual(self.gateway.spec_resolution_count, 0)
connection = open_database(self.db_path)
try:
row = connection.execute(
"SELECT status, idempotency_key, request_json"
" FROM purchase_spec_resolutions"
).fetchone()
finally:
connection.close()
self.assertEqual(row["status"], "pending")
self.assertTrue(row["idempotency_key"].startswith("spec-resolution-v1:"))
self.assertIn('"candidate_snapshot_hash"', row["request_json"])
def test_matched_resolution_page_change_failure_keeps_resolution_audit(self):
self._prepare_task(task_id="PUR-RESOLVE-CHANGED")
adapter = ChangedAfterResolutionAdapter()
candidate = adapter.snapshot.candidates[0]
self.gateway.set_next_spec_resolution(
SpecResolutionReceipt(
1,
"psr-changed",
"matched",
"ai",
adapter.snapshot.candidate_snapshot_hash,
SpecResolutionMatch(
candidate.candidate_id,
candidate.raw_text,
candidate.options,
),
9000,
"模型匹配",
"2026-08-17T08:00:01Z",
)
)
outcome = self._service(adapter).execute_selected(
"PUR-RESOLVE-CHANGED"
)
self.assertEqual(outcome.kind, "task_failed")
run = self.repository.latest_task_run("PUR-RESOLVE-CHANGED")
self.assertEqual(
run.diagnostics_json["spec_resolution"]["resolution_id"],
"psr-changed",
)
self.assertIsNone(run.irreversible_action_at)
def test_started_callback_runs_after_database_enters_running(self):
self._prepare_task(task_id="PUR-STARTED")
observed = []