feat: 完善真实采购订单核对与恢复 (#100)
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""只读订单核对 XML 解析测试;不连接真实手机。"""
|
||||
|
||||
import unittest
|
||||
|
||||
from src.pdd_purchase_reconcile_adapter import PurchaseReconcileQuery
|
||||
from src.pdd_u2_purchase_reconcile_adapter import parse_order_candidates
|
||||
|
||||
|
||||
def query() -> PurchaseReconcileQuery:
|
||||
return PurchaseReconcileQuery(
|
||||
goods_id="737116531267",
|
||||
options={"color": "黑色", "size": "L"},
|
||||
quantity=2,
|
||||
unit_price_cent=4200,
|
||||
total_price_cent=8400,
|
||||
irreversible_action_at="2026-08-10T08:00:00Z",
|
||||
reconcile_started_at="2026-08-10T08:05:00Z",
|
||||
)
|
||||
|
||||
|
||||
def xml_card(text: str) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
'<hierarchy rotation="0">'
|
||||
f'<node text="" content-desc="{text}" bounds="[0,100][1080,900]" '
|
||||
'visible-to-user="true" enabled="true" />'
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
|
||||
class PddU2PurchaseReconcileAdapterTest(unittest.TestCase):
|
||||
def test_parser_extracts_only_required_unpaid_order_fields(self):
|
||||
candidates = parse_order_candidates(
|
||||
xml_card(
|
||||
"待付款 订单编号:ORDER-20260810 商品编号:737116531267 "
|
||||
"黑色 L 共2件 合计 ¥84.00 下单时间:2026-08-10 16:03:00"
|
||||
),
|
||||
query(),
|
||||
)
|
||||
|
||||
self.assertEqual(len(candidates), 1)
|
||||
candidate = candidates[0]
|
||||
self.assertEqual(candidate.order_no, "ORDER-20260810")
|
||||
self.assertEqual(candidate.goods_id, "737116531267")
|
||||
self.assertEqual(candidate.options, {"color": "黑色", "size": "L"})
|
||||
self.assertEqual(candidate.quantity, 2)
|
||||
self.assertEqual(candidate.total_price_cent, 8400)
|
||||
self.assertEqual(candidate.ordered_at, "2026-08-10T08:03:00Z")
|
||||
self.assertEqual(candidate.payment_status, "unpaid")
|
||||
self.assertFalse(hasattr(candidate, "recipient"))
|
||||
self.assertFalse(hasattr(candidate, "address"))
|
||||
|
||||
def test_paid_or_cancelled_text_never_becomes_unpaid(self):
|
||||
candidates = parse_order_candidates(
|
||||
xml_card(
|
||||
"待付款 已付款 订单编号:ORDER-PAID 商品编号:737116531267 "
|
||||
"黑色 L 共2件 合计 ¥84.00 下单时间:2026-08-10 16:03:00"
|
||||
),
|
||||
query(),
|
||||
)
|
||||
|
||||
self.assertEqual(candidates[0].payment_status, "other")
|
||||
|
||||
def test_missing_goods_id_and_time_are_kept_incomplete_for_manual_review(self):
|
||||
candidates = parse_order_candidates(
|
||||
xml_card("待付款 订单编号:ORDER-INCOMPLETE 黑色 L 共2件 合计 ¥84.00"),
|
||||
query(),
|
||||
)
|
||||
|
||||
self.assertEqual(candidates[0].goods_id, "")
|
||||
self.assertEqual(candidates[0].ordered_at, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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")
|
||||
|
||||
@@ -122,8 +122,13 @@ class TaskDetailViewTest(unittest.TestCase):
|
||||
def test_purchase_recovery_steps_are_clear_chinese(self):
|
||||
cases = {
|
||||
"purchase_dry_run_stopped": "采购演练已在提交前停止",
|
||||
"reconcile_purchase": "只允许核对订单",
|
||||
"reconcile_manual_review": "核对结果不确定,需人工处理",
|
||||
"reconcile_purchase": "订单已提交,只允许核对未付款订单",
|
||||
"purchase_order_matched_pending_report": (
|
||||
"已提交待付款,等待向 Admin 上报"
|
||||
),
|
||||
"reconcile_manual_review": (
|
||||
"订单结果不确定或存在多个候选,需人工处理"
|
||||
),
|
||||
}
|
||||
for step, expected in cases.items():
|
||||
with self.subTest(step=step):
|
||||
|
||||
@@ -113,10 +113,21 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
"PURCHASE-LIVE", "192.168.0.173:5555"
|
||||
)
|
||||
marked_at = self.repository.mark_purchase_irreversible(
|
||||
"PURCHASE-LIVE", started.attempt_id
|
||||
"PURCHASE-LIVE",
|
||||
started.attempt_id,
|
||||
{
|
||||
"options": {"color": "黑色", "size": "L"},
|
||||
"quantity": 2,
|
||||
"unit_price_cent": 3990,
|
||||
"total_price_cent": 7980,
|
||||
},
|
||||
)
|
||||
run = self.repository.latest_task_run("PURCHASE-LIVE")
|
||||
self.assertEqual(run.irreversible_action_at, marked_at)
|
||||
self.assertEqual(
|
||||
run.diagnostics_json["final_confirmation"]["total_price_cent"],
|
||||
7980,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "已经存在"):
|
||||
self.repository.mark_purchase_irreversible(
|
||||
"PURCHASE-LIVE", started.attempt_id
|
||||
|
||||
Reference in New Issue
Block a user