feat: 完善真实采购订单核对与恢复 (#100)

This commit is contained in:
chengma
2026-08-10 16:54:06 +08:00
parent 16107aaa18
commit df67b0eaaa
16 changed files with 1134 additions and 103 deletions
+209 -26
View File
@@ -2,10 +2,10 @@
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
from src.db import open_database
from src.mock_admin_gateway import MockAdminGateway
from src.pdd_purchase_adapter import (
PddLivePurchaseAdapter,
@@ -15,7 +15,8 @@ from src.pdd_purchase_adapter import (
)
from src.pdd_purchase_reconcile_adapter import (
PddPurchaseReconcileAdapter,
PurchaseReconcileObservation,
PurchaseOrderCandidate,
PurchaseReconcileScan,
)
from src.purchase_task_service import PurchaseTaskService
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
@@ -26,7 +27,9 @@ from src.task_repository import TaskRepository
OPTIONS = {"color": "黑色", "size": "L"}
def purchase_admin_task(task_id: str = "PUR-RECOVER") -> AdminTask:
def purchase_admin_task(
task_id: str = "PUR-RECOVER", execution_mode: str = "dry_run"
) -> AdminTask:
return AdminTask(
task_id,
TaskType.PURCHASE,
@@ -39,6 +42,7 @@ def purchase_admin_task(task_id: str = "PUR-RECOVER") -> AdminTask:
"quantity": 2,
"max_price_cent": 5000,
},
execution_mode=execution_mode,
)
@@ -95,13 +99,37 @@ class FaultAdapter(PddPurchaseAdapter):
class ReadOnlyReconcileAdapter(PddPurchaseReconcileAdapter):
def __init__(self, calls) -> None:
def __init__(self, calls, candidates=None, error=None) -> None:
self.calls = calls
self.candidates = candidates
self.error = error
def read_order_match(self, query):
def read_order_candidates(self, query):
self.calls.append(("reconcile", query.goods_id))
return PurchaseReconcileObservation(
"matched", "ORDER-001", "2026-08-10T08:00:00Z"
if self.error is not None:
raise self.error
candidates = self.candidates
if candidates is None:
candidates = (
PurchaseOrderCandidate(
order_no="ORDER-001",
goods_id=query.goods_id,
options=dict(query.options),
quantity=query.quantity,
total_price_cent=query.total_price_cent,
ordered_at=query.irreversible_action_at,
ordered_at_raw="2026-08-10 16:00:00",
payment_status="unpaid",
),
)
else:
candidates = tuple(
replace(candidate, ordered_at=query.irreversible_action_at)
for candidate in candidates
)
return PurchaseReconcileScan(
candidates=candidates,
diagnostics={"pages_scanned": 1},
)
def close(self) -> None:
@@ -119,12 +147,19 @@ class PurchaseRecoveryTest(unittest.TestCase):
def tearDown(self) -> None:
self.temporary.cleanup()
def _add(self, task_id: str = "PUR-RECOVER") -> None:
task = purchase_admin_task(task_id)
def _add(
self, task_id: str = "PUR-RECOVER", execution_mode: str = "dry_run"
) -> None:
task = purchase_admin_task(task_id, execution_mode)
self.gateway.enqueue_task(task, self.client.client_id)
claimed = self.gateway.claim_next(
self.client,
ClaimCapabilities(supported_types=(TaskType.PURCHASE,)),
ClaimCapabilities(
supported_types=(TaskType.PURCHASE,),
purchase_mode=(
"live" if execution_mode == "live" else "dry_run"
),
),
)
assert claimed is not None
self.repository.add_claimed_task(
@@ -142,18 +177,18 @@ class PurchaseRecoveryTest(unittest.TestCase):
)
def _interrupt_after_irreversible(self, task_id: str) -> None:
self._add(task_id)
self._add(task_id, "live")
started = self.repository.start_purchase_run(task_id, "USB-001")
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE task_runs SET irreversible_action_at = ?"
" WHERE attempt_id = ?",
("2026-08-10T08:00:00Z", started.attempt_id),
)
finally:
connection.close()
self.repository.mark_purchase_irreversible(
task_id,
started.attempt_id,
{
"options": dict(OPTIONS),
"quantity": 2,
"unit_price_cent": 4200,
"total_price_cent": 8400,
},
)
self.repository.recover_interrupted_work()
def test_critical_action_failure_keeps_last_persisted_step(self):
@@ -240,17 +275,36 @@ class PurchaseRecoveryTest(unittest.TestCase):
)
first = dispatcher.execute_one()
pending = self.repository.next_pending_outbox()
matched_detail = self.repository.get_task("PUR-RECOVER")
matched_run = self.repository.latest_task_run("PUR-RECOVER")
duplicate = self.repository.save_matched_purchase_reconciliation(
"PUR-RECOVER",
matched_run.attempt_id,
matched_detail.pdd_data,
{"mode": "reconcile_only"},
)
self.assertEqual(duplicate.id, pending.id)
second = dispatcher.execute_one()
third = dispatcher.execute_one()
self.assertEqual(first.kind, "manual_review")
self.assertEqual(second.kind, "no_task")
self.assertEqual(first.kind, "result_pending")
self.assertEqual(second.kind, "succeeded")
self.assertEqual(third.kind, "no_task")
self.assertNotIn(("purchase",), calls)
self.assertEqual(calls.count(("reconcile", "737116531267")), 1)
detail = self.repository.get_task("PUR-RECOVER")
run = self.repository.latest_task_run("PUR-RECOVER")
assert detail is not None and run is not None
self.assertEqual(detail.current_step, "reconcile_completed")
self.assertEqual(run.diagnostics_json["mode"], "reconcile_only")
self.assertEqual(detail.current_step, "completed")
self.assertEqual(
run.diagnostics_json["reconciliation"]["mode"],
"reconcile_only",
)
self.assertEqual(
detail.pdd_data["purchase"]["payment_status"], "unpaid"
)
self.assertEqual(detail.pdd_data["purchase"]["order_no"], "ORDER-001")
def test_reconcile_device_failure_is_recorded_as_unknown(self):
task_id = "PUR-RECONCILE-OFFLINE"
@@ -278,7 +332,136 @@ class PurchaseRecoveryTest(unittest.TestCase):
run = self.repository.latest_task_run(task_id)
assert detail is not None and run is not None
self.assertEqual(detail.current_step, "reconcile_manual_review")
self.assertIn("核对设备已断开", run.diagnostics_json["error"])
self.assertIn(
"核对设备已断开",
run.diagnostics_json["reconciliation"]["error_message"],
)
def test_no_multiple_mismatched_and_paid_candidates_need_manual_review(self):
cases = {
"EMPTY": ((), "ORDER_NOT_FOUND"),
"MULTIPLE": (
(
PurchaseOrderCandidate(
"ORDER-A",
"737116531267",
OPTIONS,
2,
8400,
"2026-08-10T08:00:00Z",
"2026-08-10 16:00:00",
"unpaid",
),
PurchaseOrderCandidate(
"ORDER-B",
"737116531267",
OPTIONS,
2,
8400,
"2026-08-10T08:00:00Z",
"2026-08-10 16:00:00",
"unpaid",
),
),
"AMBIGUOUS_ORDER_MATCH",
),
"MISMATCH": (
(
PurchaseOrderCandidate(
"ORDER-C",
"OTHER-GOODS",
OPTIONS,
2,
8400,
"2026-08-10T08:00:00Z",
"2026-08-10 16:00:00",
"unpaid",
),
),
"ORDER_MATCH_UNCERTAIN",
),
"PAID": (
(
PurchaseOrderCandidate(
"ORDER-D",
"737116531267",
OPTIONS,
2,
8400,
"2026-08-10T08:00:00Z",
"2026-08-10 16:00:00",
"paid",
),
),
"ORDER_MATCH_UNCERTAIN",
),
}
for suffix, (candidates, error_code) in cases.items():
with self.subTest(suffix=suffix):
task_id = f"PUR-{suffix}"
self._interrupt_after_irreversible(task_id)
calls = []
outcome = TaskDispatcher(
self.gateway,
self.repository,
self.client,
"USB-001",
purchase_adapter_factory=lambda *_args: calls.append(
"purchase"
),
purchase_reconcile_factory=(
lambda _address, _cancelled, values=candidates:
ReadOnlyReconcileAdapter(calls, values)
),
device_connection_checker=lambda _serial: None,
).execute_one()
self.assertEqual(outcome.kind, "manual_review")
self.assertNotIn("purchase", calls)
detail = self.repository.get_task(task_id)
self.assertEqual(detail.status, TaskStatus.MANUAL_REVIEW)
self.assertEqual(detail.last_error_code, error_code)
self.assertIsNone(self.repository.next_pending_outbox())
def test_restart_after_match_only_submits_outbox_without_reading_phone(self):
task_id = "PUR-OUTBOX-RESTART"
self._interrupt_after_irreversible(task_id)
first_calls = []
first = TaskDispatcher(
self.gateway,
self.repository,
self.client,
"USB-001",
purchase_reconcile_factory=(
lambda _address, _cancelled: ReadOnlyReconcileAdapter(
first_calls
)
),
device_connection_checker=lambda _serial: None,
).execute_one()
self.assertEqual(first.kind, "result_pending")
restarted_repository = TaskRepository(self.db_path)
restarted_calls = []
second = TaskDispatcher(
self.gateway,
restarted_repository,
self.client,
"",
purchase_adapter_factory=lambda *_args: restarted_calls.append(
"purchase"
),
purchase_reconcile_factory=lambda *_args: restarted_calls.append(
"reconcile"
),
).execute_one()
self.assertEqual(second.kind, "succeeded")
self.assertEqual(restarted_calls, [])
self.assertEqual(
restarted_repository.get_task(task_id).status,
TaskStatus.SUCCEEDED,
)
def test_live_submit_is_isolated_and_payment_methods_are_unavailable(self):
self.assertEqual(ClaimCapabilities(purchase_mode="live").purchase_mode, "live")