Files
cmshoppe/app/product_status.py
T

262 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Shared product-status rules for the collection, generation, and apply flows."""
from __future__ import annotations
import hashlib
import json
import re
STATUS_NORMAL = "normal"
STATUS_UNLISTED = "unlisted"
STATUS_REVIEWING = "reviewing"
STATUS_UNKNOWN = "unknown"
VALID_PRODUCT_STATUSES = frozenset(
{
STATUS_NORMAL,
STATUS_UNLISTED,
STATUS_REVIEWING,
STATUS_UNKNOWN,
}
)
PRODUCT_STATUS_LABELS = {
STATUS_NORMAL: "正常",
STATUS_UNLISTED: "未上架",
STATUS_REVIEWING: "审核中",
STATUS_UNKNOWN: "状态未知",
}
SCOPE_NORMAL_ONLY = "normal_only"
SCOPE_ALL = "all"
# Kept as explicit collection aliases while generation and apply adopt the
# shared scope values in their own tasks.
COLLECT_SCOPE_NORMAL_ONLY = SCOPE_NORMAL_ONLY
COLLECT_SCOPE_ALL = SCOPE_ALL
_WHITESPACE_RE = re.compile(r"\s+")
_NOTE_LIMIT = 2000
def normalize_status(value) -> str:
"""Return a stable status code; legacy and invalid values are unknown."""
normalized = str(value or "").strip().lower()
if normalized in VALID_PRODUCT_STATUSES:
return normalized
return STATUS_UNKNOWN
def status_label(value) -> str:
return PRODUCT_STATUS_LABELS[normalize_status(value)]
def is_normal(value) -> bool:
return normalize_status(value) == STATUS_NORMAL
def is_known_abnormal(value) -> bool:
return normalize_status(value) in {STATUS_UNLISTED, STATUS_REVIEWING}
def normalize_text(value) -> str:
return _WHITESPACE_RE.sub(" ", str(value or "").strip())
def classify_alerts(alerts) -> dict:
"""Classify normalized EDS warning-alert data without retaining page HTML."""
if not isinstance(alerts, list):
return _snapshot(STATUS_UNKNOWN, "")
if not alerts:
return _snapshot(STATUS_NORMAL, "")
first_note = ""
valid_alert_seen = False
for alert in alerts:
if not isinstance(alert, dict):
continue
valid_alert_seen = True
title = normalize_text(alert.get("title"))
description = normalize_text(alert.get("description"))
note = _alert_note(title, description)
if not first_note:
first_note = note
if title in {"審核中", "审核中"}:
return _snapshot(STATUS_REVIEWING, note)
if "您的商品未上架" in title:
return _snapshot(STATUS_UNLISTED, note)
if not valid_alert_seen:
return _snapshot(STATUS_UNKNOWN, "")
return _snapshot(STATUS_UNKNOWN, first_note)
def partition_tasks(tasks) -> dict:
"""Return tasks grouped by normalized status for callers' run planners."""
grouped = {status: [] for status in VALID_PRODUCT_STATUSES}
for task in tasks:
grouped[normalize_status(getattr(task, "product_status", None))].append(task)
return grouped
def build_generation_plan(tasks, generate_mode, scope=SCOPE_NORMAL_ONLY) -> dict:
"""Build a frozen, status-aware generation plan without mutating tasks."""
base_candidates = _deduplicate_tasks(tasks)
status_counts = {status: 0 for status in VALID_PRODUCT_STATUSES}
for task in base_candidates:
status_counts[_task_status(task)] += 1
normalized_scope = normalize_scope(scope)
if normalized_scope == SCOPE_ALL:
execution_tasks = list(base_candidates)
else:
execution_tasks = [
task for task in base_candidates if is_normal(_task_status(task))
]
return {
"base_candidates": base_candidates,
"status_counts": status_counts,
"execution_tasks": execution_tasks,
"scope": normalized_scope,
"scope_excluded": len(base_candidates) - len(execution_tasks),
"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),
}
def normalize_collect_scope(value) -> str:
return normalize_scope(value)
def normalize_scope(value) -> str:
value = str(value or "").strip().lower()
return SCOPE_ALL if value == SCOPE_ALL else SCOPE_NORMAL_ONLY
def should_collect_content(status, scope) -> bool:
return normalize_collect_scope(scope) == COLLECT_SCOPE_ALL or is_normal(status)
def collect_skip_reason(status) -> str:
return f"{status_label(status)},按本轮范围略过"
def _deduplicate_tasks(tasks) -> list:
seen = set()
unique = []
for task in list(tasks or []):
task_id = _task_value(task, "id")
key = ("id", str(task_id)) if task_id is not None else ("object", id(task))
if key in seen:
continue
seen.add(key)
unique.append(task)
return unique
def _task_plan_fingerprint(tasks, mode) -> str:
snapshots = [
{
"task_id": _task_value(task, "id"),
"updated_at": _task_value(task, "updated_at"),
"product_status": _task_status(task),
"new_title": _task_value(task, "new_title"),
"new_cover_path": _task_value(task, "new_cover_path"),
"mode": str(mode or ""),
}
for task in tasks
]
encoded = json.dumps(
snapshots,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _task_status(task) -> str:
return normalize_status(_task_value(task, "product_status"))
def _task_value(task, name, default=None):
if isinstance(task, dict):
return task.get(name, default)
return getattr(task, name, default)
def _alert_note(title: str, description: str) -> str:
parts = []
if title:
parts.append(f"标题:{title}")
if description:
parts.append(f"说明:{description}")
return ";".join(parts)[:_NOTE_LIMIT]
def _snapshot(status: str, note: str) -> dict:
return {
"product_status": normalize_status(status),
"product_status_note": str(note or "")[:_NOTE_LIMIT] or None,
}