feat: 完善真实采购订单核对与恢复 (#100)
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user