feat: 安全继续失败采购核单 (#262)

This commit is contained in:
chengma
2026-08-18 09:55:32 +08:00
parent a92b8746b2
commit 5ea8aa3d68
8 changed files with 480 additions and 20 deletions
+69
View File
@@ -313,6 +313,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
number: int,
task_type: TaskType = TaskType.COLLECT,
title: str = "测试商品",
execution_mode: str = "dry_run",
) -> None:
self.repository.add_claimed_task(
NewClaimedTask(
@@ -325,6 +326,7 @@ class PDDTaskPageEventTest(unittest.TestCase):
target_size="M",
price_cent=3990,
quantity=1 if task_type is TaskType.PURCHASE else None,
execution_mode=execution_mode,
),
received_at="2026-08-06T08:00:00Z",
)
@@ -762,6 +764,73 @@ class PDDTaskPageEventTest(unittest.TestCase):
events.shutdown()
page.deleteLater()
def test_purchase_rerun_confirmation_routes_reconcile_before_purchase(self):
self._add_task(
1,
TaskType.PURCHASE,
execution_mode="live",
)
self._add_task(
2,
TaskType.PURCHASE,
execution_mode="live",
)
started = self.repository.start_purchase_run("PDD-001", "USB-001")
self.repository.mark_purchase_irreversible(
"PDD-001",
started.attempt_id,
{
"options": {"color": "黑色", "size": "M"},
"quantity": 1,
"unit_price_cent": 3990,
"total_price_cent": 3990,
},
)
self.repository.move_purchase_to_reconcile(
"PDD-001", started.attempt_id
)
self.repository.save_purchase_reconciliation(
"PDD-001",
started.attempt_id,
"unknown",
{"error_code": "RECONCILE_READ_FAILED"},
)
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE pdd_tasks SET status = 'failed'"
" WHERE remote_task_id = 'PDD-002'"
)
finally:
connection.close()
page = PDDTaskPage()
events = PDDTaskPageEvent(
page,
self.repository,
claim_gateway=RecordingClaimGateway(),
settings_repository=self._saved_settings(),
)
with (
patch("src.pdd_ui_event.MessageBox") as message_box,
patch.object(events, "_start_rerun_worker") as start_worker,
):
message_box.return_value.exec.return_value = True
page.purchaseRerunRequested.emit(("PDD-002", "PDD-001"))
detail = message_box.call_args.args[1]
self.assertIn("可重新采购 1 条", detail)
self.assertIn("只继续核单 1 条", detail)
message_box.return_value.yesButton.setText.assert_called_once_with(
"继续处理"
)
command = start_worker.call_args.args[0]
self.assertEqual(command.task_ids, ("PDD-001", "PDD-002"))
self.assertEqual(command.reconcile_task_ids, ("PDD-001",))
events.shutdown()
page.deleteLater()
def test_running_rerun_can_request_cooperative_cancel_once(self):
self._add_task(1)
first = self.repository.start_collect_run("PDD-001", "USB-001")
+112 -1
View File
@@ -4,6 +4,7 @@ import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest.mock import patch
from src.admin_gateway import AdminTask, ClaimCapabilities, ClientInfo
from src.mock_admin_gateway import MockAdminGateway
@@ -19,8 +20,9 @@ from src.pdd_purchase_reconcile_adapter import (
PurchaseReconcileScan,
)
from src.purchase_task_service import PurchaseTaskService
from src.pdd_ui_event import ClaimTaskWorker
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
from src.task_models import RunStatus, TaskStatus, TaskType
from src.task_models import RunStatus, TaskRerunBatch, TaskStatus, TaskType
from src.task_repository import TaskRepository
@@ -348,6 +350,115 @@ class PurchaseRecoveryTest(unittest.TestCase):
run.diagnostics_json["reconciliation"]["error_message"],
)
def test_manual_reconcile_retry_reuses_run_and_never_purchases(self):
task_id = "PUR-RECONCILE-RETRY"
self._interrupt_after_irreversible(task_id)
first = TaskDispatcher(
self.gateway,
self.repository,
self.client,
"USB-001",
purchase_reconcile_factory=(
lambda _address, _cancelled: ReadOnlyReconcileAdapter(
[], error=ConnectionError("首次核单失败")
)
),
device_connection_checker=lambda _serial: None,
).execute_one()
self.assertEqual(first.kind, "manual_review")
before = self.repository.latest_task_run(task_id)
assert before is not None
purchase_calls = []
reconcile_calls = []
worker = ClaimTaskWorker(
self.gateway,
self.repository,
None,
None,
live_purchase_adapter_factory=(
lambda *_args: purchase_calls.append("purchase")
),
purchase_reconcile_factory=(
lambda _address, _cancelled: ReadOnlyReconcileAdapter(
reconcile_calls
)
),
device_connection_checker=lambda _serial: None,
)
outcomes = []
worker.outcome.connect(
lambda kind, message, result_task_id: outcomes.append(
(kind, message, result_task_id)
)
)
worker._execute_rerun_batch(
TaskRerunBatch(
TaskType.PURCHASE,
(task_id,),
(task_id,),
),
self.client,
"USB-001",
)
after = self.repository.latest_task_run(task_id)
assert after is not None
self.assertEqual(outcomes[0][0], "succeeded")
self.assertEqual(purchase_calls, [])
self.assertEqual(
reconcile_calls.count(("reconcile", "737116531267")), 1
)
self.assertEqual(after.attempt_id, before.attempt_id)
self.assertEqual(after.attempt_no, before.attempt_no)
self.assertEqual(
after.irreversible_action_at, before.irreversible_action_at
)
self.assertEqual(
self.repository.get_task(task_id).status,
TaskStatus.SUCCEEDED,
)
def test_reconcile_retry_failure_stops_following_purchase(self):
worker = ClaimTaskWorker(
self.gateway,
self.repository,
None,
None,
device_connection_checker=lambda _serial: None,
)
outcomes = []
worker.outcome.connect(
lambda kind, message, task_id: outcomes.append(
(kind, message, task_id)
)
)
with (
patch.object(
worker,
"_execute_purchase_reconcile_retry",
side_effect=ValueError("核单状态已变化"),
) as reconcile,
patch.object(worker, "_execute_purchase_rerun") as purchase,
):
worker._execute_rerun_batch(
TaskRerunBatch(
TaskType.PURCHASE,
("PUR-RECONCILE", "PUR-NEW"),
("PUR-RECONCILE",),
),
self.client,
"USB-001",
)
reconcile.assert_called_once()
purchase.assert_not_called()
self.assertEqual(
[item[0] for item in outcomes], ["skipped", "skipped"]
)
self.assertIn("未开始", outcomes[1][1])
def test_no_multiple_mismatched_and_paid_candidates_need_manual_review(self):
cases = {
"EMPTY": ((), "ORDER_NOT_FOUND", False),
+68
View File
@@ -608,8 +608,76 @@ class TaskRepositoryTests(unittest.TestCase):
)
self.assertEqual(plan.eligible_task_ids, ())
self.assertEqual(plan.reconcile_task_ids, ())
self.assertIn("只准核对订单", plan.blocked[0][1])
def test_purchase_rerun_plan_separates_safe_reconcile_retry(self):
self.repository.add_claimed_task(
self._task("PURCHASE-RECONCILE", TaskType.PURCHASE)
)
started = self.repository.start_purchase_run(
"PURCHASE-RECONCILE", "USB-001"
)
irreversible_at = "2026-08-11T00:00:00Z"
connection = open_database(self.db_path)
try:
with connection:
connection.execute(
"UPDATE task_runs SET run_status = 'manual_review',"
" current_step = 'reconcile_manual_review',"
" irreversible_action_at = ?, error_code = 'ORDER_MATCH_UNCERTAIN'"
" WHERE attempt_id = ?",
(irreversible_at, started.attempt_id),
)
connection.execute(
"UPDATE pdd_tasks SET status = 'manual_review',"
" current_step = 'reconcile_manual_review',"
" last_error_code = 'ORDER_MATCH_UNCERTAIN'"
" WHERE remote_task_id = ?",
("PURCHASE-RECONCILE",),
)
finally:
connection.close()
plan = self.repository.plan_rerun_batch(
("PURCHASE-RECONCILE",), TaskType.PURCHASE
)
self.assertEqual(plan.eligible_task_ids, ())
self.assertEqual(
plan.reconcile_task_ids, ("PURCHASE-RECONCILE",)
)
self.assertEqual(plan.blocked, ())
before = self.repository.latest_task_run("PURCHASE-RECONCILE")
prepared = self.repository.prepare_purchase_reconcile_retry(
"PURCHASE-RECONCILE"
)
after = self.repository.latest_task_run("PURCHASE-RECONCILE")
assert before is not None and after is not None
self.assertEqual(prepared.current_step, "reconcile_purchase")
self.assertEqual(after.current_step, "reconcile_purchase")
self.assertEqual(after.attempt_id, before.attempt_id)
self.assertEqual(after.attempt_no, before.attempt_no)
self.assertEqual(after.irreversible_action_at, irreversible_at)
self.assertEqual(after.diagnostics_json, before.diagnostics_json)
def test_purchase_before_irreversible_remains_eligible_for_rerun(self):
self.repository.add_claimed_task(
self._task("PURCHASE-BEFORE-SUBMIT", TaskType.PURCHASE)
)
plan = self.repository.plan_rerun_batch(
("PURCHASE-BEFORE-SUBMIT",), TaskType.PURCHASE
)
self.assertEqual(
plan.eligible_task_ids, ("PURCHASE-BEFORE-SUBMIT",)
)
self.assertEqual(plan.reconcile_task_ids, ())
self.assertEqual(plan.blocked, ())
def test_retry_wait_can_rerun_only_after_failure_outbox_is_sent(self):
self.repository.add_claimed_task(self._task("RETRY-RERUN"))
first = self.repository.start_collect_run("RETRY-RERUN", "USB-001")