chore: [workflow] 提交工作区改动
This commit is contained in:
+19
-1
@@ -114,7 +114,7 @@
|
||||
"workflow_id": "product.erp_all_shop_daily"
|
||||
},
|
||||
{
|
||||
"at": "10:00",
|
||||
"at": "12:00",
|
||||
"business_date_offset_days": 0,
|
||||
"days": [
|
||||
"Tuesday"
|
||||
@@ -143,6 +143,24 @@
|
||||
"kind": "interval_days",
|
||||
"workflow_id": "product.style_analysis.interval"
|
||||
},
|
||||
{
|
||||
"anchor_date": "2026-09-04",
|
||||
"at": "14:00",
|
||||
"business_date_offset_days": 0,
|
||||
"enabled": true,
|
||||
"every_days": 3,
|
||||
"kind": "interval_days",
|
||||
"workflow_id": "product.jd_video_upload"
|
||||
},
|
||||
{
|
||||
"anchor_date": "2026-09-04",
|
||||
"at": "14:00",
|
||||
"business_date_offset_days": 0,
|
||||
"enabled": true,
|
||||
"every_days": 3,
|
||||
"kind": "interval_days",
|
||||
"workflow_id": "product.video_upload"
|
||||
},
|
||||
{
|
||||
"at": "08:30",
|
||||
"days": [
|
||||
|
||||
@@ -1638,7 +1638,7 @@
|
||||
{
|
||||
"id": "product.video_upload",
|
||||
"module": "product_commerce",
|
||||
"trigger": "manual",
|
||||
"trigger": "scheduled",
|
||||
"execution": {
|
||||
"steps": [
|
||||
{
|
||||
@@ -1703,12 +1703,12 @@
|
||||
"label": "天猫话题关键词",
|
||||
"hint": "输入完整话题或关键词;例如输入“秋上新”也会匹配并选中“秋上新开拍了”。"
|
||||
},
|
||||
"note": "前端手动选择旗舰店或箱包店后执行;脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。"
|
||||
"note": "默认由常驻调度器按 config/schedules.json 每 3 天 14:00 执行;前端仍可手动选择旗舰店或箱包店后执行。脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。"
|
||||
},
|
||||
{
|
||||
"id": "product.jd_video_upload",
|
||||
"module": "product_commerce",
|
||||
"trigger": "manual",
|
||||
"trigger": "scheduled",
|
||||
"execution": {
|
||||
"steps": [
|
||||
{
|
||||
@@ -1765,7 +1765,7 @@
|
||||
"source_project": "product",
|
||||
"task_name": "JdVideoUpload"
|
||||
},
|
||||
"note": "前端手动幂等工作流;每次点击从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。"
|
||||
"note": "默认由常驻调度器按 config/schedules.json 每 3 天 14:00 执行;前端仍可手动触发。每次从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。"
|
||||
},
|
||||
{
|
||||
"id": "product.tmall_baibu_apply",
|
||||
|
||||
+2
-2
@@ -51,7 +51,7 @@ uv run gyxx scripts run <command_id> --date 2026-08-01 --execute
|
||||
|
||||
### 天猫视频上传工作流
|
||||
|
||||
天猫视频上传注册为手动幂等工作流 `product.video_upload`,不会被常驻调度器自动触发。前端“立即运行”窗口会显示“光影行星旗舰店 / 光影行星鑫华达专卖店:龙虾仔”单选下拉框,并允许填写天猫话题完整名称或关键词;正式执行只处理所选店铺,话题搜索结果按包含关系选中。默认 dry-run 只生成运行记录并跳过浏览器节点;确认飞书待传记录、对应淘宝光合店铺的登录态和 PostgreSQL 后,再显式正式执行:
|
||||
天猫视频上传注册为幂等定时工作流 `product.video_upload`,按 `config/schedules.json` 以 2026-09-04 为起始日每 3 天 14:00(Asia/Shanghai)执行;前端仍可手动选择“光影行星旗舰店 / 光影行星鑫华达专卖店:龙虾仔”并立即运行,也可填写天猫话题完整名称或关键词。正式执行只处理所选店铺,话题搜索结果按包含关系选中。默认 dry-run 只生成运行记录并跳过浏览器节点;确认飞书待传记录、对应淘宝光合店铺的登录态和 PostgreSQL 后,再显式正式执行:
|
||||
|
||||
```bash
|
||||
uv run gyxx run product.video_upload --date 2026-08-07
|
||||
@@ -74,7 +74,7 @@ uv run gyxx scripts run product.video_upload.run --date 2026-08-07 --arg=--recor
|
||||
|
||||
### 京东视频上传工作流
|
||||
|
||||
京东视频上传注册为手动幂等工作流 `product.jd_video_upload`。它不会被常驻调度器自动触发;同一业务日期可以重复执行。每次都从飞书视图中的固定凌云air记录 `recvrXTRWWFXeJ`(含)扫描到视图末尾,不依赖“日期”字段。真正的防重键是 PostgreSQL 中的 `视频 file_token + 目标账号 + 飞书记录`:
|
||||
京东视频上传注册为幂等定时工作流 `product.jd_video_upload`,按 `config/schedules.json` 以 2026-09-04 为起始日每 3 天 14:00(Asia/Shanghai)执行;前端仍可手动立即运行,同一业务日期可以重复执行。每次都从飞书视图中的固定凌云air记录 `recvrXTRWWFXeJ`(含)扫描到视图末尾,不依赖“日期”字段。真正的防重键是 PostgreSQL 中的 `视频 file_token + 目标账号 + 飞书记录`:
|
||||
|
||||
```bash
|
||||
uv run gyxx run product.jd_video_upload --date 2026-08-07
|
||||
|
||||
@@ -41,7 +41,7 @@ uv run gyxx schedule run
|
||||
- 查看工作流定义、执行步骤、定时规则和下一次启动时间;
|
||||
- 修改定时类型、一个或多个时间、日期规则、启停状态和业务日期偏移;
|
||||
- 配置实际支持业务通知的工作流、发送应用和一个或多个收件人;
|
||||
- 在云端数据库中增删改查商品 ID、ERP 款式编码和各业务飞书目标表;
|
||||
- 在云端数据库中增删改查业务目标表、平台画像目标和启停状态;款式名、ERP 款式编码、各平台商品编码及大/小 NFC 数统一在“产品生命进程”维护;
|
||||
- 查看昨天或指定执行日的逐工作流运行汇总、异常原因和修复建议;
|
||||
- 以安全预演或正式执行方式手动触发已注册工作流;
|
||||
- 查看最近运行状态、步骤统计和经过脱敏的错误详情。
|
||||
@@ -52,9 +52,10 @@ uv run gyxx schedule run
|
||||
|
||||
## 商品与款式配置
|
||||
|
||||
侧栏“商品与款式配置”维护商品经营和内容营销共用的动态配置。页面提供款式、品牌、平台、
|
||||
商品 ID、ERP 编码、启停状态和各业务飞书目标表的查询与 CRUD;保存后从下一次新启动的工作流
|
||||
生效。编辑和删除带行版本校验,旧页面不能覆盖其他操作员已保存的新版本。
|
||||
侧栏“商品与款式配置”维护商品经营和内容营销共用的动态目标配置。页面提供款式、品牌、平台、
|
||||
启停状态、平台画像和各业务飞书目标表的查询与 CRUD;款式身份字段以产品生命进程为准,动态表
|
||||
中的历史 ERP/商品 ID 只作为兼容快照。保存后从下一次新启动的工作流生效。编辑和删除带行版本
|
||||
校验,旧页面不能覆盖其他操作员已保存的新版本。
|
||||
|
||||
配置存储在云端 PostgreSQL,旧飞书配置主表不再属于运行路径。首次迁移、字段模型、受影响
|
||||
工作流和失败回退边界见 [工作流动态配置](dynamic-workflow-config.md)。
|
||||
|
||||
@@ -43,6 +43,7 @@ TARGET_URLS = [
|
||||
"https://www.chanmama.com/bloggerRank/ujU3Wwa9udJc59DKO5dAA37ILJPsWvyg.html?activeTab=aweme",
|
||||
"https://www.chanmama.com/bloggerRank/ulaJWIvluqa_q6bQZzLdkzmLXNz64tgg.html?activeTab=aweme",
|
||||
]
|
||||
CHANMAMA_HOME_URL = "https://www.chanmama.com/"
|
||||
DOWNLOAD_DIR = str(PATHS.raw_root / "chanmama")
|
||||
COOKIE_FILE = str(PATHS.browser_cookie_file)
|
||||
COOKIE_MAX_AGE = 7 * 24 * 3600 # cookie 有效期 7 天(秒)
|
||||
@@ -486,7 +487,17 @@ def navigate_to_target(driver, url):
|
||||
if attempt >= CHANMAMA_NAVIGATION_ATTEMPTS:
|
||||
break
|
||||
print(f"⚠️ 目标页未完成渲染,重新打开一次: {exc}")
|
||||
time.sleep(2)
|
||||
# A same-URL reload can preserve the half-mounted SPA state. Go
|
||||
# through the site root once so the next target gets a fresh app
|
||||
# mount while retaining the authenticated browser session.
|
||||
try:
|
||||
driver.get(CHANMAMA_HOME_URL)
|
||||
WebDriverWait(driver, 15).until(
|
||||
EC.presence_of_element_located((By.TAG_NAME, "body"))
|
||||
)
|
||||
except Exception as reset_exc:
|
||||
print(f"⚠️ 蝉妈妈页面重置失败,继续重试目标页: {reset_exc}")
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f"蝉妈妈目标页加载失败: {url}; {last_error}") from last_error
|
||||
|
||||
|
||||
@@ -536,6 +547,16 @@ _VIDEO_LIST_READY_SCRIPT = r"""
|
||||
&& style.opacity !== '0' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const body = (document.body?.innerText || '').replace(/\s+/g, ' ');
|
||||
// Current Chanmama renders the list under aweme-record-wrapper before the
|
||||
// virtual table has stable row geometry. The page is usable once this
|
||||
// visible root exposes the list/export semantics, even if the table rows
|
||||
// are still being measured.
|
||||
if (/视频记录|导出数据/.test(body)
|
||||
&& [...document.querySelectorAll(
|
||||
'.aweme-record-wrapper,.list-auth-wrap-block,.list-box'
|
||||
)].some((el) => visible(el))) {
|
||||
return true;
|
||||
}
|
||||
if (/预估曝光|曝光量/.test(body)
|
||||
&& [...document.querySelectorAll('table,[role="row"],[class*="video"],[class*="list"],[class*="record"]')]
|
||||
.some((el) => visible(el) && /预估曝光|曝光量|播放量/.test(el.innerText || ''))) {
|
||||
|
||||
+4
@@ -3,6 +3,8 @@ ALTER TABLE cmt_styles
|
||||
ADD COLUMN IF NOT EXISTS brand VARCHAR(128) DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS erp_style_codes TEXT[] DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS platform_product_ids JSONB DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS big_nfc_count INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS small_nfc_count INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS product_type VARCHAR(128) DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS dimensions VARCHAR(255) DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS colors TEXT[] DEFAULT '{}',
|
||||
@@ -17,6 +19,8 @@ ALTER TABLE cmt_styles
|
||||
ADD COLUMN IF NOT EXISTS product_researched_at TIMESTAMPTZ;
|
||||
|
||||
COMMENT ON COLUMN cmt_styles.platform_product_ids IS 'Product IDs grouped by Feishu platform';
|
||||
COMMENT ON COLUMN cmt_styles.big_nfc_count IS 'Large NFC product count maintained by product lifecycle';
|
||||
COMMENT ON COLUMN cmt_styles.small_nfc_count IS 'Small NFC product count maintained by product lifecycle';
|
||||
COMMENT ON COLUMN cmt_styles.product_source_urls IS 'Public pages used to verify product facts';
|
||||
COMMENT ON COLUMN cmt_styles.product_research_status IS 'verified, partial, or pending';
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS cmt_styles (
|
||||
brand VARCHAR(128) DEFAULT '',
|
||||
erp_style_codes TEXT[] DEFAULT '{}',
|
||||
platform_product_ids JSONB DEFAULT '{}'::jsonb,
|
||||
big_nfc_count INTEGER NOT NULL DEFAULT 1,
|
||||
small_nfc_count INTEGER NOT NULL DEFAULT 1,
|
||||
product_type VARCHAR(128) DEFAULT '',
|
||||
dimensions VARCHAR(255) DEFAULT '',
|
||||
colors TEXT[] DEFAULT '{}',
|
||||
@@ -35,6 +37,9 @@ COMMENT ON TABLE cmt_styles IS '款式表';
|
||||
COMMENT ON COLUMN cmt_styles.name IS '款式名称';
|
||||
COMMENT ON COLUMN cmt_styles.style_category IS '款式风格';
|
||||
COMMENT ON COLUMN cmt_styles.platform IS '平台分组,如天猫';
|
||||
COMMENT ON COLUMN cmt_styles.platform_product_ids IS '产品生命进程统一维护的平台商品编码,固定键 tm/jd/jd_self/dy';
|
||||
COMMENT ON COLUMN cmt_styles.big_nfc_count IS '大 NFC 商品数量';
|
||||
COMMENT ON COLUMN cmt_styles.small_nfc_count IS '小 NFC 商品数量';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cmt_styles_platform ON cmt_styles (platform);
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ BUYIN_MARKET_URL = (
|
||||
"&btm_show_id=a1499f13-89eb-4bc0-99f5-ceb3b8f2e600"
|
||||
)
|
||||
BUYIN_DETAIL_URL_MARK = "/dashboard/servicehall/daren-profile"
|
||||
BUYIN_SEARCH_INPUT_SELECTOR = "input[placeholder*='搜达人昵称']"
|
||||
|
||||
# --buyin 仍可显式强制使用抖店达人广场;日报默认保留星图主采集,未命中
|
||||
# 的任务由主入口在同一批次内切换到抖店补采。
|
||||
@@ -755,17 +756,19 @@ def dismiss_popups(page: Page) -> None:
|
||||
|
||||
def select_nickname_search(page: Page) -> None:
|
||||
if CONTENT_SOURCE == "buyin":
|
||||
if not click_by_text(page, "找达人", exact=True, timeout=5000):
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const target = [...document.querySelectorAll('button, a, div, span')]
|
||||
.find((el) => (el.innerText || '').trim() === '找达人');
|
||||
target?.click();
|
||||
}
|
||||
"""
|
||||
# Buyin has a global "找达人" navigation item. Clicking the first
|
||||
# text match can leave the authenticated market and land on
|
||||
# www.douyinec.com, where there is no creator-search input at all.
|
||||
# The market route already owns the search box, so target the box
|
||||
# directly and repair the route when a stale page is still open.
|
||||
input_locator = page.locator(f"{BUYIN_SEARCH_INPUT_SELECTOR}:visible").first
|
||||
if not input_locator.count():
|
||||
current_path = urlparse(str(getattr(page, "url", "") or "")).path.rstrip(
|
||||
"/"
|
||||
)
|
||||
page.wait_for_timeout(800)
|
||||
if current_path != "/dashboard/servicehall/daren-square":
|
||||
page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=30000)
|
||||
input_locator.wait_for(state="visible", timeout=15000)
|
||||
return
|
||||
|
||||
if not click_by_text(page, "昵称找人", exact=True, timeout=5000):
|
||||
@@ -805,7 +808,12 @@ def search_creator(page: Page, creator_name: str, creator_id: str | None = None)
|
||||
select_nickname_search(page)
|
||||
# Keep the platform's original input locator. The query value is the
|
||||
# only thing that changes between the ID attempt and nickname fallback.
|
||||
input_locator = page.locator("input:visible").first
|
||||
input_selector = (
|
||||
f"{BUYIN_SEARCH_INPUT_SELECTOR}:visible"
|
||||
if CONTENT_SOURCE == "buyin"
|
||||
else "input:visible"
|
||||
)
|
||||
input_locator = page.locator(input_selector).first
|
||||
input_locator.wait_for(state="visible", timeout=30000)
|
||||
input_locator.click()
|
||||
previous_result_signature = (
|
||||
|
||||
@@ -21,11 +21,10 @@ PRODUCT_SCHEDULED_WORKFLOW_IDS = (
|
||||
"product.erp_all_shop_daily",
|
||||
"product.market_rank",
|
||||
"product.tmall_baibu_apply",
|
||||
)
|
||||
PRODUCT_MANUAL_WORKFLOW_IDS = (
|
||||
"product.video_upload",
|
||||
"product.jd_video_upload",
|
||||
)
|
||||
PRODUCT_MANUAL_WORKFLOW_IDS = ()
|
||||
PRODUCT_WORKFLOW_IDS = PRODUCT_SCHEDULED_WORKFLOW_IDS + PRODUCT_MANUAL_WORKFLOW_IDS
|
||||
PRODUCT_TIMEOUT_SECONDS = 6 * 60 * 60
|
||||
PRODUCT_RESOURCE = "module:product_commerce"
|
||||
|
||||
@@ -23,7 +23,7 @@ def safe_name(value):
|
||||
|
||||
|
||||
def load_style_meta():
|
||||
"""优先从飞书读取 ERP 编码,失败则回退 styles_input.json。"""
|
||||
"""只从产品生命进程读取 ERP 编码;不使用本地旧配置回退。"""
|
||||
result = {}
|
||||
try:
|
||||
for item in get_erp_styles_input():
|
||||
@@ -35,19 +35,7 @@ def load_style_meta():
|
||||
if result:
|
||||
return result
|
||||
except Exception as exc:
|
||||
print(f"[WARN] 动态读取 ERP 编码失败,使用本地文件回退: {exc}")
|
||||
|
||||
if not STYLE_INPUT.exists():
|
||||
return {}
|
||||
items = read_json(STYLE_INPUT)
|
||||
for item in items:
|
||||
style = item.get("style_name") or item.get("款式") or item.get("style")
|
||||
if not style:
|
||||
continue
|
||||
codes = item.get("erp_style_codes") or item.get("erp款式编码") or item.get("codes") or []
|
||||
if isinstance(codes, str):
|
||||
codes = [x.strip() for x in codes.split(",") if x.strip()]
|
||||
result[style] = {"erp_style_codes": codes}
|
||||
raise RuntimeError("产品生命进程身份源不可用,拒绝读取 styles_input.json") from exc
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -49,49 +49,15 @@ from jd_peer_product_data_collector import ( # noqa: E402
|
||||
INVENTORY_URL = "https://ppzh.jd.com/inventoryweb/brand/view/supplychainAnalysis/inventoryDetail.html"
|
||||
DEFAULT_OUTPUT_DIR = RAW_DATA_ROOT / "jd自营库存销量数据"
|
||||
|
||||
# 保留硬编码作为灾备回退;运行时优先从飞书多维表格读取。
|
||||
_STATIC_STYLE_SKU_GROUPS = [
|
||||
{"style_name": "盖亚斜挎", "skus": ["100044949353", "100087299891"]},
|
||||
{"style_name": "极星pro", "skus": ["100117005088", "100100271235"]},
|
||||
{"style_name": "盖亚微单", "skus": ["100108144308", "100108144324"]},
|
||||
{"style_name": "盖世m1", "skus": ["100191634938"]},
|
||||
{
|
||||
"style_name": "星迹2",
|
||||
"skus": [
|
||||
"100108144306",
|
||||
"100150785348",
|
||||
"100108144260",
|
||||
"100100271371",
|
||||
"100241562067",
|
||||
"100241562047",
|
||||
],
|
||||
},
|
||||
{"style_name": "星云2", "skus": ["100160619963", "100160619965", "100248422862"]},
|
||||
{
|
||||
"style_name": "宙斯",
|
||||
"skus": ["100221530889", "100306128160", "100306128166", "100255125975", "100239269869"],
|
||||
},
|
||||
{"style_name": "阿波罗x1", "skus": ["100058323820", "100058323830", "100058916832", "100058916818"]},
|
||||
{"style_name": "逐星", "skus": ["100165451136", "100165451142"]},
|
||||
{"style_name": "星云mini", "skus": ["100215176791", "100215176793"]},
|
||||
{"style_name": "晨星2", "skus": ["100287829888", "100287829866"]},
|
||||
{"style_name": "极星双肩", "skus": ["100247532920", "100247532928"]},
|
||||
{"style_name": "凌云air", "skus": ["100291530148", "100218450063"]},
|
||||
{"style_name": "觅光", "skus": ["100241563697"]},
|
||||
{"style_name": "云卷2", "skus": ["100329997886"]},
|
||||
{"style_name": "拓界mode", "skus": ["100251533359", "100251533369"]},
|
||||
]
|
||||
|
||||
|
||||
def _get_style_sku_groups():
|
||||
"""优先从飞书读取京东自营 SKU 分组,失败则回退硬编码。"""
|
||||
"""从产品生命进程读取完整身份的京东自营 SKU 分组,失败则不采集。"""
|
||||
try:
|
||||
groups = get_jd_self_sku_groups()
|
||||
if groups:
|
||||
return groups
|
||||
except Exception as exc:
|
||||
print(f"[WARN] 动态读取京东自营 SKU 配置失败,使用硬编码回退: {exc}")
|
||||
return _STATIC_STYLE_SKU_GROUPS
|
||||
print(f"[FAIL] 产品生命进程身份源不可用,跳过京东自营采集: {exc}")
|
||||
return []
|
||||
|
||||
|
||||
STYLE_SKU_GROUPS = _get_style_sku_groups()
|
||||
@@ -101,7 +67,7 @@ def _print_preflight() -> None:
|
||||
"""启动期轻量打印:京东自营周汇总分组里今日待采款式数。"""
|
||||
try:
|
||||
styles = get_jd_self_styles()
|
||||
print(f"[JD_SELF] 飞书表格 周汇总待采 {len(styles)} 款 (仅要求 SKU 非空)")
|
||||
print(f"[JD_SELF] 产品生命进程 周汇总待采 {len(styles)} 款 (要求 ERP + 京东自营 SKU)")
|
||||
except Exception as exc:
|
||||
print(f"[WARN] 飞书读取失败, 跳过 preflight: {exc}")
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ try:
|
||||
)
|
||||
from .config.style_config_loader import (
|
||||
StyleConfigLoader,
|
||||
has_complete_style_identity,
|
||||
)
|
||||
from .lark_cli_runtime import run_lark_cli
|
||||
except ImportError: # Direct script execution from the module root.
|
||||
@@ -36,6 +37,7 @@ except ImportError: # Direct script execution from the module root.
|
||||
)
|
||||
from config.style_config_loader import (
|
||||
StyleConfigLoader,
|
||||
has_complete_style_identity,
|
||||
)
|
||||
from lark_cli_runtime import run_lark_cli
|
||||
|
||||
@@ -328,7 +330,7 @@ def plan_erp_styles(
|
||||
str(code).strip() for code in raw_codes if str(code).strip()
|
||||
)
|
||||
)
|
||||
if not codes:
|
||||
if not codes or not has_complete_style_identity(style_config):
|
||||
missing.append(target.product_name)
|
||||
continue
|
||||
key = normalized_name(style_name)
|
||||
@@ -402,7 +404,7 @@ def require_complete_erp_mappings(missing_products: Sequence[str]) -> None:
|
||||
if not missing:
|
||||
return
|
||||
raise RuntimeError(
|
||||
"以下产品在飞书款式配置中缺少款式映射或 ERP 款式编码,"
|
||||
"以下产品在产品生命进程中缺少款式映射、ERP 款式编码或平台商品编码,"
|
||||
"已停止正式写入且目标表未清空: " + "、".join(missing)
|
||||
)
|
||||
|
||||
|
||||
@@ -169,6 +169,8 @@ _BATCH_FAILURE_RE = re.compile(
|
||||
)
|
||||
_DRAFT_COUNT_RE = re.compile(r"草稿\s*[((]\s*(?P<count>\d+)\s*[))]")
|
||||
_DRAFT_EMPTY_MARKERS = ("暂无数据", "数据为空", "暂无商品", "没有符合条件的商品")
|
||||
_ANNOUNCEMENT_MARKERS = ("公告", "通知")
|
||||
_ANNOUNCEMENT_DISMISS_LABELS = ("我已阅读完成", "关闭")
|
||||
_EDITOR_CLOSE_SELECTORS = (
|
||||
"button[aria-label='关闭此对话框']",
|
||||
"[role='button'][aria-label='关闭此对话框']",
|
||||
@@ -627,6 +629,58 @@ def _visible_locator(context: Any, selectors: Iterable[str]) -> Any | None:
|
||||
return None
|
||||
|
||||
|
||||
def _dismiss_blocking_announcements(page: Any) -> bool:
|
||||
"""Close a visible Tmall announcement dialog that blocks page actions.
|
||||
|
||||
The old-shop activity page can show an announcement overlay after the
|
||||
account identity has already loaded. The underlying ``商品批量导入``
|
||||
button remains present, but a normal click is intercepted by the dialog.
|
||||
Only dialogs whose own text contains an announcement marker are touched;
|
||||
business dialogs are left alone.
|
||||
"""
|
||||
|
||||
dismissed = False
|
||||
for context in _page_contexts(page):
|
||||
get_by_role = getattr(context, "get_by_role", None)
|
||||
if not callable(get_by_role):
|
||||
continue
|
||||
try:
|
||||
dialogs = get_by_role("dialog")
|
||||
count = min(dialogs.count(), 12)
|
||||
except Exception:
|
||||
continue
|
||||
for index in range(count):
|
||||
dialog = dialogs.nth(index)
|
||||
try:
|
||||
if not dialog.is_visible():
|
||||
continue
|
||||
dialog_text = _normalized_page_text(
|
||||
dialog.inner_text(timeout=2_000)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if not any(marker in dialog_text for marker in _ANNOUNCEMENT_MARKERS):
|
||||
continue
|
||||
|
||||
candidates: list[Any] = []
|
||||
for label in _ANNOUNCEMENT_DISMISS_LABELS:
|
||||
candidates.extend(_visible_text_candidates(dialog, label))
|
||||
if not candidates:
|
||||
close = _visible_locator(dialog, _EDITOR_CLOSE_SELECTORS)
|
||||
if close is not None:
|
||||
candidates.append(close)
|
||||
for candidate in candidates:
|
||||
try:
|
||||
candidate.click(timeout=8_000)
|
||||
dismissed = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if dismissed:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _find_password_login_form(
|
||||
page: Any,
|
||||
*,
|
||||
@@ -860,6 +914,8 @@ def _open_batch_import(
|
||||
) -> _BatchImportStatus | None:
|
||||
"""Open the import dialog, failing closed on an active prior operation."""
|
||||
|
||||
_dismiss_blocking_announcements(page)
|
||||
|
||||
# The account marker is rendered before the 最近操作 panel on this SPA.
|
||||
# Capture the panel after it has had a short chance to hydrate; otherwise a
|
||||
# previous terminal summary can appear only after the dialog is opened and
|
||||
@@ -886,6 +942,7 @@ def _open_batch_import(
|
||||
|
||||
deadline = time.monotonic() + 20
|
||||
while time.monotonic() < deadline:
|
||||
_dismiss_blocking_announcements(page)
|
||||
if _click_text(page, ("商品批量导入",)):
|
||||
return previous
|
||||
current = _latest_batch_import_status(_page_text(page))
|
||||
@@ -993,26 +1050,28 @@ def _return_to_draft_list(page: Any, entry: BaibuEntry) -> None:
|
||||
"""Leave the current editor and wait until the draft list is usable.
|
||||
|
||||
Tmall keeps the editor open after ``下一步`` saves a product. The next
|
||||
product must be selected from the draft list, so explicitly navigate back
|
||||
before the loop reads the draft count. The visible 草稿 tab is preferred;
|
||||
closing the editor and browser history are fallbacks for the two page
|
||||
variants used by the old and new seller consoles.
|
||||
product must be selected from the draft list, so close the editor first
|
||||
and only then navigate back before the loop reads the draft count. This
|
||||
prevents a click on the 草稿 tab or the next 完善商品 action from being
|
||||
intercepted by the still-open drawer. Browser history remains a fallback
|
||||
for the two page variants used by the old and new seller consoles.
|
||||
"""
|
||||
|
||||
deadline = time.monotonic() + _DRAFT_NAVIGATION_TIMEOUT_SECONDS
|
||||
close_attempted = False
|
||||
back_attempted = False
|
||||
while time.monotonic() < deadline:
|
||||
if _click_draft_tab(page) and _draft_list_is_ready(page):
|
||||
return
|
||||
|
||||
if not close_attempted:
|
||||
close_attempted = True
|
||||
if _close_editor(page):
|
||||
# Let the drawer close animation finish before trying the
|
||||
# tab again or falling back to browser history.
|
||||
# draft list or falling back to browser history.
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
if _draft_list_is_ready(page):
|
||||
return
|
||||
|
||||
if _click_draft_tab(page) and _draft_list_is_ready(page):
|
||||
return
|
||||
|
||||
if not back_attempted:
|
||||
back_attempted = True
|
||||
|
||||
@@ -1291,14 +1291,14 @@
|
||||
const platforms = Array.isArray(record.platforms) ? record.platforms : [];
|
||||
const enabledPlatforms = platforms.filter((platform) => platform.enabled !== false);
|
||||
const itemCount = enabledPlatforms.reduce((total, platform) => total + (Array.isArray(platform.item_ids) ? platform.item_ids.length : 0), 0);
|
||||
const platformRows = enabledPlatforms.slice(0, 4).map((platform) => `<span class="config-platform-chip"><b>${esc(platform.platform || "未填写平台")}</b><small>${Array.isArray(platform.item_ids) && platform.item_ids.length ? `${platform.item_ids.length} 个 ID` : "待补 ID"}</small></span>`).join("");
|
||||
const platformRows = enabledPlatforms.slice(0, 4).map((platform) => `<span class="config-platform-chip"><b>${esc(platform.platform || "未填写平台")}</b><small>${Array.isArray(platform.item_ids) && platform.item_ids.length ? `${platform.item_ids.length} 个兼容 ID` : "由生命进程维护"}</small></span>`).join("");
|
||||
const morePlatforms = enabledPlatforms.length > 4 ? `<span class="config-platform-chip config-platform-chip--more">+${enabledPlatforms.length - 4}</span>` : "";
|
||||
const destinationNames = dynamicConfigDestinations(record).filter((item) => item.enabled && item.url).slice(0, 3).map((item) => `<span>${esc(item.label)}</span>`).join("");
|
||||
const moreTargets = targetCount > 3 ? `<span class="config-target-more">+${targetCount - 3}</span>` : "";
|
||||
return `<article class="dynamic-config-record" role="listitem" data-config-id="${Number(record.id)}">
|
||||
<div class="dynamic-config-record__identity" data-label="款式身份"><span class="dynamic-config-record__avatar">${esc(configInitial(style))}</span><div><strong>${esc(style)}</strong><small>${esc(brand)}${record.note ? ` · ${esc(record.note)}` : ""}</small><span class="dynamic-config-record__id">配置 #${Number(record.id)}</span></div></div>
|
||||
<div class="dynamic-config-record__platforms" data-label="平台商品"><div class="dynamic-config-record__metric"><strong>${enabledPlatforms.length}</strong><span>个平台 · ${itemCount} 个商品 ID</span></div><div class="config-platform-list">${platformRows || '<span class="muted">暂无平台映射</span>'}${morePlatforms}</div></div>
|
||||
<div class="dynamic-config-record__targets" data-label="ERP / 业务目标"><div class="dynamic-config-record__erp"><span>ERP</span>${configCodeList(record.erp_codes)}</div><div class="config-target-list">${destinationNames || '<span class="muted">尚未配置目标地址</span>'}${moreTargets}</div></div>
|
||||
<div class="dynamic-config-record__platforms" data-label="平台目标"><div class="dynamic-config-record__metric"><strong>${enabledPlatforms.length}</strong><span>个平台 · ${itemCount} 个兼容 ID</span></div><div class="config-platform-list">${platformRows || '<span class="muted">暂无平台目标</span>'}${morePlatforms}</div></div>
|
||||
<div class="dynamic-config-record__targets" data-label="业务目标"><div class="dynamic-config-record__erp"><span>身份</span><span class="muted">产品生命进程</span></div><div class="config-target-list">${destinationNames || '<span class="muted">尚未配置目标地址</span>'}${moreTargets}</div></div>
|
||||
<div class="dynamic-config-record__status" data-label="状态"><span class="config-state-pill config-state-pill--${record.enabled ? "enabled" : "disabled"}">${record.enabled ? "启用" : "停用"}</span><small>${targetCount} 个目标已接入</small></div>
|
||||
<div class="dynamic-config-record__action"><button class="dynamic-config-edit" type="button" data-action="edit-dynamic-config" data-config-id="${Number(record.id)}">编辑配置<span aria-hidden="true">→</span></button></div>
|
||||
</article>`;
|
||||
@@ -1335,9 +1335,9 @@
|
||||
function dynamicConfigPlatformRow(platform = {}) {
|
||||
const enabled = platform.enabled !== false;
|
||||
return `<article class="dynamic-config-platform-row" data-platform-row>
|
||||
<div class="dynamic-config-platform-row__header"><span class="dynamic-config-platform-row__mark" aria-hidden="true">台</span><div><strong data-platform-preview>${esc(platform.platform || "新平台")}</strong><small>平台级商品身份</small></div><label class="dynamic-config-platform-toggle"><input data-platform-field="enabled" type="checkbox" ${enabled ? "checked" : ""}><span>启用</span></label><button class="dynamic-config-platform-remove" data-action="remove-platform" type="button" aria-label="删除平台">×</button></div>
|
||||
<div class="dynamic-config-platform-row__header"><span class="dynamic-config-platform-row__mark" aria-hidden="true">台</span><div><strong data-platform-preview>${esc(platform.platform || "新平台")}</strong><small>平台画像与启停</small></div><label class="dynamic-config-platform-toggle"><input data-platform-field="enabled" type="checkbox" ${enabled ? "checked" : ""}><span>启用</span></label><button class="dynamic-config-platform-remove" data-action="remove-platform" type="button" aria-label="删除平台">×</button></div>
|
||||
<div class="dynamic-config-platform-row__fields"><label class="field"><span>平台名称 <i>必填</i></span><input data-platform-field="platform" list="config-platform-options" maxlength="80" value="${esc(platform.platform || "")}" placeholder="例如:天猫" required></label>
|
||||
<label class="field"><span>商品 ID / SPU / SKU <small>多个用逗号或换行分隔</small></span><textarea data-platform-field="item_ids" rows="2" placeholder="例如:1074059145982">${esc((platform.item_ids || []).join(", "))}</textarea></label>
|
||||
<label class="field"><span>商品 ID / SPU / SKU <small>只读,前往产品生命进程维护</small></span><textarea data-platform-field="item_ids" rows="2" placeholder="由产品生命进程提供" readonly>${esc((platform.item_ids || []).join(", "))}</textarea></label>
|
||||
<label class="field field--full"><span>本平台人群画像表 <small>不采集画像可留空</small></span><input data-platform-field="persona_bitable_url" type="url" value="${esc(platform.persona_bitable_url || "")}" placeholder="https://…"></label></div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
</button>
|
||||
<button class="module-nav__item sidebar-config__item" id="dynamic-config-nav-button" type="button" data-view="dynamic-configs" aria-controls="dynamic-config-view">
|
||||
<span class="module-nav__glyph sidebar-config__glyph dynamic-config-nav__glyph" aria-hidden="true">配</span>
|
||||
<span class="module-nav__copy"><strong>款式平台配置</strong><small>ERP、平台商品 ID 与目标表</small></span>
|
||||
<span class="module-nav__copy"><strong>款式目标配置</strong><small>业务目标、画像表与启停</small></span>
|
||||
<span class="module-nav__count" id="dynamic-config-nav-count">…</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -351,7 +351,7 @@
|
||||
<div class="dynamic-config-header__copy">
|
||||
<span class="eyebrow">STYLE CONFIGURATION WORKSPACE</span>
|
||||
<h1 id="dynamic-config-title">款式配置中心</h1>
|
||||
<p>一款一条配置,集中维护 ERP、飞书业务目标和各平台商品身份。</p>
|
||||
<p>一款一条配置,集中维护飞书业务目标、平台画像和启停;商品身份请在产品生命进程维护。</p>
|
||||
</div>
|
||||
<div class="dynamic-config-header__actions">
|
||||
<div class="dynamic-config-source-note"><span class="config-source-dot" aria-hidden="true"></span><span><small>配置来源</small><strong>云端 PostgreSQL</strong></span></div>
|
||||
@@ -362,7 +362,7 @@
|
||||
|
||||
<section class="dynamic-config-source-bar" aria-label="款式配置读取范围">
|
||||
<div class="dynamic-config-source-bar__lead"><span class="config-source-icon" aria-hidden="true">↗</span><div><strong>统一配置,按款式生效</strong><p>新启动的工作流会读取这里的最新版本。</p></div></div>
|
||||
<div class="dynamic-config-source-bar__items"><span><b>01</b>ERP 款式编码</span><span><b>02</b>业务目标地址</span><span><b>03</b>平台商品身份</span></div>
|
||||
<div class="dynamic-config-source-bar__items"><span><b>01</b>业务目标地址</span><span><b>02</b>平台画像表</span><span><b>03</b>启停状态</span></div>
|
||||
</section>
|
||||
|
||||
<section class="dynamic-config-summary" aria-label="配置统计">
|
||||
@@ -386,7 +386,7 @@
|
||||
<div class="dynamic-config-feedback" id="dynamic-config-feedback" role="status" aria-live="polite" hidden></div>
|
||||
<div class="dynamic-config-table-wrap">
|
||||
<div class="dynamic-config-table" role="list" aria-label="款式配置列表">
|
||||
<div class="dynamic-config-table-head" aria-hidden="true"><span>款式身份</span><span>平台商品</span><span>ERP / 业务目标</span><span>状态</span><span>操作</span></div>
|
||||
<div class="dynamic-config-table-head" aria-hidden="true"><span>款式身份</span><span>平台目标</span><span>业务目标</span><span>状态</span><span>操作</span></div>
|
||||
<div id="dynamic-config-table-body"><div class="dynamic-config-loading">正在读取云端配置…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,7 +560,7 @@
|
||||
<div class="overlay__backdrop" data-close="dynamic-config" aria-hidden="true"></div>
|
||||
<section class="modal dynamic-config-modal" id="dynamic-config-modal" role="dialog" aria-modal="true" aria-labelledby="dynamic-config-form-title" aria-hidden="true">
|
||||
<header class="modal__header dynamic-config-modal__header">
|
||||
<div class="dynamic-config-modal__heading"><div class="dynamic-config-modal__eyebrow"><span class="config-modal-mark" aria-hidden="true">款</span><span class="eyebrow">STYLE CONFIGURATION</span></div><h2 id="dynamic-config-form-title">新增款式</h2><p id="dynamic-config-form-subtitle">一次维护款式公共配置与全部平台商品身份。</p></div>
|
||||
<div class="dynamic-config-modal__heading"><div class="dynamic-config-modal__eyebrow"><span class="config-modal-mark" aria-hidden="true">款</span><span class="eyebrow">STYLE CONFIGURATION</span></div><h2 id="dynamic-config-form-title">新增目标配置</h2><p id="dynamic-config-form-subtitle">身份在产品生命进程维护;这里配置业务目标与平台画像。</p></div>
|
||||
<div class="dynamic-config-modal__header-actions"><span class="dynamic-config-revision" id="dynamic-config-editor-revision">新配置</span><button class="icon-button" id="dynamic-config-form-close" type="button" aria-label="关闭配置编辑">×</button></div>
|
||||
</header>
|
||||
<form id="dynamic-config-form" novalidate>
|
||||
@@ -569,20 +569,20 @@
|
||||
<aside class="dynamic-config-form-rail" aria-label="编辑页导航">
|
||||
<div class="dynamic-config-form-identity"><span class="dynamic-config-editor-avatar" id="dynamic-config-editor-avatar">款</span><div><small>当前款式</small><strong id="dynamic-config-editor-style">新款式</strong><span class="dynamic-config-form-identity__status" id="dynamic-config-editor-status">准备创建</span><span class="dynamic-config-form-identity__summary" id="dynamic-config-editor-summary">新配置 · 1 个平台 · 1 个目标</span></div></div>
|
||||
<nav class="dynamic-config-form-nav" aria-label="配置分区">
|
||||
<button type="button" data-config-section-nav="identity" class="is-active"><span>01</span><strong>基础信息</strong><small>款式与 ERP 编码</small></button>
|
||||
<button type="button" data-config-section-nav="identity" class="is-active"><span>01</span><strong>基础信息</strong><small>款式与状态</small></button>
|
||||
<button type="button" data-config-section-nav="destinations"><span>02</span><strong>业务目标</strong><small>可扩展飞书表格地址</small></button>
|
||||
<button type="button" data-config-section-nav="platforms"><span>03</span><strong>平台映射</strong><small>商品 ID 与画像表</small></button>
|
||||
<button type="button" data-config-section-nav="platforms"><span>03</span><strong>平台目标</strong><small>画像表与启停</small></button>
|
||||
</nav>
|
||||
<div class="dynamic-config-form-rail__tip"><span aria-hidden="true">✦</span><div><strong>为下一条逻辑留位置</strong><p>新分析逻辑只需新增一条业务目标,使用稳定的逻辑标识读取对应地址。</p></div></div>
|
||||
</aside>
|
||||
<div class="modal__body dynamic-config-form-body">
|
||||
<p class="form-error" id="dynamic-config-form-error" role="alert" hidden></p>
|
||||
<section class="dynamic-config-form-section" id="dynamic-config-identity-section" data-config-section="identity" aria-labelledby="dynamic-config-identity-title">
|
||||
<header><span>01</span><div><h3 id="dynamic-config-identity-title">款式基本信息</h3><p>ERP 编码和款式身份只维护一次,所有平台共享。</p></div></header>
|
||||
<header><span>01</span><div><h3 id="dynamic-config-identity-title">款式基本信息</h3><p>款式身份只在产品生命进程维护;这里仅关联目标配置。</p></div></header>
|
||||
<div class="dynamic-config-form-grid">
|
||||
<label class="field"><span>款式名称 <i>必填</i></span><input id="config-style-name" maxlength="120" placeholder="例如:云栖" required></label>
|
||||
<label class="field"><span>品牌</span><input id="config-brand" maxlength="120" placeholder="例如:光影行星"></label>
|
||||
<label class="field field--full"><span>ERP 款式编码 <small>多个编码用逗号或换行分隔</small></span><textarea id="config-erp-codes" rows="2" placeholder="例如:10521, 10522"></textarea></label>
|
||||
<label class="field field--full"><span>ERP 款式编码 <small>只读,前往产品生命进程维护</small></span><textarea id="config-erp-codes" rows="2" placeholder="由产品生命进程提供" readonly></textarea></label>
|
||||
<label class="field dynamic-config-enabled-field"><span><strong>参与工作流</strong><small>停用后保留记录,但整款不再被工作流解析。</small></span><input id="config-enabled" type="checkbox" checked><i aria-hidden="true"></i></label>
|
||||
<label class="field field--full"><span>备注</span><input id="config-note" maxlength="500" placeholder="记录这款的业务说明、特殊规则或负责人"></label>
|
||||
<input id="config-style-content" type="hidden">
|
||||
@@ -596,7 +596,7 @@
|
||||
</section>
|
||||
|
||||
<section class="dynamic-config-form-section dynamic-config-platform-section" id="dynamic-config-platforms-section" data-config-section="platforms" aria-labelledby="dynamic-config-platforms-title">
|
||||
<header><span>03</span><div><h3 id="dynamic-config-platforms-title">平台商品身份</h3><p>一个平台一行:填写商品 ID;需要画像采集时再填本平台人群画像表。</p></div><button class="button button--secondary" id="dynamic-config-add-platform" type="button"><span aria-hidden="true">+</span>添加平台</button></header>
|
||||
<header><span>03</span><div><h3 id="dynamic-config-platforms-title">平台目标</h3><p>商品编码由产品生命进程提供;这里维护平台画像表和启停。</p></div><button class="button button--secondary" id="dynamic-config-add-platform" type="button"><span aria-hidden="true">+</span>添加平台</button></header>
|
||||
<div class="dynamic-config-platform-editor" id="dynamic-config-platform-editor"></div>
|
||||
<datalist id="config-platform-options"><option value="天猫"><option value="京东旗舰店"><option value="京东自营"><option value="抖音"><option value="拼多多"><option value="拼多多光影行星GYXX箱包专卖店"><option value="拼多多光影行星箱包官方旗舰店"><option value="拼多多光影行星官方旗舰店"></datalist>
|
||||
</section>
|
||||
|
||||
@@ -32,6 +32,45 @@ def test_buyin_source_uses_doudian_market_and_cookie_scope():
|
||||
assert xingtu.BUSINESS_COOKIE_DOMAINS == ("jinritemai.com",)
|
||||
|
||||
|
||||
def test_buyin_search_stays_on_market_route_instead_of_clicking_global_nav():
|
||||
xingtu.configure_source("buyin")
|
||||
events = []
|
||||
|
||||
class Locator:
|
||||
def __init__(self, count):
|
||||
self._count = count
|
||||
self.first = self
|
||||
|
||||
def count(self):
|
||||
return self._count
|
||||
|
||||
def wait_for(self, **kwargs):
|
||||
events.append(("wait", kwargs))
|
||||
|
||||
class Page:
|
||||
url = "https://www.douyinec.com/"
|
||||
|
||||
def locator(self, selector):
|
||||
events.append(("locator", selector))
|
||||
return Locator(0)
|
||||
|
||||
def goto(self, url, **kwargs):
|
||||
events.append(("goto", url, kwargs))
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
raise AssertionError("Buyin must not click the global 找达人 navigation")
|
||||
|
||||
xingtu.select_nickname_search(Page())
|
||||
|
||||
assert events[0] == (
|
||||
"locator",
|
||||
f"{xingtu.BUYIN_SEARCH_INPUT_SELECTOR}:visible",
|
||||
)
|
||||
assert events[1][0] == "goto"
|
||||
assert events[1][1] == xingtu.MARKET_URL
|
||||
assert events[2][0] == "wait"
|
||||
|
||||
|
||||
def test_save_state_creates_cookie_parent_directory(tmp_path, monkeypatch):
|
||||
cookie_file = tmp_path / "nested" / "cookies.json"
|
||||
monkeypatch.setattr(xingtu, "DATA_DIR", tmp_path / "raw")
|
||||
|
||||
@@ -36,13 +36,29 @@ def test_style_plan_rejects_one_erp_code_owned_by_two_styles() -> None:
|
||||
backfill.build_style_plans(
|
||||
{
|
||||
"styles": {
|
||||
"款式甲": {"erp_codes": ["10416"]},
|
||||
"款式乙": {"erp_codes": ["10416"]},
|
||||
"款式甲": {"erp_codes": ["10416"], "tm": {"item_ids": ["tm-1"]}},
|
||||
"款式乙": {"erp_codes": ["10416"], "tm": {"item_ids": ["tm-2"]}},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_style_plan_skips_erp_only_style_until_product_id_is_added() -> None:
|
||||
plans = backfill.build_style_plans(
|
||||
{
|
||||
"styles": {
|
||||
"研发款": {"erp_codes": ["ERP-ONLY"]},
|
||||
"已上架款": {
|
||||
"erp_codes": ["ERP-READY"],
|
||||
"jd": {"spus": ["JD-1"]},
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert plans == (backfill.StylePlan("已上架款", ("ERP-READY",)),)
|
||||
|
||||
|
||||
def test_all_shop_filter_selects_every_shop_and_batches_all_codes() -> None:
|
||||
class Frame:
|
||||
def __init__(self) -> None:
|
||||
|
||||
@@ -210,8 +210,11 @@ def test_erp_style_plan_uses_style_base_codes_and_aliases() -> None:
|
||||
ProductTarget("云栖相机双肩包", 200),
|
||||
]
|
||||
styles = {
|
||||
"盖世m1": {"erp_codes": ["10416", "10455", "10416"]},
|
||||
"星迹2": {"erp_codes": ["10504", "10394"]},
|
||||
"盖世m1": {
|
||||
"erp_codes": ["10416", "10455", "10416"],
|
||||
"tm": {"item_ids": ["tm-1"]},
|
||||
},
|
||||
"星迹2": {"erp_codes": ["10504", "10394"], "tm": {"item_ids": ["tm-2"]}},
|
||||
"云栖": {"erp_codes": []},
|
||||
}
|
||||
|
||||
|
||||
@@ -321,6 +321,83 @@ def test_upload_template_supports_tmall_dialog_import_button(
|
||||
assert chooser.files == str(template)
|
||||
|
||||
|
||||
class _AnnouncementDialog:
|
||||
def __init__(self) -> None:
|
||||
self.dismiss = _LoginLocator()
|
||||
|
||||
def is_visible(self) -> bool:
|
||||
return True
|
||||
|
||||
def inner_text(self, **_kwargs) -> str:
|
||||
return "公告 26年天猫活动通知"
|
||||
|
||||
def count(self) -> int:
|
||||
return 1
|
||||
|
||||
def nth(self, _index: int):
|
||||
return self
|
||||
|
||||
def get_by_role(self, role: str, *, name: str, exact: bool):
|
||||
if role == "button" and name == "我已阅读完成" and exact:
|
||||
return self.dismiss
|
||||
return _LoginLocator(visible=False)
|
||||
|
||||
def get_by_text(self, _label: str, *, exact: bool):
|
||||
return _LoginLocator(visible=False)
|
||||
|
||||
def locator(self, _selector: str):
|
||||
return _LoginLocator(visible=False)
|
||||
|
||||
|
||||
class _AnnouncementPage:
|
||||
frames: list[object] = []
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.dialog = _AnnouncementDialog()
|
||||
|
||||
def get_by_role(self, role: str, **_kwargs):
|
||||
if role == "dialog":
|
||||
return self.dialog
|
||||
return _LoginLocator(visible=False)
|
||||
|
||||
|
||||
def test_dismiss_blocking_announcements_closes_only_announcement_dialog() -> None:
|
||||
page = _AnnouncementPage()
|
||||
|
||||
assert baibu._dismiss_blocking_announcements(page) is True
|
||||
assert page.dialog.dismiss.click_count == 1
|
||||
|
||||
|
||||
def test_open_batch_import_dismisses_announcement_before_click(monkeypatch) -> None:
|
||||
events: list[str] = []
|
||||
page = SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_page_text",
|
||||
lambda _page: (
|
||||
"您于2026-09-03 15:00:51执行了商品批量导入操作,"
|
||||
"总数量2件,已成功2件,失败0件,待查看0件。"
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_dismiss_blocking_announcements",
|
||||
lambda _page: events.append("dismiss") or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_click_text",
|
||||
lambda _page, _labels: events.append("click") or True,
|
||||
)
|
||||
|
||||
baibu._open_batch_import(page, _entry())
|
||||
|
||||
assert events[:2] == ["dismiss", "dismiss"]
|
||||
assert events[2:] == ["click"]
|
||||
|
||||
|
||||
def test_open_batch_import_refuses_an_active_previous_operation(monkeypatch) -> None:
|
||||
page = SimpleNamespace()
|
||||
monkeypatch.setattr(
|
||||
@@ -538,6 +615,33 @@ def test_return_to_draft_list_falls_back_to_browser_history(monkeypatch) -> None
|
||||
assert events == ["draft", "back"]
|
||||
|
||||
|
||||
def test_return_to_draft_list_closes_editor_before_clicking_draft_tab(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_close_editor",
|
||||
lambda _page: events.append("close") or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_draft_list_is_ready",
|
||||
lambda _page: events.append("ready") or True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
baibu,
|
||||
"_click_draft_tab",
|
||||
lambda _page: pytest.fail("关闭编辑器后列表已就绪时不得重复点草稿"),
|
||||
)
|
||||
monkeypatch.setattr(baibu.time, "sleep", lambda _seconds: None)
|
||||
|
||||
baibu._return_to_draft_list(SimpleNamespace(), _entry())
|
||||
|
||||
assert events == ["close", "ready"]
|
||||
|
||||
|
||||
def test_tmall_config_enables_draft_completion_for_only_old_all_3c() -> None:
|
||||
old_all_3c = baibu._load_entry("old_all_3c")
|
||||
old_all_bag = baibu._load_entry("old_all_bag")
|
||||
|
||||
+23
-18
@@ -11,15 +11,15 @@ from gyxx_flow.catalog import CatalogError, WorkflowCatalog
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_catalog_maps_27_project_scheduled_tasks() -> None:
|
||||
def test_catalog_maps_29_project_scheduled_tasks() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
scheduled = catalog.scheduled_workflows()
|
||||
|
||||
assert len(scheduled) == 27
|
||||
assert len({item.source_task_name for item in scheduled}) == 27
|
||||
assert len(scheduled) == 29
|
||||
assert len({item.source_task_name for item in scheduled}) == 29
|
||||
assert Counter(item.module for item in scheduled) == {
|
||||
"content_marketing": 9,
|
||||
"product_commerce": 10,
|
||||
"product_commerce": 12,
|
||||
"shop_intelligence": 4,
|
||||
"supply_chain": 4,
|
||||
}
|
||||
@@ -30,10 +30,7 @@ def test_catalog_schedules_douyin_price_appeal() -> None:
|
||||
schedule = catalog.schedule_for("shop.douyin_price_appeal")
|
||||
|
||||
assert len(catalog.workflows) == 29
|
||||
assert tuple(item.workflow_id for item in catalog.manual_workflows()) == (
|
||||
"product.video_upload",
|
||||
"product.jd_video_upload",
|
||||
)
|
||||
assert catalog.manual_workflows() == ()
|
||||
assert "shop.douyin_price_appeal" in {
|
||||
schedule.workflow_id for schedule in catalog.schedules
|
||||
}
|
||||
@@ -42,13 +39,13 @@ def test_catalog_schedules_douyin_price_appeal() -> None:
|
||||
assert schedule.effective_times == ("08:00", "16:00", "22:00")
|
||||
|
||||
|
||||
def test_tmall_video_upload_is_an_idempotent_store_selected_manual_workflow() -> None:
|
||||
def test_tmall_video_upload_is_an_idempotent_store_selected_scheduled_workflow() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
workflow = next(
|
||||
item for item in catalog.workflows if item.workflow_id == "product.video_upload"
|
||||
)
|
||||
|
||||
assert workflow.trigger == "manual"
|
||||
assert workflow.trigger == "scheduled"
|
||||
assert [step.step_id for step in workflow.steps] == ["upload"]
|
||||
step = workflow.steps[0]
|
||||
assert step.entry == "upload_video_to_guanghe.py"
|
||||
@@ -60,11 +57,15 @@ def test_tmall_video_upload_is_an_idempotent_store_selected_manual_workflow() ->
|
||||
assert step.timeout_seconds == 21600
|
||||
assert workflow.topic_config is not None
|
||||
assert workflow.topic_config.default_keyword == "我的夏日焕新清单"
|
||||
with pytest.raises(KeyError):
|
||||
catalog.schedule_for(workflow.workflow_id)
|
||||
schedule = catalog.schedule_for(workflow.workflow_id)
|
||||
assert schedule.kind == "interval_days"
|
||||
assert schedule.at == "14:00"
|
||||
assert schedule.every_days == 3
|
||||
assert schedule.anchor_date == "2026-09-04"
|
||||
assert schedule.enabled is True
|
||||
|
||||
|
||||
def test_jd_video_upload_is_an_idempotent_manual_workflow() -> None:
|
||||
def test_jd_video_upload_is_an_idempotent_scheduled_workflow() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
workflow = next(
|
||||
item
|
||||
@@ -72,7 +73,7 @@ def test_jd_video_upload_is_an_idempotent_manual_workflow() -> None:
|
||||
if item.workflow_id == "product.jd_video_upload"
|
||||
)
|
||||
|
||||
assert workflow.trigger == "manual"
|
||||
assert workflow.trigger == "scheduled"
|
||||
assert [step.step_id for step in workflow.steps] == ["upload"]
|
||||
step = workflow.steps[0]
|
||||
assert step.entry == "upload_video_to_jd.py"
|
||||
@@ -84,8 +85,12 @@ def test_jd_video_upload_is_an_idempotent_manual_workflow() -> None:
|
||||
)
|
||||
assert step.replay_policy == "idempotent"
|
||||
assert step.timeout_seconds == 21600
|
||||
with pytest.raises(KeyError):
|
||||
catalog.schedule_for(workflow.workflow_id)
|
||||
schedule = catalog.schedule_for(workflow.workflow_id)
|
||||
assert schedule.kind == "interval_days"
|
||||
assert schedule.at == "14:00"
|
||||
assert schedule.every_days == 3
|
||||
assert schedule.anchor_date == "2026-09-04"
|
||||
assert schedule.enabled is True
|
||||
|
||||
|
||||
def test_tmall_baibu_apply_is_a_six_step_hourly_workflow() -> None:
|
||||
@@ -181,13 +186,13 @@ def test_every_scheduled_workflow_has_one_valid_asia_shanghai_schedule() -> None
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
|
||||
assert catalog.timezone == "Asia/Shanghai"
|
||||
assert len(catalog.schedules) == 27
|
||||
assert len(catalog.schedules) == 29
|
||||
for workflow in catalog.scheduled_workflows():
|
||||
schedule = catalog.schedule_for(workflow.workflow_id)
|
||||
assert schedule.workflow_id == workflow.workflow_id
|
||||
assert schedule.kind in {"daily", "weekly", "monthly", "interval_days"}
|
||||
assert schedule.at.count(":") == 1
|
||||
assert sum(schedule.enabled for schedule in catalog.schedules) == 23
|
||||
assert sum(schedule.enabled for schedule in catalog.schedules) == 25
|
||||
|
||||
|
||||
def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() -> None:
|
||||
|
||||
@@ -51,7 +51,7 @@ def test_default_cli_registry_exposes_all_project_scheduled_workflows(
|
||||
scheduled_ids = {
|
||||
entry.workflow_id for entry in registry.catalog.scheduled_workflows()
|
||||
}
|
||||
assert len(scheduled_ids) == 27
|
||||
assert len(scheduled_ids) == 29
|
||||
assert scheduled_ids == {
|
||||
schedule.workflow_id for schedule in registry.catalog.schedules
|
||||
}
|
||||
|
||||
@@ -24,11 +24,8 @@ def test_product_module_registers_scheduled_and_manual_entries() -> None:
|
||||
WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
)
|
||||
assert tuple(item.workflow_id for item in module.workflow_definitions()) == PRODUCT_WORKFLOW_IDS
|
||||
assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 10
|
||||
assert PRODUCT_MANUAL_WORKFLOW_IDS == (
|
||||
"product.video_upload",
|
||||
"product.jd_video_upload",
|
||||
)
|
||||
assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 12
|
||||
assert PRODUCT_MANUAL_WORKFLOW_IDS == ()
|
||||
|
||||
|
||||
def test_tmall_baibu_import_mode_filters_hyperlink_steps_and_rewires_dependencies() -> None:
|
||||
|
||||
@@ -53,7 +53,7 @@ def test_catalog_and_scripts_work_after_project_and_data_roots_move(
|
||||
|
||||
assert workflows.returncode == 0, workflows.stderr
|
||||
assert scripts.returncode == 0, scripts.stderr
|
||||
assert len(json.loads(workflows.stdout)) == 27
|
||||
assert len(json.loads(workflows.stdout)) == 29
|
||||
commands = json.loads(scripts.stdout)
|
||||
assert len(commands) == 41
|
||||
assert len({row["command_id"] for row in commands}) == 41
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_python_scheduler_status_is_project_owned_and_cross_platform(tmp_path: P
|
||||
assert exit_code == 0
|
||||
payload = json.loads(output.getvalue())
|
||||
assert payload["timezone"] == "Asia/Shanghai"
|
||||
assert payload["scheduled_count"] == 27
|
||||
assert payload["scheduled_count"] == 29
|
||||
assert payload["state"]["schema_version"] == 1
|
||||
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ def test_misfire_older_than_grace_is_not_launched(tmp_path: Path) -> None:
|
||||
def test_configured_schedules_disable_cookie_refresh_job() -> None:
|
||||
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
||||
|
||||
assert len(catalog.schedules) == 27
|
||||
assert len(catalog.schedules) == 29
|
||||
assert [
|
||||
schedule.workflow_id for schedule in catalog.schedules if not schedule.enabled
|
||||
] == [
|
||||
|
||||
@@ -80,8 +80,8 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None:
|
||||
for entry in entries:
|
||||
workflow_entries.add(f"{workflow['module']}:{entry}")
|
||||
|
||||
assert scheduled_workflows == 27
|
||||
assert manual_workflows == 2
|
||||
assert scheduled_workflows == 29
|
||||
assert manual_workflows == 0
|
||||
assert len(workflow_entries) == 44
|
||||
assert len(catalog.scripts) == 49
|
||||
assert workflow_entries < set(catalog.script_ids)
|
||||
|
||||
@@ -47,8 +47,8 @@ def test_deployment_documents_one_scheduler_and_external_data_root() -> None:
|
||||
assert "GYXX_DATA_ROOT" in guide
|
||||
assert "/var/lib/gyxx-flow" in guide
|
||||
assert "Windows Task Scheduler" in guide
|
||||
assert len(payload["schedules"]) == 27
|
||||
assert sum(item.get("enabled", True) for item in payload["schedules"]) == 23
|
||||
assert len(payload["schedules"]) == 29
|
||||
assert sum(item.get("enabled", True) for item in payload["schedules"]) == 25
|
||||
|
||||
|
||||
def test_three_canonical_documents_cover_architecture_deployment_and_operations() -> None:
|
||||
|
||||
Reference in New Issue
Block a user