feat: 安全继续失败采购核单 (#262)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user