chore: [workflow] 提交工作区改动

This commit is contained in:
2026-09-07 15:14:54 +08:00
parent 485e77ee74
commit 2ff588a65d
27 changed files with 373 additions and 138 deletions
+19 -1
View File
@@ -114,7 +114,7 @@
"workflow_id": "product.erp_all_shop_daily" "workflow_id": "product.erp_all_shop_daily"
}, },
{ {
"at": "10:00", "at": "12:00",
"business_date_offset_days": 0, "business_date_offset_days": 0,
"days": [ "days": [
"Tuesday" "Tuesday"
@@ -143,6 +143,24 @@
"kind": "interval_days", "kind": "interval_days",
"workflow_id": "product.style_analysis.interval" "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", "at": "08:30",
"days": [ "days": [
+4 -4
View File
@@ -1638,7 +1638,7 @@
{ {
"id": "product.video_upload", "id": "product.video_upload",
"module": "product_commerce", "module": "product_commerce",
"trigger": "manual", "trigger": "scheduled",
"execution": { "execution": {
"steps": [ "steps": [
{ {
@@ -1703,12 +1703,12 @@
"label": "天猫话题关键词", "label": "天猫话题关键词",
"hint": "输入完整话题或关键词;例如输入“秋上新”也会匹配并选中“秋上新开拍了”。" "hint": "输入完整话题或关键词;例如输入“秋上新”也会匹配并选中“秋上新开拍了”。"
}, },
"note": "前端手动选择旗舰店或箱包店后执行脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。" "note": "默认由常驻调度器按 config/schedules.json 每 3 天 14:00 执行;前端仍可手动选择旗舰店或箱包店后执行脚本先扫描飞书并按所选店铺规则筛选,只有数据库存在可领取附件时才登录对应账号上传。允许同一业务日期重复扫描新增附件,PostgreSQL 附件级账本阻止已发布或结果不明的附件自动重发;框架安全预演不会启动上传脚本。"
}, },
{ {
"id": "product.jd_video_upload", "id": "product.jd_video_upload",
"module": "product_commerce", "module": "product_commerce",
"trigger": "manual", "trigger": "scheduled",
"execution": { "execution": {
"steps": [ "steps": [
{ {
@@ -1765,7 +1765,7 @@
"source_project": "product", "source_project": "product",
"task_name": "JdVideoUpload" "task_name": "JdVideoUpload"
}, },
"note": "前端手动幂等工作流;每次点击从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。" "note": "默认由常驻调度器按 config/schedules.json 每 3 天 14:00 执行;前端仍可手动触发。每次从凌云air基线扫描到视图末尾,新增行或旧行新增的视频 file_token 会入账,已发布 token 不会重复发布。安全验证出现时在有头浏览器中人工完成后脚本自动继续。"
}, },
{ {
"id": "product.tmall_baibu_apply", "id": "product.tmall_baibu_apply",
+2 -2
View File
@@ -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:00Asia/Shanghai)执行;前端仍可手动选择“光影行星旗舰店 / 光影行星鑫华达专卖店:龙虾仔”并立即运行,也可填写天猫话题完整名称或关键词正式执行只处理所选店铺,话题搜索结果按包含关系选中。默认 dry-run 只生成运行记录并跳过浏览器节点;确认飞书待传记录、对应淘宝光合店铺的登录态和 PostgreSQL 后,再显式正式执行:
```bash ```bash
uv run gyxx run product.video_upload --date 2026-08-07 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:00Asia/Shanghai)执行;前端仍可手动立即运行,同一业务日期可以重复执行。每次都从飞书视图中的固定凌云air记录 `recvrXTRWWFXeJ`(含)扫描到视图末尾,不依赖“日期”字段。真正的防重键是 PostgreSQL 中的 `视频 file_token + 目标账号 + 飞书记录`
```bash ```bash
uv run gyxx run product.jd_video_upload --date 2026-08-07 uv run gyxx run product.jd_video_upload --date 2026-08-07
+5 -4
View File
@@ -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,旧飞书配置主表不再属于运行路径。首次迁移、字段模型、受影响 配置存储在云端 PostgreSQL,旧飞书配置主表不再属于运行路径。首次迁移、字段模型、受影响
工作流和失败回退边界见 [工作流动态配置](dynamic-workflow-config.md)。 工作流和失败回退边界见 [工作流动态配置](dynamic-workflow-config.md)。
@@ -43,6 +43,7 @@ TARGET_URLS = [
"https://www.chanmama.com/bloggerRank/ujU3Wwa9udJc59DKO5dAA37ILJPsWvyg.html?activeTab=aweme", "https://www.chanmama.com/bloggerRank/ujU3Wwa9udJc59DKO5dAA37ILJPsWvyg.html?activeTab=aweme",
"https://www.chanmama.com/bloggerRank/ulaJWIvluqa_q6bQZzLdkzmLXNz64tgg.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") DOWNLOAD_DIR = str(PATHS.raw_root / "chanmama")
COOKIE_FILE = str(PATHS.browser_cookie_file) COOKIE_FILE = str(PATHS.browser_cookie_file)
COOKIE_MAX_AGE = 7 * 24 * 3600 # cookie 有效期 7 天(秒) COOKIE_MAX_AGE = 7 * 24 * 3600 # cookie 有效期 7 天(秒)
@@ -486,7 +487,17 @@ def navigate_to_target(driver, url):
if attempt >= CHANMAMA_NAVIGATION_ATTEMPTS: if attempt >= CHANMAMA_NAVIGATION_ATTEMPTS:
break break
print(f"⚠️ 目标页未完成渲染,重新打开一次: {exc}") 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 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; && style.opacity !== '0' && rect.width > 0 && rect.height > 0;
}; };
const body = (document.body?.innerText || '').replace(/\s+/g, ' '); 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) if (/预估曝光|曝光量/.test(body)
&& [...document.querySelectorAll('table,[role="row"],[class*="video"],[class*="list"],[class*="record"]')] && [...document.querySelectorAll('table,[role="row"],[class*="video"],[class*="list"],[class*="record"]')]
.some((el) => visible(el) && /预估曝光|曝光量|播放量/.test(el.innerText || ''))) { .some((el) => visible(el) && /预估曝光|曝光量|播放量/.test(el.innerText || ''))) {
@@ -3,6 +3,8 @@ ALTER TABLE cmt_styles
ADD COLUMN IF NOT EXISTS brand VARCHAR(128) DEFAULT '', ADD COLUMN IF NOT EXISTS brand VARCHAR(128) DEFAULT '',
ADD COLUMN IF NOT EXISTS erp_style_codes TEXT[] 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 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 product_type VARCHAR(128) DEFAULT '',
ADD COLUMN IF NOT EXISTS dimensions VARCHAR(255) DEFAULT '', ADD COLUMN IF NOT EXISTS dimensions VARCHAR(255) DEFAULT '',
ADD COLUMN IF NOT EXISTS colors TEXT[] 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; 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.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_source_urls IS 'Public pages used to verify product facts';
COMMENT ON COLUMN cmt_styles.product_research_status IS 'verified, partial, or pending'; 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 '', brand VARCHAR(128) DEFAULT '',
erp_style_codes TEXT[] DEFAULT '{}', erp_style_codes TEXT[] DEFAULT '{}',
platform_product_ids JSONB DEFAULT '{}'::jsonb, 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 '', product_type VARCHAR(128) DEFAULT '',
dimensions VARCHAR(255) DEFAULT '', dimensions VARCHAR(255) DEFAULT '',
colors TEXT[] 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.name IS '款式名称';
COMMENT ON COLUMN cmt_styles.style_category IS '款式风格'; COMMENT ON COLUMN cmt_styles.style_category IS '款式风格';
COMMENT ON COLUMN cmt_styles.platform 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); 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" "&btm_show_id=a1499f13-89eb-4bc0-99f5-ceb3b8f2e600"
) )
BUYIN_DETAIL_URL_MARK = "/dashboard/servicehall/daren-profile" BUYIN_DETAIL_URL_MARK = "/dashboard/servicehall/daren-profile"
BUYIN_SEARCH_INPUT_SELECTOR = "input[placeholder*='搜达人昵称']"
# --buyin 仍可显式强制使用抖店达人广场;日报默认保留星图主采集,未命中 # --buyin 仍可显式强制使用抖店达人广场;日报默认保留星图主采集,未命中
# 的任务由主入口在同一批次内切换到抖店补采。 # 的任务由主入口在同一批次内切换到抖店补采。
@@ -755,17 +756,19 @@ def dismiss_popups(page: Page) -> None:
def select_nickname_search(page: Page) -> None: def select_nickname_search(page: Page) -> None:
if CONTENT_SOURCE == "buyin": if CONTENT_SOURCE == "buyin":
if not click_by_text(page, "找达人", exact=True, timeout=5000): # Buyin has a global "找达人" navigation item. Clicking the first
page.evaluate( # 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
const target = [...document.querySelectorAll('button, a, div, span')] # directly and repair the route when a stale page is still open.
.find((el) => (el.innerText || '').trim() === '找达人'); input_locator = page.locator(f"{BUYIN_SEARCH_INPUT_SELECTOR}:visible").first
target?.click(); 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 return
if not click_by_text(page, "昵称找人", exact=True, timeout=5000): 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) select_nickname_search(page)
# Keep the platform's original input locator. The query value is the # Keep the platform's original input locator. The query value is the
# only thing that changes between the ID attempt and nickname fallback. # 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.wait_for(state="visible", timeout=30000)
input_locator.click() input_locator.click()
previous_result_signature = ( previous_result_signature = (
@@ -21,11 +21,10 @@ PRODUCT_SCHEDULED_WORKFLOW_IDS = (
"product.erp_all_shop_daily", "product.erp_all_shop_daily",
"product.market_rank", "product.market_rank",
"product.tmall_baibu_apply", "product.tmall_baibu_apply",
)
PRODUCT_MANUAL_WORKFLOW_IDS = (
"product.video_upload", "product.video_upload",
"product.jd_video_upload", "product.jd_video_upload",
) )
PRODUCT_MANUAL_WORKFLOW_IDS = ()
PRODUCT_WORKFLOW_IDS = PRODUCT_SCHEDULED_WORKFLOW_IDS + PRODUCT_MANUAL_WORKFLOW_IDS PRODUCT_WORKFLOW_IDS = PRODUCT_SCHEDULED_WORKFLOW_IDS + PRODUCT_MANUAL_WORKFLOW_IDS
PRODUCT_TIMEOUT_SECONDS = 6 * 60 * 60 PRODUCT_TIMEOUT_SECONDS = 6 * 60 * 60
PRODUCT_RESOURCE = "module:product_commerce" PRODUCT_RESOURCE = "module:product_commerce"
@@ -23,7 +23,7 @@ def safe_name(value):
def load_style_meta(): def load_style_meta():
"""优先从飞书读取 ERP 编码,失败则回退 styles_input.json""" """只从产品生命进程读取 ERP 编码;不使用本地旧配置回退"""
result = {} result = {}
try: try:
for item in get_erp_styles_input(): for item in get_erp_styles_input():
@@ -35,19 +35,7 @@ def load_style_meta():
if result: if result:
return result return result
except Exception as exc: except Exception as exc:
print(f"[WARN] 动态读取 ERP 编码失败,使用本地文件回退: {exc}") raise RuntimeError("产品生命进程身份源不可用,拒绝读取 styles_input.json") from 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}
return result 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" INVENTORY_URL = "https://ppzh.jd.com/inventoryweb/brand/view/supplychainAnalysis/inventoryDetail.html"
DEFAULT_OUTPUT_DIR = RAW_DATA_ROOT / "jd自营库存销量数据" 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(): def _get_style_sku_groups():
"""优先从飞书读取京东自营 SKU 分组,失败则回退硬编码""" """从产品生命进程读取完整身份的京东自营 SKU 分组,失败则不采集"""
try: try:
groups = get_jd_self_sku_groups() groups = get_jd_self_sku_groups()
if groups: if groups:
return groups return groups
except Exception as exc: except Exception as exc:
print(f"[WARN] 动态读取京东自营 SKU 配置失败,使用硬编码回退: {exc}") print(f"[FAIL] 产品生命进程身份源不可用,跳过京东自营采集: {exc}")
return _STATIC_STYLE_SKU_GROUPS return []
STYLE_SKU_GROUPS = _get_style_sku_groups() STYLE_SKU_GROUPS = _get_style_sku_groups()
@@ -101,7 +67,7 @@ def _print_preflight() -> None:
"""启动期轻量打印:京东自营周汇总分组里今日待采款式数。""" """启动期轻量打印:京东自营周汇总分组里今日待采款式数。"""
try: try:
styles = get_jd_self_styles() styles = get_jd_self_styles()
print(f"[JD_SELF] 飞书表格 周汇总待采 {len(styles)} 款 (要求 SKU 非空)") print(f"[JD_SELF] 产品生命进程 周汇总待采 {len(styles)} 款 (要求 ERP + 京东自营 SKU)")
except Exception as exc: except Exception as exc:
print(f"[WARN] 飞书读取失败, 跳过 preflight: {exc}") print(f"[WARN] 飞书读取失败, 跳过 preflight: {exc}")
@@ -25,6 +25,7 @@ try:
) )
from .config.style_config_loader import ( from .config.style_config_loader import (
StyleConfigLoader, StyleConfigLoader,
has_complete_style_identity,
) )
from .lark_cli_runtime import run_lark_cli from .lark_cli_runtime import run_lark_cli
except ImportError: # Direct script execution from the module root. 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 ( from config.style_config_loader import (
StyleConfigLoader, StyleConfigLoader,
has_complete_style_identity,
) )
from lark_cli_runtime import run_lark_cli 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() 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) missing.append(target.product_name)
continue continue
key = normalized_name(style_name) key = normalized_name(style_name)
@@ -402,7 +404,7 @@ def require_complete_erp_mappings(missing_products: Sequence[str]) -> None:
if not missing: if not missing:
return return
raise RuntimeError( raise RuntimeError(
"以下产品在飞书款式配置中缺少款式映射ERP 款式编码," "以下产品在产品生命进程中缺少款式映射ERP 款式编码或平台商品编码"
"已停止正式写入且目标表未清空: " + "".join(missing) "已停止正式写入且目标表未清空: " + "".join(missing)
) )
@@ -169,6 +169,8 @@ _BATCH_FAILURE_RE = re.compile(
) )
_DRAFT_COUNT_RE = re.compile(r"草稿\s*[(]\s*(?P<count>\d+)\s*[)]") _DRAFT_COUNT_RE = re.compile(r"草稿\s*[(]\s*(?P<count>\d+)\s*[)]")
_DRAFT_EMPTY_MARKERS = ("暂无数据", "数据为空", "暂无商品", "没有符合条件的商品") _DRAFT_EMPTY_MARKERS = ("暂无数据", "数据为空", "暂无商品", "没有符合条件的商品")
_ANNOUNCEMENT_MARKERS = ("公告", "通知")
_ANNOUNCEMENT_DISMISS_LABELS = ("我已阅读完成", "关闭")
_EDITOR_CLOSE_SELECTORS = ( _EDITOR_CLOSE_SELECTORS = (
"button[aria-label='关闭此对话框']", "button[aria-label='关闭此对话框']",
"[role='button'][aria-label='关闭此对话框']", "[role='button'][aria-label='关闭此对话框']",
@@ -627,6 +629,58 @@ def _visible_locator(context: Any, selectors: Iterable[str]) -> Any | None:
return 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( def _find_password_login_form(
page: Any, page: Any,
*, *,
@@ -860,6 +914,8 @@ def _open_batch_import(
) -> _BatchImportStatus | None: ) -> _BatchImportStatus | None:
"""Open the import dialog, failing closed on an active prior operation.""" """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. # 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 # 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 # previous terminal summary can appear only after the dialog is opened and
@@ -886,6 +942,7 @@ def _open_batch_import(
deadline = time.monotonic() + 20 deadline = time.monotonic() + 20
while time.monotonic() < deadline: while time.monotonic() < deadline:
_dismiss_blocking_announcements(page)
if _click_text(page, ("商品批量导入",)): if _click_text(page, ("商品批量导入",)):
return previous return previous
current = _latest_batch_import_status(_page_text(page)) 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. """Leave the current editor and wait until the draft list is usable.
Tmall keeps the editor open after ``下一步`` saves a product. The next Tmall keeps the editor open after ``下一步`` saves a product. The next
product must be selected from the draft list, so explicitly navigate back product must be selected from the draft list, so close the editor first
before the loop reads the draft count. The visible 草稿 tab is preferred; and only then navigate back before the loop reads the draft count. This
closing the editor and browser history are fallbacks for the two page prevents a click on the 草稿 tab or the next 完善商品 action from being
variants used by the old and new seller consoles. 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 deadline = time.monotonic() + _DRAFT_NAVIGATION_TIMEOUT_SECONDS
close_attempted = False close_attempted = False
back_attempted = False back_attempted = False
while time.monotonic() < deadline: while time.monotonic() < deadline:
if _click_draft_tab(page) and _draft_list_is_ready(page):
return
if not close_attempted: if not close_attempted:
close_attempted = True close_attempted = True
if _close_editor(page): if _close_editor(page):
# Let the drawer close animation finish before trying the # 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) 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: if not back_attempted:
back_attempted = True back_attempted = True
+5 -5
View File
@@ -1291,14 +1291,14 @@
const platforms = Array.isArray(record.platforms) ? record.platforms : []; const platforms = Array.isArray(record.platforms) ? record.platforms : [];
const enabledPlatforms = platforms.filter((platform) => platform.enabled !== false); 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 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 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 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>` : ""; 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)}"> 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__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__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__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__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> <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>`; </article>`;
@@ -1335,9 +1335,9 @@
function dynamicConfigPlatformRow(platform = {}) { function dynamicConfigPlatformRow(platform = {}) {
const enabled = platform.enabled !== false; const enabled = platform.enabled !== false;
return `<article class="dynamic-config-platform-row" data-platform-row> 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> <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> <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>`; </article>`;
} }
+10 -10
View File
@@ -74,7 +74,7 @@
</button> </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"> <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__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> <span class="module-nav__count" id="dynamic-config-nav-count"></span>
</button> </button>
</div> </div>
@@ -351,7 +351,7 @@
<div class="dynamic-config-header__copy"> <div class="dynamic-config-header__copy">
<span class="eyebrow">STYLE CONFIGURATION WORKSPACE</span> <span class="eyebrow">STYLE CONFIGURATION WORKSPACE</span>
<h1 id="dynamic-config-title">款式配置中心</h1> <h1 id="dynamic-config-title">款式配置中心</h1>
<p>一款一条配置,集中维护 ERP、飞书业务目标和各平台商品身份</p> <p>一款一条配置,集中维护飞书业务目标、平台画像和启停;商品身份请在产品生命进程维护</p>
</div> </div>
<div class="dynamic-config-header__actions"> <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> <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="款式配置读取范围"> <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__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>
<section class="dynamic-config-summary" aria-label="配置统计"> <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-feedback" id="dynamic-config-feedback" role="status" aria-live="polite" hidden></div>
<div class="dynamic-config-table-wrap"> <div class="dynamic-config-table-wrap">
<div class="dynamic-config-table" role="list" aria-label="款式配置列表"> <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 id="dynamic-config-table-body"><div class="dynamic-config-loading">正在读取云端配置…</div></div>
</div> </div>
</div> </div>
@@ -560,7 +560,7 @@
<div class="overlay__backdrop" data-close="dynamic-config" aria-hidden="true"></div> <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"> <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"> <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> <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> </header>
<form id="dynamic-config-form" novalidate> <form id="dynamic-config-form" novalidate>
@@ -569,20 +569,20 @@
<aside class="dynamic-config-form-rail" aria-label="编辑页导航"> <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> <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="配置分区"> <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="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> </nav>
<div class="dynamic-config-form-rail__tip"><span aria-hidden="true"></span><div><strong>为下一条逻辑留位置</strong><p>新分析逻辑只需新增一条业务目标,使用稳定的逻辑标识读取对应地址。</p></div></div> <div class="dynamic-config-form-rail__tip"><span aria-hidden="true"></span><div><strong>为下一条逻辑留位置</strong><p>新分析逻辑只需新增一条业务目标,使用稳定的逻辑标识读取对应地址。</p></div></div>
</aside> </aside>
<div class="modal__body dynamic-config-form-body"> <div class="modal__body dynamic-config-form-body">
<p class="form-error" id="dynamic-config-form-error" role="alert" hidden></p> <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"> <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"> <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>款式名称 <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"><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 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> <label class="field field--full"><span>备注</span><input id="config-note" maxlength="500" placeholder="记录这款的业务说明、特殊规则或负责人"></label>
<input id="config-style-content" type="hidden"> <input id="config-style-content" type="hidden">
@@ -596,7 +596,7 @@
</section> </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"> <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> <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> <datalist id="config-platform-options"><option value="天猫"><option value="京东旗舰店"><option value="京东自营"><option value="抖音"><option value="拼多多"><option value="拼多多光影行星GYXX箱包专卖店"><option value="拼多多光影行星箱包官方旗舰店"><option value="拼多多光影行星官方旗舰店"></datalist>
</section> </section>
@@ -32,6 +32,45 @@ def test_buyin_source_uses_doudian_market_and_cookie_scope():
assert xingtu.BUSINESS_COOKIE_DOMAINS == ("jinritemai.com",) 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): def test_save_state_creates_cookie_parent_directory(tmp_path, monkeypatch):
cookie_file = tmp_path / "nested" / "cookies.json" cookie_file = tmp_path / "nested" / "cookies.json"
monkeypatch.setattr(xingtu, "DATA_DIR", tmp_path / "raw") 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( backfill.build_style_plans(
{ {
"styles": { "styles": {
"款式甲": {"erp_codes": ["10416"]}, "款式甲": {"erp_codes": ["10416"], "tm": {"item_ids": ["tm-1"]}},
"款式乙": {"erp_codes": ["10416"]}, "款式乙": {"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: def test_all_shop_filter_selects_every_shop_and_batches_all_codes() -> None:
class Frame: class Frame:
def __init__(self) -> None: def __init__(self) -> None:
@@ -210,8 +210,11 @@ def test_erp_style_plan_uses_style_base_codes_and_aliases() -> None:
ProductTarget("云栖相机双肩包", 200), ProductTarget("云栖相机双肩包", 200),
] ]
styles = { styles = {
"盖世m1": {"erp_codes": ["10416", "10455", "10416"]}, "盖世m1": {
"星迹2": {"erp_codes": ["10504", "10394"]}, "erp_codes": ["10416", "10455", "10416"],
"tm": {"item_ids": ["tm-1"]},
},
"星迹2": {"erp_codes": ["10504", "10394"], "tm": {"item_ids": ["tm-2"]}},
"云栖": {"erp_codes": []}, "云栖": {"erp_codes": []},
} }
@@ -321,6 +321,83 @@ def test_upload_template_supports_tmall_dialog_import_button(
assert chooser.files == str(template) 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: def test_open_batch_import_refuses_an_active_previous_operation(monkeypatch) -> None:
page = SimpleNamespace() page = SimpleNamespace()
monkeypatch.setattr( monkeypatch.setattr(
@@ -538,6 +615,33 @@ def test_return_to_draft_list_falls_back_to_browser_history(monkeypatch) -> None
assert events == ["draft", "back"] 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: def test_tmall_config_enables_draft_completion_for_only_old_all_3c() -> None:
old_all_3c = baibu._load_entry("old_all_3c") old_all_3c = baibu._load_entry("old_all_3c")
old_all_bag = baibu._load_entry("old_all_bag") old_all_bag = baibu._load_entry("old_all_bag")
+23 -18
View File
@@ -11,15 +11,15 @@ from gyxx_flow.catalog import CatalogError, WorkflowCatalog
PROJECT_ROOT = Path(__file__).resolve().parents[1] 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") catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
scheduled = catalog.scheduled_workflows() scheduled = catalog.scheduled_workflows()
assert len(scheduled) == 27 assert len(scheduled) == 29
assert len({item.source_task_name for item in scheduled}) == 27 assert len({item.source_task_name for item in scheduled}) == 29
assert Counter(item.module for item in scheduled) == { assert Counter(item.module for item in scheduled) == {
"content_marketing": 9, "content_marketing": 9,
"product_commerce": 10, "product_commerce": 12,
"shop_intelligence": 4, "shop_intelligence": 4,
"supply_chain": 4, "supply_chain": 4,
} }
@@ -30,10 +30,7 @@ def test_catalog_schedules_douyin_price_appeal() -> None:
schedule = catalog.schedule_for("shop.douyin_price_appeal") schedule = catalog.schedule_for("shop.douyin_price_appeal")
assert len(catalog.workflows) == 29 assert len(catalog.workflows) == 29
assert tuple(item.workflow_id for item in catalog.manual_workflows()) == ( assert catalog.manual_workflows() == ()
"product.video_upload",
"product.jd_video_upload",
)
assert "shop.douyin_price_appeal" in { assert "shop.douyin_price_appeal" in {
schedule.workflow_id for schedule in catalog.schedules 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") 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") catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
workflow = next( workflow = next(
item for item in catalog.workflows if item.workflow_id == "product.video_upload" 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"] assert [step.step_id for step in workflow.steps] == ["upload"]
step = workflow.steps[0] step = workflow.steps[0]
assert step.entry == "upload_video_to_guanghe.py" 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 step.timeout_seconds == 21600
assert workflow.topic_config is not None assert workflow.topic_config is not None
assert workflow.topic_config.default_keyword == "我的夏日焕新清单" assert workflow.topic_config.default_keyword == "我的夏日焕新清单"
with pytest.raises(KeyError): schedule = catalog.schedule_for(workflow.workflow_id)
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") catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
workflow = next( workflow = next(
item item
@@ -72,7 +73,7 @@ def test_jd_video_upload_is_an_idempotent_manual_workflow() -> None:
if item.workflow_id == "product.jd_video_upload" 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"] assert [step.step_id for step in workflow.steps] == ["upload"]
step = workflow.steps[0] step = workflow.steps[0]
assert step.entry == "upload_video_to_jd.py" 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.replay_policy == "idempotent"
assert step.timeout_seconds == 21600 assert step.timeout_seconds == 21600
with pytest.raises(KeyError): schedule = catalog.schedule_for(workflow.workflow_id)
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: 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") catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
assert catalog.timezone == "Asia/Shanghai" assert catalog.timezone == "Asia/Shanghai"
assert len(catalog.schedules) == 27 assert len(catalog.schedules) == 29
for workflow in catalog.scheduled_workflows(): for workflow in catalog.scheduled_workflows():
schedule = catalog.schedule_for(workflow.workflow_id) schedule = catalog.schedule_for(workflow.workflow_id)
assert schedule.workflow_id == workflow.workflow_id assert schedule.workflow_id == workflow.workflow_id
assert schedule.kind in {"daily", "weekly", "monthly", "interval_days"} assert schedule.kind in {"daily", "weekly", "monthly", "interval_days"}
assert schedule.at.count(":") == 1 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: def test_scheduled_workflows_use_python_entries_and_content_tasks_are_graphs() -> None:
+1 -1
View File
@@ -51,7 +51,7 @@ def test_default_cli_registry_exposes_all_project_scheduled_workflows(
scheduled_ids = { scheduled_ids = {
entry.workflow_id for entry in registry.catalog.scheduled_workflows() entry.workflow_id for entry in registry.catalog.scheduled_workflows()
} }
assert len(scheduled_ids) == 27 assert len(scheduled_ids) == 29
assert scheduled_ids == { assert scheduled_ids == {
schedule.workflow_id for schedule in registry.catalog.schedules schedule.workflow_id for schedule in registry.catalog.schedules
} }
+2 -5
View File
@@ -24,11 +24,8 @@ def test_product_module_registers_scheduled_and_manual_entries() -> None:
WorkflowCatalog.load(PROJECT_ROOT / "config") WorkflowCatalog.load(PROJECT_ROOT / "config")
) )
assert tuple(item.workflow_id for item in module.workflow_definitions()) == PRODUCT_WORKFLOW_IDS assert tuple(item.workflow_id for item in module.workflow_definitions()) == PRODUCT_WORKFLOW_IDS
assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 10 assert len(PRODUCT_SCHEDULED_WORKFLOW_IDS) == 12
assert PRODUCT_MANUAL_WORKFLOW_IDS == ( assert PRODUCT_MANUAL_WORKFLOW_IDS == ()
"product.video_upload",
"product.jd_video_upload",
)
def test_tmall_baibu_import_mode_filters_hyperlink_steps_and_rewires_dependencies() -> None: def test_tmall_baibu_import_mode_filters_hyperlink_steps_and_rewires_dependencies() -> None:
+1 -1
View File
@@ -53,7 +53,7 @@ def test_catalog_and_scripts_work_after_project_and_data_roots_move(
assert workflows.returncode == 0, workflows.stderr assert workflows.returncode == 0, workflows.stderr
assert scripts.returncode == 0, scripts.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) commands = json.loads(scripts.stdout)
assert len(commands) == 41 assert len(commands) == 41
assert len({row["command_id"] for row in commands}) == 41 assert len({row["command_id"] for row in commands}) == 41
+1 -1
View File
@@ -54,7 +54,7 @@ def test_python_scheduler_status_is_project_owned_and_cross_platform(tmp_path: P
assert exit_code == 0 assert exit_code == 0
payload = json.loads(output.getvalue()) payload = json.loads(output.getvalue())
assert payload["timezone"] == "Asia/Shanghai" assert payload["timezone"] == "Asia/Shanghai"
assert payload["scheduled_count"] == 27 assert payload["scheduled_count"] == 29
assert payload["state"]["schema_version"] == 1 assert payload["state"]["schema_version"] == 1
+1 -1
View File
@@ -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: def test_configured_schedules_disable_cookie_refresh_job() -> None:
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config") catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
assert len(catalog.schedules) == 27 assert len(catalog.schedules) == 29
assert [ assert [
schedule.workflow_id for schedule in catalog.schedules if not schedule.enabled schedule.workflow_id for schedule in catalog.schedules if not schedule.enabled
] == [ ] == [
+2 -2
View File
@@ -80,8 +80,8 @@ def test_default_registry_covers_every_executable_workflow_entry() -> None:
for entry in entries: for entry in entries:
workflow_entries.add(f"{workflow['module']}:{entry}") workflow_entries.add(f"{workflow['module']}:{entry}")
assert scheduled_workflows == 27 assert scheduled_workflows == 29
assert manual_workflows == 2 assert manual_workflows == 0
assert len(workflow_entries) == 44 assert len(workflow_entries) == 44
assert len(catalog.scripts) == 49 assert len(catalog.scripts) == 49
assert workflow_entries < set(catalog.script_ids) assert workflow_entries < set(catalog.script_ids)
+2 -2
View File
@@ -47,8 +47,8 @@ def test_deployment_documents_one_scheduler_and_external_data_root() -> None:
assert "GYXX_DATA_ROOT" in guide assert "GYXX_DATA_ROOT" in guide
assert "/var/lib/gyxx-flow" in guide assert "/var/lib/gyxx-flow" in guide
assert "Windows Task Scheduler" in guide assert "Windows Task Scheduler" in guide
assert len(payload["schedules"]) == 27 assert len(payload["schedules"]) == 29
assert sum(item.get("enabled", True) for item in payload["schedules"]) == 23 assert sum(item.get("enabled", True) for item in payload["schedules"]) == 25
def test_three_canonical_documents_cover_architecture_deployment_and_operations() -> None: def test_three_canonical_documents_cover_architecture_deployment_and_operations() -> None: