feat: 完善采购任务安全恢复 (#73)
This commit is contained in:
@@ -23,6 +23,7 @@ from .task_models import (
|
||||
TaskStatus,
|
||||
TaskSummary,
|
||||
TaskType,
|
||||
TaskRunRecord,
|
||||
)
|
||||
|
||||
|
||||
@@ -156,7 +157,7 @@ class TaskRepository:
|
||||
return self._to_detail(row) if row is not None else None
|
||||
|
||||
def recover_interrupted_work(self) -> None:
|
||||
"""恢复上次异常退出留下的可重试状态。"""
|
||||
"""恢复上次异常退出留下的任务,不猜测内存状态。"""
|
||||
|
||||
now = utc_now_iso()
|
||||
connection = open_database(self._db_path)
|
||||
@@ -193,6 +194,64 @@ class TaskRepository:
|
||||
" AND task_id IN (SELECT id FROM pdd_tasks WHERE task_type = 'collect')",
|
||||
(now, now),
|
||||
)
|
||||
purchase_runs = connection.execute(
|
||||
"SELECT t.id AS task_id, r.irreversible_action_at"
|
||||
" FROM pdd_tasks t JOIN task_runs r ON r.task_id = t.id"
|
||||
" WHERE t.task_type = 'purchase' AND t.status = 'running'"
|
||||
" AND r.run_status = 'running'"
|
||||
" ORDER BY r.attempt_no DESC"
|
||||
).fetchall()
|
||||
recovered_task_ids = set()
|
||||
for run in purchase_runs:
|
||||
task_id = int(run["task_id"])
|
||||
if task_id in recovered_task_ids:
|
||||
continue
|
||||
recovered_task_ids.add(task_id)
|
||||
irreversible = connection.execute(
|
||||
"SELECT 1 FROM task_runs WHERE task_id = ?"
|
||||
" AND run_status = 'running'"
|
||||
" AND irreversible_action_at IS NOT NULL LIMIT 1",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if irreversible is not None:
|
||||
message = (
|
||||
"上次采购在不可逆阶段中断,"
|
||||
"只允许核对订单"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE task_runs SET run_status = 'manual_review',"
|
||||
" current_step = 'reconcile_purchase',"
|
||||
" error_code = 'PURCHASE_OUTCOME_UNKNOWN',"
|
||||
" error_message = ?, finished_at = ?, updated_at = ?"
|
||||
" WHERE task_id = ? AND run_status = 'running'",
|
||||
(message, now, now, task_id),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'manual_review',"
|
||||
" current_step = 'reconcile_purchase',"
|
||||
" last_error_code = 'PURCHASE_OUTCOME_UNKNOWN',"
|
||||
" last_error_message = ?, finished_at = ?, updated_at = ?"
|
||||
" WHERE id = ?",
|
||||
(message, now, now, task_id),
|
||||
)
|
||||
else:
|
||||
message = "采购演练上次执行中断,已关闭旧执行记录"
|
||||
connection.execute(
|
||||
"UPDATE task_runs SET run_status = 'failed',"
|
||||
" error_code = 'CLIENT_INTERRUPTED',"
|
||||
" error_message = ?, finished_at = ?, updated_at = ?"
|
||||
" WHERE task_id = ? AND run_status = 'running'",
|
||||
(message, now, now, task_id),
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'claimed',"
|
||||
" current_step = 'purchase_recovery_ready',"
|
||||
" retry_count = retry_count + 1,"
|
||||
" last_error_code = 'CLIENT_INTERRUPTED',"
|
||||
" last_error_message = ?, finished_at = NULL,"
|
||||
" updated_at = ? WHERE id = ?",
|
||||
(message, now, task_id),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@@ -224,6 +283,39 @@ class TaskRepository:
|
||||
connection.close()
|
||||
return self._to_detail(row) if row is not None else None
|
||||
|
||||
def next_purchase_reconcile_task(self) -> Optional[TaskDetail]:
|
||||
"""返回最早一条只允许读取核对的采购任务。"""
|
||||
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM pdd_tasks WHERE task_type = 'purchase'"
|
||||
" AND status = 'manual_review'"
|
||||
" AND current_step = 'reconcile_purchase'"
|
||||
" ORDER BY received_at ASC, id ASC LIMIT 1"
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
return self._to_detail(row) if row is not None else None
|
||||
|
||||
def latest_task_run(
|
||||
self, remote_task_id: str
|
||||
) -> Optional[TaskRunRecord]:
|
||||
"""返回任务最新的执行记录。"""
|
||||
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT r.* FROM task_runs r"
|
||||
" JOIN pdd_tasks t ON t.id = r.task_id"
|
||||
" WHERE t.remote_task_id = ?"
|
||||
" ORDER BY r.attempt_no DESC LIMIT 1",
|
||||
(remote_task_id,),
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
return self._to_task_run(row) if row is not None else None
|
||||
|
||||
def next_runnable_task(
|
||||
self, *, include_purchase: bool
|
||||
) -> Optional[TaskDetail]:
|
||||
@@ -479,6 +571,92 @@ class TaskRepository:
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def save_purchase_reconciliation(
|
||||
self,
|
||||
remote_task_id: str,
|
||||
attempt_id: str,
|
||||
match_status: str,
|
||||
diagnostics: Dict[str, object],
|
||||
) -> None:
|
||||
"""保存一次只读订单核对结果,任务仍留给人工确认。"""
|
||||
|
||||
allowed = {"matched", "not_found", "ambiguous", "unknown"}
|
||||
if match_status not in allowed:
|
||||
raise ValueError("采购核对结果无效")
|
||||
now = utc_now_iso()
|
||||
step = (
|
||||
"reconcile_completed"
|
||||
if match_status == "matched"
|
||||
else "reconcile_manual_review"
|
||||
)
|
||||
messages = {
|
||||
"matched": "只读核对发现唯一候选订单,请人工确认",
|
||||
"not_found": "只读核对未找到订单,不得重新下单",
|
||||
"ambiguous": "只读核对发现多个候选订单,请人工确认",
|
||||
"unknown": "无法确定采购结果,不得重新下单",
|
||||
}
|
||||
message = messages[match_status]
|
||||
saved_diagnostics = dict(diagnostics)
|
||||
saved_diagnostics["match_status"] = match_status
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
with connection:
|
||||
task = connection.execute(
|
||||
"SELECT id, task_type, status, current_step FROM pdd_tasks"
|
||||
" WHERE remote_task_id = ?",
|
||||
(remote_task_id,),
|
||||
).fetchone()
|
||||
if task is None:
|
||||
raise ValueError(f"任务 {remote_task_id} 不存在")
|
||||
if task["task_type"] != TaskType.PURCHASE.value:
|
||||
raise ValueError("当前任务不是采购任务")
|
||||
if (
|
||||
task["status"] != TaskStatus.MANUAL_REVIEW.value
|
||||
or task["current_step"] != "reconcile_purchase"
|
||||
):
|
||||
raise ValueError("采购任务当前不在待核对状态")
|
||||
run = connection.execute(
|
||||
"SELECT irreversible_action_at, run_status, current_step"
|
||||
" FROM task_runs"
|
||||
" WHERE task_id = ? AND attempt_id = ?",
|
||||
(task["id"], attempt_id),
|
||||
).fetchone()
|
||||
if run is None or run["irreversible_action_at"] is None:
|
||||
raise ValueError("只有已进入不可逆阶段的运行才能核对")
|
||||
if (
|
||||
run["run_status"] != RunStatus.MANUAL_REVIEW.value
|
||||
or run["current_step"] != "reconcile_purchase"
|
||||
):
|
||||
raise ValueError("采购执行记录已经核对或状态已变更")
|
||||
run_cursor = connection.execute(
|
||||
"UPDATE task_runs SET run_status = 'manual_review',"
|
||||
" current_step = ?, error_code = 'PURCHASE_RECONCILED',"
|
||||
" error_message = ?, diagnostics_json = ?, updated_at = ?"
|
||||
" WHERE task_id = ? AND attempt_id = ?",
|
||||
(
|
||||
step,
|
||||
message,
|
||||
json.dumps(saved_diagnostics, ensure_ascii=False),
|
||||
now,
|
||||
task["id"],
|
||||
attempt_id,
|
||||
),
|
||||
)
|
||||
if run_cursor.rowcount != 1:
|
||||
raise ValueError("采购执行记录核对保存失败")
|
||||
task_cursor = connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'manual_review',"
|
||||
" current_step = ?, last_error_code = 'PURCHASE_RECONCILED',"
|
||||
" last_error_message = ?, updated_at = ? WHERE id = ?"
|
||||
" AND status = 'manual_review'"
|
||||
" AND current_step = 'reconcile_purchase'",
|
||||
(step, message, now, task["id"]),
|
||||
)
|
||||
if task_cursor.rowcount != 1:
|
||||
raise ValueError("采购任务状态已变更,核对结果未保存")
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def save_purchase_result(
|
||||
self,
|
||||
remote_task_id: str,
|
||||
@@ -628,7 +806,8 @@ class TaskRepository:
|
||||
}.get(status, RunStatus.FAILED)
|
||||
cursor = connection.execute(
|
||||
"UPDATE task_runs SET run_status = ?, current_step = ?,"
|
||||
" error_code = ?, error_message = ?, finished_at = ?,"
|
||||
" error_code = ?, error_message = ?, diagnostics_json = ?,"
|
||||
" finished_at = ?,"
|
||||
" updated_at = ? WHERE task_id = ? AND attempt_id = ?"
|
||||
" AND run_status = 'running'",
|
||||
(
|
||||
@@ -636,6 +815,7 @@ class TaskRepository:
|
||||
checked_step,
|
||||
error_code,
|
||||
error_message,
|
||||
json.dumps(diagnostics or {}, ensure_ascii=False),
|
||||
now,
|
||||
now,
|
||||
task["id"],
|
||||
@@ -935,6 +1115,39 @@ class TaskRepository:
|
||||
sent_at=row["sent_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_task_run(row: sqlite3.Row) -> TaskRunRecord:
|
||||
diagnostics = (
|
||||
TaskRepository._load_json_object(row["diagnostics_json"])
|
||||
if row["diagnostics_json"] is not None
|
||||
else None
|
||||
)
|
||||
result_data = (
|
||||
TaskRepository._load_json_object(row["result_data"])
|
||||
if row["result_data"] is not None
|
||||
else None
|
||||
)
|
||||
return TaskRunRecord(
|
||||
id=row["id"],
|
||||
task_id=row["task_id"],
|
||||
attempt_id=row["attempt_id"],
|
||||
attempt_no=row["attempt_no"],
|
||||
device_address=row["device_address"],
|
||||
run_status=RunStatus(row["run_status"]),
|
||||
current_step=row["current_step"],
|
||||
started_at=row["started_at"],
|
||||
finished_at=row["finished_at"],
|
||||
irreversible_action_at=row["irreversible_action_at"],
|
||||
order_submitted_at=row["order_submitted_at"],
|
||||
error_code=row["error_code"],
|
||||
error_message=row["error_message"],
|
||||
diagnostics_json=diagnostics,
|
||||
result_data=result_data,
|
||||
artifact_directory=row["artifact_directory"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_page(limit: int, offset: int) -> None:
|
||||
if not 1 <= limit <= MAX_PAGE_SIZE:
|
||||
|
||||
Reference in New Issue
Block a user