feat(apply): block abnormal product statuses
This commit is contained in:
+96
-21
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import product_status
|
||||
from ..models import ApplyTaskTableModel
|
||||
from ..widgets import *
|
||||
from ..workers import ApplyWorker as _RealApplyWorker, WriteBackWorker as _RealWriteBackWorker
|
||||
@@ -244,21 +245,23 @@ class ApplyTab(QWidget):
|
||||
if self.apply_thread is not None:
|
||||
self._set_status("更新正在进行...")
|
||||
return
|
||||
tasks = [
|
||||
task for task in self.model.tasks
|
||||
if self._is_update_candidate_task(task)
|
||||
]
|
||||
if not tasks:
|
||||
base_candidates = self._update_candidates()
|
||||
if not base_candidates:
|
||||
self._set_status("当前筛选结果没有可更新任务")
|
||||
return
|
||||
update_cfg = self._shopee_update_config()
|
||||
update_mode = self._current_update_mode()
|
||||
content_plan = self._partition_update_content_tasks(tasks, update_mode)
|
||||
executable_tasks = content_plan["executable"]
|
||||
update_plan = product_status.build_apply_plan(base_candidates, update_mode)
|
||||
executable_tasks = update_plan["executable"]
|
||||
if not executable_tasks:
|
||||
content_error = self._update_content_error(content_plan, update_mode)
|
||||
QMessageBox.warning(self, "更新内容未生成", content_error)
|
||||
self._set_status(content_error.replace("\n", " "))
|
||||
preflight_error = self._update_preflight_error(update_plan, update_mode)
|
||||
title = (
|
||||
"商品状态不允许更新"
|
||||
if update_plan["status_scope_excluded"]
|
||||
else "更新内容未生成"
|
||||
)
|
||||
QMessageBox.warning(self, title, preflight_error)
|
||||
self._set_status(preflight_error.replace("\n", " "))
|
||||
return
|
||||
dry_run = bool(dry_run)
|
||||
answer = QMessageBox.question(
|
||||
@@ -267,7 +270,7 @@ class ApplyTab(QWidget):
|
||||
self._confirmation_message(
|
||||
executable_tasks,
|
||||
dry_run=dry_run,
|
||||
content_plan=content_plan,
|
||||
content_plan=update_plan,
|
||||
),
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
@@ -275,6 +278,18 @@ class ApplyTab(QWidget):
|
||||
if answer != QMessageBox.Yes:
|
||||
self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新")
|
||||
return
|
||||
current_plan = product_status.build_apply_plan(
|
||||
self._update_candidates(),
|
||||
update_mode,
|
||||
)
|
||||
if current_plan["fingerprint"] != update_plan["fingerprint"]:
|
||||
self._set_status("当前任务数据已变化,请重新开始更新")
|
||||
return
|
||||
executable_tasks = current_plan["executable"]
|
||||
if not executable_tasks:
|
||||
preflight_error = self._update_preflight_error(current_plan, update_mode)
|
||||
self._set_status(preflight_error.replace("\n", " "))
|
||||
return
|
||||
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
|
||||
worker = ApplyWorker(
|
||||
executable_tasks,
|
||||
@@ -288,6 +303,10 @@ class ApplyTab(QWidget):
|
||||
),
|
||||
batch_size=batch_size,
|
||||
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
||||
product_status_counts=current_plan["status_counts"],
|
||||
status_scope_excluded=current_plan["status_scope_excluded"],
|
||||
content_scope_excluded=current_plan["content_scope_excluded"],
|
||||
apply_plan_fingerprint=current_plan["fingerprint"],
|
||||
)
|
||||
worker.progress.connect(self._on_apply_progress)
|
||||
worker.row_updated.connect(self._on_apply_row_updated)
|
||||
@@ -304,9 +323,9 @@ class ApplyTab(QWidget):
|
||||
executable_tasks,
|
||||
dry_run=dry_run,
|
||||
batch_size=batch_size,
|
||||
content_plan=content_plan,
|
||||
content_plan=current_plan,
|
||||
)
|
||||
skip_status = self._content_skip_status(content_plan)
|
||||
skip_status = self._content_skip_status(current_plan)
|
||||
if dry_run:
|
||||
self._set_status(f"开始检查本轮更新:{len(executable_tasks)} 条{skip_status}")
|
||||
else:
|
||||
@@ -407,6 +426,13 @@ class ApplyTab(QWidget):
|
||||
and getattr(task, "status", None) in {"success", "pending", "failed"}
|
||||
)
|
||||
|
||||
def _update_candidates(self):
|
||||
return [
|
||||
task
|
||||
for task in self.model.tasks
|
||||
if self._is_update_candidate_task(task)
|
||||
]
|
||||
|
||||
def _populate_batch_filter(self, batches, selected_batch):
|
||||
had_previous_items = self.batch_filter.count() > 0
|
||||
self.batch_filter.blockSignals(True)
|
||||
@@ -617,6 +643,22 @@ class ApplyTab(QWidget):
|
||||
"missing_cover": missing_cover,
|
||||
}
|
||||
|
||||
def _update_preflight_error(self, update_plan, update_mode):
|
||||
if update_plan["executable"]:
|
||||
return None
|
||||
lines = []
|
||||
status_text = self._status_skip_text(update_plan)
|
||||
if status_text:
|
||||
lines.append("当前筛选结果没有状态正常且可更新的商品。")
|
||||
lines.append(status_text)
|
||||
lines.append("请先回到①导入采集重新确认商品状态。")
|
||||
content_error = self._update_content_error(update_plan, update_mode)
|
||||
if content_error:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append(content_error)
|
||||
return "\n".join(lines) or "当前筛选结果没有可更新任务。"
|
||||
|
||||
def _update_content_error(self, tasks_or_plan, update_mode):
|
||||
if isinstance(tasks_or_plan, dict):
|
||||
content_plan = tasks_or_plan
|
||||
@@ -643,12 +685,13 @@ class ApplyTab(QWidget):
|
||||
def _content_skip_text(self, content_plan):
|
||||
if not content_plan:
|
||||
return ""
|
||||
status_text = self._status_skip_text(content_plan)
|
||||
missing_title = content_plan.get("missing_title", [])
|
||||
missing_cover = content_plan.get("missing_cover", [])
|
||||
skipped_count = len({id(task) for task in missing_title + missing_cover})
|
||||
if not skipped_count:
|
||||
return ""
|
||||
lines = [f"本轮将跳过 {skipped_count} 条缺少所选更新内容的记录:"]
|
||||
lines = [status_text] if status_text else []
|
||||
if skipped_count:
|
||||
lines.append(f"本轮将跳过 {skipped_count} 条缺少所选更新内容的记录:")
|
||||
if missing_title:
|
||||
lines.append(
|
||||
f"缺少新标题:{len(missing_title)} 条(示例商品ID:{self._sample_item_ids(missing_title)})"
|
||||
@@ -662,16 +705,43 @@ class ApplyTab(QWidget):
|
||||
def _content_skip_status(self, content_plan):
|
||||
if not content_plan:
|
||||
return ""
|
||||
status_skipped = int(content_plan.get("status_scope_excluded", 0) or 0)
|
||||
missing_title = content_plan.get("missing_title", [])
|
||||
missing_cover = content_plan.get("missing_cover", [])
|
||||
skipped_count = len({id(task) for task in missing_title + missing_cover})
|
||||
if not skipped_count:
|
||||
if not skipped_count and not status_skipped:
|
||||
return ""
|
||||
return (
|
||||
f",跳过 {skipped_count} 条(缺标题 {len(missing_title)} / "
|
||||
f"缺封面 {len(missing_cover)})"
|
||||
parts = []
|
||||
if status_skipped:
|
||||
parts.append(f"状态异常 {status_skipped}")
|
||||
if skipped_count:
|
||||
parts.append(
|
||||
f"缺标题 {len(missing_title)} / 缺封面 {len(missing_cover)}"
|
||||
)
|
||||
return ",跳过 {total} 条({details})".format(
|
||||
total=status_skipped + skipped_count,
|
||||
details=";".join(parts),
|
||||
)
|
||||
|
||||
def _status_skip_text(self, update_plan):
|
||||
if not update_plan:
|
||||
return ""
|
||||
rows = [
|
||||
("未上架", update_plan.get("unlisted", [])),
|
||||
("审核中", update_plan.get("reviewing", [])),
|
||||
("状态未知", update_plan.get("unknown", [])),
|
||||
]
|
||||
skipped = sum(len(tasks) for _label, tasks in rows)
|
||||
if not skipped:
|
||||
return ""
|
||||
lines = [f"本轮将排除 {skipped} 条商品状态异常记录:"]
|
||||
for label, tasks in rows:
|
||||
if tasks:
|
||||
lines.append(
|
||||
f"{label}:{len(tasks)} 条(示例商品ID:{self._sample_item_ids(tasks)})"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _sample_item_ids(self, tasks):
|
||||
values = [
|
||||
str(getattr(task, "item_id", "") or "").strip()
|
||||
@@ -784,8 +854,13 @@ class ApplyTab(QWidget):
|
||||
)
|
||||
skip_status = self._content_skip_status(content_plan)
|
||||
if skip_status:
|
||||
excluded_label = (
|
||||
"预检排除记录"
|
||||
if content_plan and content_plan.get("status_scope_excluded")
|
||||
else "缺失记录"
|
||||
)
|
||||
self._append_run_log(
|
||||
f"本轮预检:可执行 {len(tasks)} 条{skip_status};缺失记录未进入本轮执行。"
|
||||
f"本轮预检:可执行 {len(tasks)} 条{skip_status};{excluded_label}未进入本轮执行。"
|
||||
)
|
||||
|
||||
def _load_latest_run_log(self):
|
||||
|
||||
+23
-1
@@ -1251,6 +1251,10 @@ class ApplyWorker(BaseWorker):
|
||||
max_parallel_accounts=1,
|
||||
batch_size=None,
|
||||
diagnostic_log_dir=None,
|
||||
product_status_counts=None,
|
||||
status_scope_excluded=0,
|
||||
content_scope_excluded=0,
|
||||
apply_plan_fingerprint=None,
|
||||
):
|
||||
super().__init__()
|
||||
self.tasks = list(tasks)
|
||||
@@ -1271,6 +1275,13 @@ class ApplyWorker(BaseWorker):
|
||||
self._batch_count = 0
|
||||
self._progress_lock = threading.Lock()
|
||||
self.diagnostic_log_dir = diagnostic_log_dir
|
||||
self.product_status_counts = {
|
||||
status: int((product_status_counts or {}).get(status, 0) or 0)
|
||||
for status in product_status.VALID_PRODUCT_STATUSES
|
||||
}
|
||||
self.status_scope_excluded = max(0, int(status_scope_excluded or 0))
|
||||
self.content_scope_excluded = max(0, int(content_scope_excluded or 0))
|
||||
self.apply_plan_fingerprint = str(apply_plan_fingerprint or "") or None
|
||||
self._run_id = None
|
||||
|
||||
def execute(self):
|
||||
@@ -1295,7 +1306,7 @@ class ApplyWorker(BaseWorker):
|
||||
}
|
||||
self._run_id = self._create_run_log(eligible, batch_ids)
|
||||
self._log_run_event(
|
||||
"step=start result=start detail=运行开始:{mode},更新内容{update_mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format(
|
||||
"step=start result=start detail=运行开始:{mode},更新内容{update_mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel};商品状态异常排除{status_excluded}条;内容缺失排除{content_excluded}条".format(
|
||||
mode="检查本轮更新" if self.dry_run else "真实更新",
|
||||
update_mode=_update_mode_label(self.update_mode),
|
||||
total=total,
|
||||
@@ -1306,6 +1317,8 @@ class ApplyWorker(BaseWorker):
|
||||
if self.max_parallel_accounts > 1
|
||||
else "串行"
|
||||
),
|
||||
status_excluded=self.status_scope_excluded,
|
||||
content_excluded=self.content_scope_excluded,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1363,6 +1376,7 @@ class ApplyWorker(BaseWorker):
|
||||
return (
|
||||
getattr(task, "stage", None) == "generated"
|
||||
and getattr(task, "status", None) in {"success", "pending", "failed"}
|
||||
and product_status.is_normal(getattr(task, "product_status", None))
|
||||
and (
|
||||
(
|
||||
appconfig.update_mode_includes_title(self.update_mode)
|
||||
@@ -1766,6 +1780,10 @@ class ApplyWorker(BaseWorker):
|
||||
"batch_count": self._batch_count,
|
||||
"update_mode": self.update_mode,
|
||||
"run_id": self._run_id,
|
||||
"product_status_counts": dict(self.product_status_counts),
|
||||
"status_scope_excluded": self.status_scope_excluded,
|
||||
"content_scope_excluded": self.content_scope_excluded,
|
||||
"apply_plan_fingerprint": self.apply_plan_fingerprint,
|
||||
}
|
||||
if blocked:
|
||||
summary["blocked"] = True
|
||||
@@ -1787,6 +1805,10 @@ class ApplyWorker(BaseWorker):
|
||||
"batch_size": self._current_batch_size,
|
||||
"batch_count": self._batch_count,
|
||||
"update_mode": self.update_mode,
|
||||
"product_status_counts": dict(self.product_status_counts),
|
||||
"status_scope_excluded": self.status_scope_excluded,
|
||||
"content_scope_excluded": self.content_scope_excluded,
|
||||
"apply_plan_fingerprint": self.apply_plan_fingerprint,
|
||||
},
|
||||
path=self.db_path,
|
||||
)
|
||||
|
||||
+57
-3
@@ -125,7 +125,61 @@ def build_generation_plan(tasks, generate_mode, scope=SCOPE_NORMAL_ONLY) -> dict
|
||||
"execution_tasks": execution_tasks,
|
||||
"scope": normalized_scope,
|
||||
"scope_excluded": len(base_candidates) - len(execution_tasks),
|
||||
"fingerprint": _generation_fingerprint(base_candidates, generate_mode),
|
||||
"fingerprint": _task_plan_fingerprint(base_candidates, generate_mode),
|
||||
}
|
||||
|
||||
|
||||
def build_apply_plan(tasks, update_mode) -> dict:
|
||||
"""Return a status-first, content-aware plan for Shopee update actions."""
|
||||
|
||||
base_candidates = _deduplicate_tasks(tasks)
|
||||
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
|
||||
status_excluded = {
|
||||
STATUS_UNLISTED: [],
|
||||
STATUS_REVIEWING: [],
|
||||
STATUS_UNKNOWN: [],
|
||||
}
|
||||
normal_candidates = []
|
||||
for task in base_candidates:
|
||||
status = _task_status(task)
|
||||
status_counts[status] += 1
|
||||
if status == STATUS_NORMAL:
|
||||
normal_candidates.append(task)
|
||||
else:
|
||||
status_excluded[status].append(task)
|
||||
|
||||
mode = str(update_mode or "").strip().lower()
|
||||
requires_title = mode in {"title", "title_cover"}
|
||||
requires_cover = mode in {"cover", "title_cover"}
|
||||
missing_title = [
|
||||
task
|
||||
for task in normal_candidates
|
||||
if requires_title and not str(_task_value(task, "new_title") or "").strip()
|
||||
]
|
||||
missing_cover = [
|
||||
task
|
||||
for task in normal_candidates
|
||||
if requires_cover and not str(_task_value(task, "new_cover_path") or "").strip()
|
||||
]
|
||||
content_excluded_ids = {id(task) for task in missing_title + missing_cover}
|
||||
executable_tasks = [
|
||||
task for task in normal_candidates if id(task) not in content_excluded_ids
|
||||
]
|
||||
status_excluded_count = sum(len(rows) for rows in status_excluded.values())
|
||||
|
||||
return {
|
||||
"base_candidates": base_candidates,
|
||||
"status_counts": status_counts,
|
||||
"executable": executable_tasks,
|
||||
"unlisted": status_excluded[STATUS_UNLISTED],
|
||||
"reviewing": status_excluded[STATUS_REVIEWING],
|
||||
"unknown": status_excluded[STATUS_UNKNOWN],
|
||||
"missing_title": missing_title,
|
||||
"missing_cover": missing_cover,
|
||||
"status_scope_excluded": status_excluded_count,
|
||||
"content_scope_excluded": len(content_excluded_ids),
|
||||
"scope_excluded": status_excluded_count + len(content_excluded_ids),
|
||||
"fingerprint": _task_plan_fingerprint(base_candidates, update_mode),
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +213,7 @@ def _deduplicate_tasks(tasks) -> list:
|
||||
return unique
|
||||
|
||||
|
||||
def _generation_fingerprint(tasks, generate_mode) -> str:
|
||||
def _task_plan_fingerprint(tasks, mode) -> str:
|
||||
snapshots = [
|
||||
{
|
||||
"task_id": _task_value(task, "id"),
|
||||
@@ -167,7 +221,7 @@ def _generation_fingerprint(tasks, generate_mode) -> str:
|
||||
"product_status": _task_status(task),
|
||||
"new_title": _task_value(task, "new_title"),
|
||||
"new_cover_path": _task_value(task, "new_cover_path"),
|
||||
"generate_mode": str(generate_mode or ""),
|
||||
"mode": str(mode or ""),
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user