"""VIP 报表数据同步：采集 S11（Campaign-年货节）来源并落盘校验结果。"""

from __future__ import annotations

import argparse
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
import io
import json
import re
from pathlib import Path
from typing import Any
from urllib.parse import quote
from urllib.request import Request, urlopen

import pymysql
import yaml
from PIL import Image, ImageDraw, ImageFont
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
import numpy as np


# 中文月份归一化映射：CLI/config 可传中文或数字，最终统一为 month_num。
MONTH_CN_MAP = {
    1: "一月",
    2: "二月",
    3: "三月",
    4: "四月",
    5: "五月",
    6: "六月",
    7: "七月",
    8: "八月",
    9: "九月",
    10: "十月",
    11: "十一月",
    12: "十二月",
}

MONTH_CN_TO_NUM = {label: number for number, label in MONTH_CN_MAP.items()}
LEVEL1_ORDER = ["BAGS", "SHOES", "ACC"]
ACTIVITY_MEMOS = {"活动款", "活动折扣款"}
NON_ACTIVITY_ONLY_MEMOS = {"非活动款"}
ESS_MEMO = "ESS"

TABLE3_CATEGORY_ORDER = [
    "女单鞋",
    "女凉鞋",
    "女拖鞋",
    "女休闲鞋",
    "女靴",
    "单肩包",
    "卡包",
    "钱包",
    "手拿包",
    "手提包",
    "双肩包",
    "胸包/腰包",
]
TABLE3_CATEGORY_SET = set(TABLE3_CATEGORY_ORDER)
TABLE3_BAGS_CATEGORY_SET = {"单肩包", "卡包", "钱包", "手拿包", "手提包", "双肩包", "胸包/腰包"}
TABLE3_SHOES_CATEGORY_SET = {"女单鞋", "女凉鞋", "女拖鞋", "女休闲鞋", "女靴"}

# 明细 fallback：当同比期没有可直接映射的 article 时，按 level3 兜底归类。
DETAIL_LEVEL3_FALLBACK = {
    "TOP HANDLE": "手提包",
    "BACKPACK": "双肩包",
    "BUM / BELT BAG": "胸包/腰包",
    "CARD HOLDER": "卡包",
    "WALLET": "钱包",
    "COMPACT WALLET": "钱包",
    "PHONE POUCH": "钱包",
    "MINI PURSE": "钱包",
    "POUCH": "手拿包",
    "CLUTCH": "手拿包",
    "WALLET ON CHAIN": "手拿包",
    "SHOULDER BAG": "单肩包",
    "SHOULDER": "单肩包",
    "CROSSBODY": "单肩包",
    "HOBO": "单肩包",
    "TOTE": "单肩包",
    "BUCKET": "单肩包",
    "CHAIN BAG": "单肩包",
    "MINI BAG": "单肩包",
    "MICRO BAG": "单肩包",
    "HEELS": "女单鞋",
    "FLATS": "女单鞋",
    "WEDGES": "女凉鞋",
    "SNEAKERS": "女休闲鞋",
    "BOOTS": "女靴",
    "ACCESSORY": "手拿包",
}
DETAIL_CATEGORY_DEFAULT = "手拿包"

JAN_TEMPLATE_EXPECTED = {
    "sales_10k": 910,
    "activity_ratio_qty": "11:89",
}
S11_NARRATIVE_SHAPE = {
    "slide": 11,
    "shape_id": 27,
    "shape_name": "TextBox 15",
}
S11_TABLE1_SHAPE = {
    "slide": 11,
    "shape_id": 2,
    "shape_name": "表格 1",
}
S11_TABLE2_SHAPE = {
    "slide": 11,
    "shape_id": 3,
    "shape_name": "表格 2",
}
S11_TABLE2_TOTAL_ROWS = 18
S11_TABLE3_SHAPE = {
    "slide": 11,
    "shape_id": 4,
    "shape_name": "表格 3",
}
S11_CHART_QTY_SHAPE = {
    "slide": 11,
    "shape_id": 28,
    "shape_name": "图表 27",
}
S11_CHART_SALES_SHAPE = {
    "slide": 11,
    "shape_id": 29,
    "shape_name": "图表 28",
}
S11_TOP10_SHAPE = {
    "slide": 11,
    "shape_id": 5,
    "shape_name": "图片 4",
}


@dataclass(frozen=True)
class ReportPeriod:
    """报表期对象，统一管理当期与同比期字符串口径。"""

    month_label: str
    month_num: int
    report_year: int
    compare_year: int

    @property
    def ym(self) -> str:
        """函数说明：封装ym方法的核心处理流程。"""
        return f"{self.report_year}-{self.month_num:02d}"

    @property
    def compare_ym(self) -> str:
        """函数说明：封装compare_ym方法的核心处理流程。"""
        return f"{self.compare_year}-{self.month_num:02d}"


@dataclass(frozen=True)
class CampaignWindow:
    """活动窗口对象：用于统一 S11 的时间口径与标题展示。"""

    current_start: date
    current_end: date
    compare_start: date
    compare_end: date

    @property
    def label(self) -> str:
        """函数说明：封装label方法的核心处理流程。"""
        return f"{self.current_start.month}.{self.current_start.day}-{self.current_end.month}.{self.current_end.day}"


def normalize_month_label(raw_month: Any) -> str:
    """函数说明：封装normalize_month_label方法的核心处理流程。"""
    if raw_month is None:
        return "一月"
    if isinstance(raw_month, int):
        return MONTH_CN_MAP.get(raw_month, "一月")
    text = str(raw_month).strip()
    if text in MONTH_CN_TO_NUM:
        return text
    match = re.fullmatch(r"0?([1-9]|1[0-2])(?:月)?", text)
    if match:
        return MONTH_CN_MAP[int(match.group(1))]
    return text


def month_label_to_number(month_label: str) -> int:
    """函数说明：封装month_label_to_number方法的核心处理流程。"""
    return MONTH_CN_TO_NUM.get(month_label, 1)


def parse_args() -> argparse.Namespace:
    """函数说明：封装parse_args方法的核心处理流程。"""
    parser = argparse.ArgumentParser(description="同步 S11 Campaign 数据来源并生成校验产物。")
    parser.add_argument(
        "--config",
        default=r"C:\Users\niuniu\.codex\vip-report\config.yaml",
        help="VIP report config.yaml 路径",
    )
    parser.add_argument(
        "--slides",
        default="S11",
        help="仅用于保持统一参数风格，当前仅支持 S11",
    )
    parser.add_argument(
        "--report-month",
        default="",
        help="报表月份（中文/数字）",
    )
    parser.add_argument(
        "--report-year",
        type=int,
        default=0,
        help="报表年份",
    )
    parser.add_argument(
        "--compare-year",
        type=int,
        default=0,
        help="同比年份",
    )
    parser.add_argument(
        "--shop-id",
        type=int,
        default=20,
        help="店铺 id，默认 20（CKC-VIP）",
    )
    parser.add_argument(
        "--disable-strict-january-check",
        action="store_true",
        help="关闭 2026-01 模板口径强校验。",
    )
    parser.add_argument(
        "--output-dir",
        default="",
        help="Override the output/work directory used for generated assets and ops.",
    )
    return parser.parse_args()


def resolve_report_period(args: argparse.Namespace, config: dict[str, Any]) -> ReportPeriod:
    """函数说明：封装resolve_report_period方法的核心处理流程。"""
    report_cfg = config.get("report", {})
    month_label = normalize_month_label(args.report_month or report_cfg.get("month_cn", "一月"))
    month_num = month_label_to_number(month_label)
    report_year = int(args.report_year or report_cfg.get("year", 2026))
    compare_year = int(args.compare_year or report_cfg.get("compare_year", report_year - 1))
    return ReportPeriod(
        month_label=month_label,
        month_num=month_num,
        report_year=report_year,
        compare_year=compare_year,
    )


def parse_ymd(raw: Any) -> date | None:
    """函数说明：封装parse_ymd方法的核心处理流程。"""
    if raw is None:
        return None
    text = str(raw).strip()
    if not text:
        return None
    try:
        return datetime.strptime(text[:10], "%Y-%m-%d").date()
    except ValueError:
        return None


def to_decimal(value: Any) -> Decimal:
    """函数说明：封装to_decimal方法的核心处理流程。"""
    if value is None:
        return Decimal("0")
    if isinstance(value, Decimal):
        return value
    text = str(value).strip()
    if not text:
        return Decimal("0")
    try:
        return Decimal(text)
    except InvalidOperation:
        return Decimal("0")


def to_int(value: Any) -> int:
    """函数说明：封装to_int方法的核心处理流程。"""
    if value is None:
        return 0
    try:
        return int(value)
    except (TypeError, ValueError):
        return 0


def round_half_up(value: Decimal) -> int:
    """函数说明：封装round_half_up方法的核心处理流程。"""
    return int(value.quantize(Decimal("1"), rounding=ROUND_HALF_UP))


def safe_ratio(cur: Decimal, prev: Decimal) -> Decimal:
    """函数说明：封装safe_ratio方法的核心处理流程。"""
    if prev == 0:
        return Decimal("0")
    return (cur - prev) / prev


def ratio_string(left: int, right: int) -> str:
    """函数说明：封装ratio_string方法的核心处理流程。"""
    total = left + right
    if total <= 0:
        return "0:0"
    left_pct = round_half_up(Decimal(left) * Decimal("100") / Decimal(total))
    right_pct = 100 - left_pct
    return f"{left_pct}:{right_pct}"


def ratio_percent_text(cur: Decimal, prev: Decimal) -> str:
    """函数说明：封装ratio_percent_text方法的核心处理流程。"""
    pct = round_half_up(safe_ratio(cur, prev) * Decimal("100"))
    return f"{pct}%"


def pct_of_total_text(value: Decimal, total: Decimal) -> str:
    """函数说明：封装pct_of_total_text方法的核心处理流程。"""
    if total == 0:
        return "0%"
    pct = round_half_up(value * Decimal("100") / total)
    return f"{pct}%"


def format_int_with_comma(value: int) -> str:
    """函数说明：封装format_int_with_comma方法的核心处理流程。"""
    return f"{value:,}"


def format_plain_int(value: int) -> str:
    """函数说明：封装format_plain_int方法的核心处理流程。"""
    return str(value)


def format_sales_2_with_comma(value: Decimal) -> str:
    """函数说明：封装format_sales_2_with_comma方法的核心处理流程。"""
    return f"{value.quantize(Decimal('0.01')):,.2f}"


def format_sales_int_with_comma(value: Decimal) -> str:
    """函数说明：封装format_sales_int_with_comma方法的核心处理流程。"""
    return f"{round_half_up(value):,}"


def format_sales_2_plain(value: Decimal) -> str:
    """函数说明：封装format_sales_2_plain方法的核心处理流程。"""
    return f"{value.quantize(Decimal('0.01')):.2f}"


def format_sales_trim_plain(value: Decimal) -> str:
    """函数说明：封装format_sales_trim_plain方法的核心处理流程。"""
    normalized = value.quantize(Decimal("0.01")).normalize()
    text = format(normalized, "f")
    if "." in text:
        text = text.rstrip("0").rstrip(".")
    return text


def fetch_one(cursor: pymysql.cursors.Cursor, sql: str, params: tuple[Any, ...]) -> dict[str, Any]:
    """函数说明：封装fetch_one方法的核心处理流程。"""
    cursor.execute(sql, params)
    row = cursor.fetchone() or {}
    return dict(row)


def fetch_all(cursor: pymysql.cursors.Cursor, sql: str, params: tuple[Any, ...]) -> list[dict[str, Any]]:
    """函数说明：封装fetch_all方法的核心处理流程。"""
    cursor.execute(sql, params)
    rows = cursor.fetchall() or []
    return [dict(row) for row in rows]


def infer_campaign_window(shop_rows: list[dict[str, Any]], period: ReportPeriod) -> CampaignWindow:
    """函数说明：封装infer_campaign_window方法的核心处理流程。"""
    start_candidates = [parse_ymd(item.get("activitybegin")) for item in shop_rows]
    end_candidates = [parse_ymd(item.get("activityend")) for item in shop_rows]
    starts = [item for item in start_candidates if item is not None]
    ends = [item for item in end_candidates if item is not None]
    if not starts or not ends:
        raise SystemExit("oms_shop_report 缺少 activitybegin/activityend，无法推导 S11 活动窗口。")

    current_start_seed = min(starts)
    end_seed = max(ends)

    current_start = date(period.report_year, current_start_seed.month, current_start_seed.day)
    compare_start = date(period.compare_year, current_start_seed.month, current_start_seed.day)

    current_end = date(period.report_year, end_seed.month, end_seed.day)
    compare_end = date(period.compare_year, end_seed.month, end_seed.day)

    # 若 activityend 本身就是同比年，沿用它作为 compare_end 更稳妥。
    if end_seed.year == period.compare_year:
        compare_end = end_seed
        current_end = date(period.report_year, end_seed.month, end_seed.day)
    elif end_seed.year == period.report_year:
        current_end = end_seed
        compare_end = date(period.compare_year, end_seed.month, end_seed.day)

    return CampaignWindow(
        current_start=current_start,
        current_end=current_end,
        compare_start=compare_start,
        compare_end=compare_end,
    )


def filter_shop_rows_by_window(
    shop_rows: list[dict[str, Any]],
    start_date: date,
    end_date: date,
) -> list[dict[str, Any]]:
    """函数说明：按 activitybegin/activityend 精确筛选活动窗口，避免同月混入同比快照。"""
    filtered: list[dict[str, Any]] = []
    for row in shop_rows:
        activity_begin = parse_ymd(row.get("activitybegin"))
        activity_end = parse_ymd(row.get("activityend"))
        if activity_begin == start_date and activity_end == end_date:
            filtered.append(row)
    return filtered


def load_s11_narrative_template(workdir: Path) -> str:
    """函数说明：封装load_s11_narrative_template方法的核心处理流程。"""
    inventory_path = workdir / "shape-inventory.json"
    if inventory_path.exists():
        try:
            inventory = json.loads(inventory_path.read_text(encoding="utf-8-sig"))
            for slide in inventory.get("Slides", []):
                if slide.get("Slide") != S11_NARRATIVE_SHAPE["slide"]:
                    continue
                for shape in slide.get("Shapes", []):
                    if shape.get("Id") == S11_NARRATIVE_SHAPE["shape_id"]:
                        text = shape.get("Text") or ""
                        if isinstance(text, str) and text.strip():
                            return text
        except (json.JSONDecodeError, OSError):
            pass
    return (
        "唯品年货节销售910w，于1.24起销售逐渐回正并反超，同比增幅在11%。"
        "除客单仍较去年低3%外，流量及转化均呈现正向增长。流量在联投帮助下涨幅更加明显；\r"
        "活动款及非活动款比例为11:89。从品类层面来看，鞋包销量同比均为正向。"
        "鞋类增幅远大于包，达41%。主要依靠独家款及正价RP凉鞋及单鞋驱动。"
    )


def rewrite_s11_narrative(
    template_text: str,
    *,
    sales_10k: int,
    sales_lfl_pct: int,
    atv_lfl_pct: int,
    activity_ratio_qty: str,
    shoes_sales_lfl_pct: int,
) -> str:
    """函数说明：封装rewrite_s11_narrative方法的核心处理流程。"""
    text = template_text
    text = re.sub(r"销售\d+w", f"销售{sales_10k}w", text, count=1)

    sales_phrase = f"同比增幅在{sales_lfl_pct}%" if sales_lfl_pct >= 0 else f"同比降幅在{abs(sales_lfl_pct)}%"
    text = re.sub(r"同比[增降]幅在-?\d+%", sales_phrase, text, count=1)

    if atv_lfl_pct > 0:
        atv_phrase = f"客单仍较去年高{atv_lfl_pct}%"
        text = re.sub(r"客单仍较去年[低高]-?\d+%", atv_phrase, text, count=1)
    elif atv_lfl_pct < 0:
        atv_phrase = f"客单仍较去年低{abs(atv_lfl_pct)}%"
        text = re.sub(r"客单仍较去年[低高]-?\d+%", atv_phrase, text, count=1)
    else:
        text = re.sub(r"客单仍较去年[低高]-?\d+%", "客单与去年持平", text, count=1)

    text = re.sub(r"比例为\d+:\d+", f"比例为{activity_ratio_qty}", text, count=1)

    shoes_phrase = f"鞋类增幅远大于包，达{shoes_sales_lfl_pct}%"
    if shoes_sales_lfl_pct < 0:
        shoes_phrase = f"鞋类降幅明显，达{abs(shoes_sales_lfl_pct)}%"
    text = re.sub(r"鞋类增幅远大于包，达-?\d+%", shoes_phrase, text, count=1)
    return text


def build_table_cells(rows: list[list[str]]) -> list[dict[str, Any]]:
    """函数说明：封装build_table_cells方法的核心处理流程。"""
    cells: list[dict[str, Any]] = []
    for row_index, row in enumerate(rows, start=1):
        for col_index, value in enumerate(row, start=1):
            cells.append(
                {
                    "row": row_index,
                    "col": col_index,
                    "new_text": value,
                }
            )
    return cells


def choose_cn_category_for_detail(
    *,
    articlecode: str,
    level3: str,
    level4: str,
    article_to_cn: dict[str, str],
    combo_to_cn: dict[tuple[str, str], str],
    level3_to_cn: dict[str, str],
) -> str:
    # 优先级：同款 article 映射 > level3+level4 > level3 > fallback。
    """函数说明：封装choose_cn_category_for_detail方法的核心处理流程。"""
    if articlecode in article_to_cn and article_to_cn[articlecode] in TABLE3_CATEGORY_SET:
        return article_to_cn[articlecode]
    if (level3, level4) in combo_to_cn and combo_to_cn[(level3, level4)] in TABLE3_CATEGORY_SET:
        return combo_to_cn[(level3, level4)]
    if level3 in level3_to_cn and level3_to_cn[level3] in TABLE3_CATEGORY_SET:
        return level3_to_cn[level3]
    fallback = DETAIL_LEVEL3_FALLBACK.get(level3, DETAIL_CATEGORY_DEFAULT)
    if fallback not in TABLE3_CATEGORY_SET:
        return DETAIL_CATEGORY_DEFAULT
    return fallback


def load_font(size: int, *, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
    """函数说明：封装load_font方法的核心处理流程。"""
    candidates: list[str] = []
    if bold:
        candidates.extend(
            [
                r"C:\Windows\Fonts\msyhbd.ttc",
                r"C:\Windows\Fonts\simhei.ttf",
            ]
        )
    candidates.extend(
        [
            r"C:\Windows\Fonts\msyh.ttc",
            r"C:\Windows\Fonts\simsun.ttc",
            r"C:\Windows\Fonts\arial.ttf",
        ]
    )
    for path in candidates:
        try:
            return ImageFont.truetype(path, size=size)
        except OSError:
            continue
    return ImageFont.load_default()


def measure_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
    """函数说明：封装measure_text方法的核心处理流程。"""
    bbox = draw.textbbox((0, 0), text, font=font)
    return bbox[2] - bbox[0], bbox[3] - bbox[1]


def candidate_image_codes(article: str) -> list[str]:
    """函数说明：封装candidate_image_codes方法的核心处理流程。"""
    article = article.strip()
    if not article:
        return []
    codes = [article]
    suffix_match = re.fullmatch(r"(.+)-([A-Za-z0-9]{1,2})", article)
    if suffix_match:
        base_code = suffix_match.group(1)
        if base_code not in codes:
            codes.append(base_code)
    return codes


def load_article_image(article: str, *, image_cache_dir: Path) -> Image.Image | None:
    """函数说明：封装load_article_image方法的核心处理流程。"""
    image_cache_dir.mkdir(parents=True, exist_ok=True)
    for code in candidate_image_codes(article):
        cache_path = image_cache_dir / f"{code}.jpg"
        if cache_path.exists():
            try:
                return Image.open(cache_path).convert("RGB")
            except OSError:
                cache_path.unlink(missing_ok=True)

        url = f"https://api.charleskeith.cn/img/{quote(code)}.jpg"
        request = Request(url, headers={"User-Agent": "Mozilla/5.0"})
        try:
            with urlopen(request, timeout=8) as response:
                content_type = (response.headers.get("Content-Type") or "").lower()
                body = response.read()
            if not body or "image" not in content_type:
                continue
            cache_path.write_bytes(body)
            return Image.open(io.BytesIO(body)).convert("RGB")
        except Exception:
            continue
    return None


def render_top10_panel_image(top_rows: list[dict[str, Any]], output_path: Path, *, image_cache_dir: Path) -> None:
    """函数说明：封装render_top10_panel_image方法的核心处理流程。"""
    width = 1000
    height = 1980
    margin = 14
    header_h = 46
    row_h = (height - margin * 2 - header_h) // 10
    # TOP10 / PIC / QTY / SALES / 活动款备注 / 独家
    col_widths = [250, 205, 100, 150, 205, 90]

    bg = Image.new("RGB", (width, height), "#F7F7F7")
    draw = ImageDraw.Draw(bg)

    line_color = "#6F74D8"
    header_bg = "#7F83E6"
    header_text = "#FFFFFF"
    text_color = "#2E2E2E"
    light_box = "#EAEAEA"

    font_header = load_font(24, bold=True)
    font_body = load_font(22, bold=False)
    font_small = load_font(18, bold=False)
    font_tiny = load_font(16, bold=False)

    # 外框与标题栏
    draw.rectangle((margin, margin, width - margin, height - margin), outline=line_color, width=3)
    draw.rectangle((margin, margin, width - margin, margin + header_h), fill=header_bg, outline=line_color, width=2)

    headers = ["TOP10", "PIC", "QTY", "SALES", "活动款备注", "独家"]
    x = margin
    for idx, title in enumerate(headers):
        w = col_widths[idx]
        tw, th = measure_text(draw, title, font_header)
        draw.text((x + (w - tw) / 2, margin + (header_h - th) / 2 - 2), title, font=font_header, fill=header_text)
        x += w
        if idx < len(headers) - 1:
            draw.line((x, margin, x, height - margin), fill=line_color, width=2)

    # 数据行
    y0 = margin + header_h
    for row_idx in range(10):
        top = y0 + row_idx * row_h
        bottom = top + row_h
        draw.line((margin, bottom, width - margin, bottom), fill=line_color, width=1)

        record = top_rows[row_idx] if row_idx < len(top_rows) else None
        if not record:
            continue

        article = str(record.get("article") or "")
        qty = to_int(record.get("qty"))
        sales = to_decimal(record.get("sales"))
        memo = str(record.get("memo") or "")
        single = "Y" if str(record.get("single") or "").upper() == "Y" else ""

        # TOP10 列：article 编码 + 排名
        col0_left = margin
        col0_w = col_widths[0]
        rank_text = f"#{row_idx + 1}"
        draw.text((col0_left + 8, top + 6), rank_text, font=font_small, fill="#666666")
        draw.text((col0_left + 8, top + 34), article, font=font_body, fill=text_color)

        # PIC 列：按 article 从固定图片域名拉图，失败时再回落占位。
        col1_left = margin + col_widths[0]
        col1_w = col_widths[1]
        box = (
            col1_left + 10,
            top + 12,
            col1_left + col1_w - 10,
            bottom - 12,
        )
        draw.rectangle(box, fill=light_box, outline="#C8C8C8", width=1)
        image = load_article_image(article, image_cache_dir=image_cache_dir)
        if image is not None:
            box_w = box[2] - box[0]
            box_h = box[3] - box[1]
            scale = min(box_w / max(image.width, 1), box_h / max(image.height, 1))
            resized_w = max(1, int(image.width * scale))
            resized_h = max(1, int(image.height * scale))
            resized = image.resize((resized_w, resized_h), Image.Resampling.LANCZOS)
            paste_left = box[0] + (box_w - resized_w) // 2
            paste_top = box[1] + (box_h - resized_h) // 2
            bg.paste(resized, (paste_left, paste_top))
            draw.rectangle(box, outline="#C8C8C8", width=1)
        else:
            placeholder = "NO PIC"
            tw, th = measure_text(draw, placeholder, font_tiny)
            draw.text(
                (col1_left + (col1_w - tw) / 2, top + (row_h - th) / 2),
                placeholder,
                font=font_tiny,
                fill="#8A8A8A",
            )

        # 其余列：QTY / SALES / 活动款备注 / 独家
        col2_left = margin + col_widths[0] + col_widths[1]
        col3_left = col2_left + col_widths[2]
        col4_left = col3_left + col_widths[3]
        col5_left = col4_left + col_widths[4]

        draw.text((col2_left + 8, top + (row_h - 24) / 2), format_plain_int(qty), font=font_body, fill=text_color)
        draw.text((col3_left + 8, top + (row_h - 24) / 2), format_sales_trim_plain(sales), font=font_body, fill=text_color)
        draw.text((col4_left + 8, top + (row_h - 24) / 2), memo, font=font_body, fill=text_color)
        draw.text((col5_left + 26, top + (row_h - 24) / 2), single, font=font_body, fill=text_color)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    bg.save(output_path)


def nice_percent_axis_bounds(values_pct: list[float], *, floor_min: float = -10.0, ceil_max: float = 50.0) -> tuple[float, float]:
    """函数说明：封装nice_percent_axis_bounds方法的核心处理流程。"""
    if not values_pct:
        return floor_min, ceil_max
    min_v = min(values_pct)
    max_v = max(values_pct)
    lower = min(floor_min, np.floor((min_v - 2.0) / 5.0) * 5.0)
    upper = max(ceil_max, np.ceil((max_v + 2.0) / 5.0) * 5.0)
    if upper - lower < 20:
        upper = lower + 20
    return float(lower), float(upper)


def render_lfl_chart_image(
    *,
    output_path: Path,
    title: str,
    current_values: list[float],
    compare_values: list[float],
    lfl_values: list[float],
) -> None:
    """函数说明：封装render_lfl_chart_image方法的核心处理流程。"""
    categories = ["BAGS", "SHOES"]
    x = np.arange(len(categories))
    width = 0.32

    fig, ax1 = plt.subplots(figsize=(6.5, 3.5), dpi=220)
    fig.patch.set_facecolor("white")
    ax1.set_facecolor("white")

    color_cur = "#6E76E8"
    color_prev = "#F1B7DF"
    color_lfl_positive = "#FF2D2D"
    color_lfl_negative = "#00B050"
    color_lfl_zero = "#4A4A4A"

    bars_cur = ax1.bar(x - width / 2, current_values, width, color=color_cur, label="Y-26")
    bars_prev = ax1.bar(x + width / 2, compare_values, width, color=color_prev, label="Y-25")

    ax1.set_xticks(x)
    ax1.set_xticklabels(categories, fontsize=9)
    ax1.tick_params(axis="y", labelsize=8, colors="#6B6B6B")
    ax1.tick_params(axis="x", labelsize=8, colors="#6B6B6B")
    ax1.spines["top"].set_visible(False)
    ax1.spines["right"].set_visible(False)
    ax1.spines["left"].set_color("#D0D0D0")
    ax1.spines["bottom"].set_color("#D0D0D0")
    ax1.yaxis.grid(False)
    ax1.set_title(title, fontsize=12, fontweight="bold", color="#3F3F3F", pad=10)

    ax2 = ax1.twinx()
    pct_values = [item * 100.0 for item in lfl_values]
    lower, upper = nice_percent_axis_bounds(pct_values)
    ax2.set_ylim(lower, upper)
    ax2.tick_params(axis="y", labelsize=8, colors="#6B6B6B")
    ax2.spines["top"].set_visible(False)
    ax2.spines["left"].set_visible(False)
    ax2.spines["right"].set_color("#D0D0D0")

    lfl_colors = []
    for idx, pct in enumerate(pct_values):
        if pct > 0:
            point_color = color_lfl_positive
        elif pct < 0:
            point_color = color_lfl_negative
        else:
            point_color = color_lfl_zero
        lfl_colors.append(point_color)
    # 关键：仅画点，不画连线；正值红色，负值绿色。
    ax2.scatter(x, pct_values, color=lfl_colors, s=28, zorder=6, label="LFL")
    for idx, pct in enumerate(pct_values):
        label_color = lfl_colors[idx]
        ax2.text(
            x[idx] + 0.05,
            pct,
            f"{round(pct):.0f}%",
            color=label_color,
            fontsize=9,
            va="center",
            ha="left",
        )

    # 右侧百分比轴标签
    ticks = np.linspace(lower, upper, num=7)
    ax2.set_yticks(ticks)
    ax2.set_yticklabels([f"{int(round(v))}%" for v in ticks])

    legend_handles = [
        bars_cur[0],
        bars_prev[0],
        Line2D([0], [0], marker="o", color="w", markerfacecolor=color_lfl_positive, markersize=6, label="LFL"),
    ]
    ax1.legend(
        handles=legend_handles,
        labels=["Y-26", "Y-25", "LFL"],
        loc="lower center",
        bbox_to_anchor=(0.5, -0.28),
        ncol=3,
        frameon=False,
        fontsize=8,
        handlelength=1.2,
        columnspacing=0.8,
    )

    output_path.parent.mkdir(parents=True, exist_ok=True)
    fig.tight_layout()
    fig.savefig(output_path, facecolor="white")
    plt.close(fig)


def render_lfl_chart_image_with_line(
    *,
    output_path: Path,
    title: str,
    current_values: list[float],
    compare_values: list[float],
    lfl_values: list[float],
) -> None:
    """Render the S11 LFL combo chart with a connector line between BAGS and SHOES."""
    categories = ["BAGS", "SHOES"]
    x = np.arange(len(categories))
    width = 0.32

    fig, ax1 = plt.subplots(figsize=(6.5, 3.5), dpi=220)
    fig.patch.set_facecolor("white")
    ax1.set_facecolor("white")

    color_cur = "#6E76E8"
    color_prev = "#F1B7DF"
    color_lfl_positive = "#FF2D2D"
    color_lfl_negative = "#00B050"
    color_lfl_zero = "#4A4A4A"

    bars_cur = ax1.bar(x - width / 2, current_values, width, color=color_cur, label="Y-26")
    bars_prev = ax1.bar(x + width / 2, compare_values, width, color=color_prev, label="Y-25")

    ax1.set_xticks(x)
    ax1.set_xticklabels(categories, fontsize=9)
    ax1.tick_params(axis="y", labelsize=8, colors="#6B6B6B")
    ax1.tick_params(axis="x", labelsize=8, colors="#6B6B6B")
    ax1.spines["top"].set_visible(False)
    ax1.spines["right"].set_visible(False)
    ax1.spines["left"].set_color("#D0D0D0")
    ax1.spines["bottom"].set_color("#D0D0D0")
    ax1.yaxis.grid(False)
    ax1.set_title(title, fontsize=12, fontweight="bold", color="#3F3F3F", pad=10)

    ax2 = ax1.twinx()
    pct_values = [item * 100.0 for item in lfl_values]
    lower, upper = nice_percent_axis_bounds(pct_values)
    ax2.set_ylim(lower, upper)
    ax2.tick_params(axis="y", labelsize=8, colors="#6B6B6B")
    ax2.spines["top"].set_visible(False)
    ax2.spines["left"].set_visible(False)
    ax2.spines["right"].set_color("#D0D0D0")

    lfl_colors: list[str] = []
    for pct in pct_values:
        if pct > 0:
            lfl_colors.append(color_lfl_positive)
        elif pct < 0:
            lfl_colors.append(color_lfl_negative)
        else:
            lfl_colors.append(color_lfl_zero)

    if all(pct > 0 for pct in pct_values):
        lfl_line_color = color_lfl_positive
    elif all(pct < 0 for pct in pct_values):
        lfl_line_color = color_lfl_negative
    elif all(pct == 0 for pct in pct_values):
        lfl_line_color = color_lfl_zero
    else:
        lfl_line_color = "#7A7A7A"

    ax2.plot(x, pct_values, color=lfl_line_color, linewidth=1.2, zorder=5)
    ax2.scatter(x, pct_values, color=lfl_colors, s=28, zorder=6, label="LFL")
    for idx, pct in enumerate(pct_values):
        ax2.text(
            x[idx] + 0.05,
            pct,
            f"{round(pct):.0f}%",
            color=lfl_colors[idx],
            fontsize=9,
            va="center",
            ha="left",
        )

    ticks = np.linspace(lower, upper, num=7)
    ax2.set_yticks(ticks)
    ax2.set_yticklabels([f"{int(round(v))}%" for v in ticks])

    legend_handles = [
        bars_cur[0],
        bars_prev[0],
        Line2D(
            [0],
            [0],
            marker="o",
            color=color_lfl_positive,
            markerfacecolor=color_lfl_positive,
            markersize=6,
            linewidth=1.2,
            label="LFL",
        ),
    ]
    ax1.legend(
        handles=legend_handles,
        labels=["Y-26", "Y-25", "LFL"],
        loc="lower center",
        bbox_to_anchor=(0.5, -0.28),
        ncol=3,
        frameon=False,
        fontsize=8,
        handlelength=1.2,
        columnspacing=0.8,
    )

    output_path.parent.mkdir(parents=True, exist_ok=True)
    fig.tight_layout()
    fig.savefig(output_path, facecolor="white")
    plt.close(fig)


def run_sync(args: argparse.Namespace) -> dict[str, Any]:
    """函数说明：封装run_sync方法的核心处理流程。"""
    config_path = Path(args.config)
    config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    period = resolve_report_period(args, config)

    if "S11" not in {item.strip().upper() for item in args.slides.split(",") if item.strip()}:
        raise SystemExit("当前脚本只支持 S11，请传 --slides S11")

    mysql_cfg = config["mysql"]
    workdir = Path(args.output_dir).resolve() if args.output_dir else Path(config["paths"]["workdir"]).resolve()
    data_dir = workdir / "data" / "campaign-s11"
    data_dir.mkdir(parents=True, exist_ok=True)

    conn = pymysql.connect(
        host=mysql_cfg["host"],
        port=int(mysql_cfg["port"]),
        user=mysql_cfg["username"],
        password=mysql_cfg["password"],
        database=mysql_cfg["database"],
        charset="utf8mb4",
        cursorclass=pymysql.cursors.DictCursor,
    )

    try:
        with conn.cursor() as cursor:
            shop_rows = fetch_all(
                cursor,
                """
                SELECT article, qty, sales, level1, level3, memo, single, date, activitybegin, activityend
                FROM oms_shop_report
                WHERE date = %s
                """,
                (period.ym,),
            )
            compare_shop_rows = fetch_all(
                cursor,
                """
                SELECT article, qty, sales, level1, level3, memo, single, date
                FROM oms_shop_report
                WHERE date = %s
                """,
                (period.compare_ym,),
            )
            if not shop_rows:
                raise SystemExit(f"oms_shop_report 未找到 {period.ym} 数据。")

            raw_shop_rows = list(shop_rows)
            campaign_window = infer_campaign_window(raw_shop_rows, period)

            current_window_shop_rows = filter_shop_rows_by_window(
                raw_shop_rows,
                campaign_window.current_start,
                campaign_window.current_end,
            )
            if current_window_shop_rows:
                shop_rows = current_window_shop_rows

            compare_window_shop_rows = filter_shop_rows_by_window(
                compare_shop_rows,
                campaign_window.compare_start,
                campaign_window.compare_end,
            )
            if not compare_window_shop_rows:
                compare_window_shop_rows = filter_shop_rows_by_window(
                    raw_shop_rows,
                    campaign_window.compare_start,
                    campaign_window.compare_end,
                )
            if compare_window_shop_rows:
                compare_shop_rows = compare_window_shop_rows

            summary_cur = fetch_one(
                cursor,
                """
                SELECT
                  SUM(COALESCE(dailysales, 0)) AS sales,
                  SUM(COALESCE(uv, 0)) AS uv,
                  SUM(COALESCE(tran, 0)) AS tran,
                  SUM(COALESCE(qtysold, 0)) AS qty
                FROM oms_daily_report
                WHERE shopid = %s
                  AND daily BETWEEN %s AND %s
                """,
                (
                    args.shop_id,
                    campaign_window.current_start.strftime("%Y-%m-%d"),
                    campaign_window.current_end.strftime("%Y-%m-%d"),
                ),
            )
            summary_prev = fetch_one(
                cursor,
                """
                SELECT
                  SUM(COALESCE(dailysales, 0)) AS sales,
                  SUM(COALESCE(uv, 0)) AS uv,
                  SUM(COALESCE(tran, 0)) AS tran,
                  SUM(COALESCE(qtysold, 0)) AS qty
                FROM oms_daily_report
                WHERE shopid = %s
                  AND daily BETWEEN %s AND %s
                """,
                (
                    args.shop_id,
                    campaign_window.compare_start.strftime("%Y-%m-%d"),
                    campaign_window.compare_end.strftime("%Y-%m-%d"),
                ),
            )
            detail_cur_positive = fetch_all(
                cursor,
                """
                SELECT
                  articlecode,
                  level1,
                  level3,
                  level4,
                  SUM(CASE WHEN qty > 0 THEN qty ELSE 0 END) AS qty,
                  SUM(CASE WHEN sales > 0 THEN sales ELSE 0 END) AS sales
                FROM oms_daily_report_detail
                WHERE shopid = %s
                  AND daily BETWEEN %s AND %s
                GROUP BY articlecode, level1, level3, level4
                HAVING qty > 0 OR sales > 0
                """,
                (
                    args.shop_id,
                    campaign_window.current_start.strftime("%Y-%m-%d"),
                    campaign_window.current_end.strftime("%Y-%m-%d"),
                ),
            )
            detail_prev_positive = fetch_all(
                cursor,
                """
                SELECT
                  articlecode,
                  level1,
                  level3,
                  level4,
                  SUM(CASE WHEN qty > 0 THEN qty ELSE 0 END) AS qty,
                  SUM(CASE WHEN sales > 0 THEN sales ELSE 0 END) AS sales
                FROM oms_daily_report_detail
                WHERE shopid = %s
                  AND daily BETWEEN %s AND %s
                GROUP BY articlecode, level1, level3, level4
                HAVING qty > 0 OR sales > 0
                """,
                (
                    args.shop_id,
                    campaign_window.compare_start.strftime("%Y-%m-%d"),
                    campaign_window.compare_end.strftime("%Y-%m-%d"),
                ),
            )
    finally:
        conn.close()

    total_qty = 0
    total_sales = Decimal("0")
    activity_qty = 0
    activity_sales = Decimal("0")
    non_activity_only_qty = 0
    non_activity_only_sales = Decimal("0")
    ess_qty = 0
    ess_sales = Decimal("0")
    exclusive_qty = 0
    exclusive_sales = Decimal("0")

    level1_bucket: dict[str, dict[str, dict[str, Decimal | int]]] = {
        level1: {
            "activity": {"qty": 0, "sales": Decimal("0")},
            "ess": {"qty": 0, "sales": Decimal("0")},
            "non_activity": {"qty": 0, "sales": Decimal("0")},
            "ttl": {"qty": 0, "sales": Decimal("0")},
        }
        for level1 in LEVEL1_ORDER
    }
    current_table3_by_category: dict[str, dict[str, Decimal | int]] = {
        name: {"qty": 0, "sales": Decimal("0")} for name in TABLE3_CATEGORY_ORDER
    }
    by_memo: dict[str, dict[str, Any]] = {}
    top_rows: list[dict[str, Any]] = []
    current_article_to_cn_category: dict[str, str] = {}

    for row in shop_rows:
        qty = to_int(row.get("qty"))
        sales = to_decimal(row.get("sales"))
        memo = (row.get("memo") or "").strip()
        single = (row.get("single") or "").strip()
        level1 = (row.get("level1") or "").strip().upper()
        level3 = (row.get("level3") or "").strip()
        article = (row.get("article") or "").strip()

        total_qty += qty
        total_sales += sales

        bucket = by_memo.setdefault(
            memo or "<EMPTY>",
            {"rows": 0, "qty": 0, "sales": Decimal("0")},
        )
        bucket["rows"] += 1
        bucket["qty"] += qty
        bucket["sales"] += sales

        if level1 in level1_bucket:
            level1_bucket[level1]["ttl"]["qty"] += qty
            level1_bucket[level1]["ttl"]["sales"] += sales

        if memo in ACTIVITY_MEMOS:
            activity_qty += qty
            activity_sales += sales
            if level1 in level1_bucket:
                level1_bucket[level1]["activity"]["qty"] += qty
                level1_bucket[level1]["activity"]["sales"] += sales
        elif memo == ESS_MEMO:
            ess_qty += qty
            ess_sales += sales
            if level1 in level1_bucket:
                level1_bucket[level1]["ess"]["qty"] += qty
                level1_bucket[level1]["ess"]["sales"] += sales
        elif memo in NON_ACTIVITY_ONLY_MEMOS:
            non_activity_only_qty += qty
            non_activity_only_sales += sales
            if level1 in level1_bucket:
                level1_bucket[level1]["non_activity"]["qty"] += qty
                level1_bucket[level1]["non_activity"]["sales"] += sales

        if single == "Y":
            exclusive_qty += qty
            exclusive_sales += sales

        if level3 in current_table3_by_category:
            current_table3_by_category[level3]["qty"] += qty
            current_table3_by_category[level3]["sales"] += sales

        if article and level3:
            current_article_to_cn_category[article] = level3

        top_rows.append(
            {
                "article": article,
                "memo": memo,
                "single": single,
                "qty": qty,
                "sales": float(sales),
                "level1": level1,
                "level3": level3,
                "activitybegin": row.get("activitybegin"),
                "activityend": row.get("activityend"),
            }
        )

    top_rows_by_sales = sorted(top_rows, key=lambda item: item["sales"], reverse=True)[:20]
    top_rows = sorted(
        top_rows,
        key=lambda item: (item["qty"], item["sales"]),
        reverse=True,
    )[:20]
    top10_rows = top_rows[:10]

    asset_dir = workdir / "assets" / "campaign-s11"
    top10_image_path = asset_dir / f"s11-top10-{period.ym}.png"
    top10_image_cache_dir = asset_dir / "top10-images"
    render_top10_panel_image(top10_rows, top10_image_path, image_cache_dir=top10_image_cache_dir)
    qty_lfl_chart_image_path = asset_dir / f"s11-qty-lfl-{period.ym}.png"
    sales_lfl_chart_image_path = asset_dir / f"s11-sales-lfl-{period.ym}.png"

    summary_cur_sales = to_decimal(summary_cur.get("sales"))
    summary_cur_uv = to_decimal(summary_cur.get("uv"))
    summary_cur_tran = to_decimal(summary_cur.get("tran"))
    summary_cur_qty = to_decimal(summary_cur.get("qty"))

    summary_prev_sales = to_decimal(summary_prev.get("sales"))
    summary_prev_uv = to_decimal(summary_prev.get("uv"))
    summary_prev_tran = to_decimal(summary_prev.get("tran"))
    summary_prev_qty = to_decimal(summary_prev.get("qty"))

    cur_con = Decimal("0") if summary_cur_uv == 0 else summary_cur_tran / summary_cur_uv
    prev_con = Decimal("0") if summary_prev_uv == 0 else summary_prev_tran / summary_prev_uv
    cur_atv = Decimal("0") if summary_cur_tran == 0 else summary_cur_sales / summary_cur_tran
    prev_atv = Decimal("0") if summary_prev_tran == 0 else summary_prev_sales / summary_prev_tran

    # 文案中的“活动款及非活动款”口径延续现状：活动 vs (ESS + 非活动款)。
    non_activity_for_ratio_qty = max(total_qty - activity_qty, 0)
    non_activity_for_ratio_sales = total_sales - activity_sales
    activity_ratio_qty = ratio_string(activity_qty, non_activity_for_ratio_qty)
    activity_ratio_sales = ratio_string(
        round_half_up(activity_sales),
        round_half_up(non_activity_for_ratio_sales),
    )
    exclusive_ratio_qty = ratio_string(exclusive_qty, max(total_qty - exclusive_qty, 0))
    sales_10k = int(total_sales / Decimal("10000"))

    # 先构建同比期三级分类：优先 compare_ym 的 oms_shop_report；没有就走明细映射兜底。
    compare_table3_by_category: dict[str, dict[str, Decimal | int]] = {
        name: {"qty": 0, "sales": Decimal("0")} for name in TABLE3_CATEGORY_ORDER
    }
    table3_compare_source = "oms_shop_report"
    if compare_shop_rows:
        for row in compare_shop_rows:
            category = (row.get("level3") or "").strip()
            if category not in compare_table3_by_category:
                continue
            compare_table3_by_category[category]["qty"] += to_int(row.get("qty"))
            compare_table3_by_category[category]["sales"] += to_decimal(row.get("sales"))
    else:
        table3_compare_source = "oms_daily_report_detail_positive_with_mapping"
        # 当前期明细 article -> (level3, level4) 取销售最高组合作为映射锚点。
        current_detail_article_feature: dict[str, tuple[str, str, Decimal]] = {}
        for row in detail_cur_positive:
            article = (row.get("articlecode") or "").strip()
            level3 = (row.get("level3") or "").strip()
            level4 = (row.get("level4") or "").strip()
            sales = to_decimal(row.get("sales"))
            if not article:
                continue
            previous = current_detail_article_feature.get(article)
            if previous is None or sales > previous[2]:
                current_detail_article_feature[article] = (level3, level4, sales)

        # 构建 level3+level4 以及 level3 的票选映射，限制到表3可展示的中文类目。
        combo_votes: dict[tuple[str, str], dict[str, Decimal]] = {}
        level3_votes: dict[str, dict[str, Decimal]] = {}
        for article, category in current_article_to_cn_category.items():
            if category not in TABLE3_CATEGORY_SET:
                continue
            feature = current_detail_article_feature.get(article)
            if feature is None:
                continue
            level3, level4, sales = feature
            combo_votes.setdefault((level3, level4), {}).setdefault(category, Decimal("0"))
            combo_votes[(level3, level4)][category] += sales
            level3_votes.setdefault(level3, {}).setdefault(category, Decimal("0"))
            level3_votes[level3][category] += sales

        combo_to_cn: dict[tuple[str, str], str] = {}
        for key, vote in combo_votes.items():
            category = max(vote.items(), key=lambda item: item[1])[0]
            combo_to_cn[key] = category

        level3_to_cn: dict[str, str] = {}
        for level3, vote in level3_votes.items():
            category = max(vote.items(), key=lambda item: item[1])[0]
            level3_to_cn[level3] = category

        for row in detail_prev_positive:
            article = (row.get("articlecode") or "").strip()
            level3 = (row.get("level3") or "").strip()
            level4 = (row.get("level4") or "").strip()
            qty = to_int(row.get("qty"))
            sales = to_decimal(row.get("sales"))
            category = choose_cn_category_for_detail(
                articlecode=article,
                level3=level3,
                level4=level4,
                article_to_cn=current_article_to_cn_category,
                combo_to_cn=combo_to_cn,
                level3_to_cn=level3_to_cn,
            )
            compare_table3_by_category[category]["qty"] += qty
            compare_table3_by_category[category]["sales"] += sales

    # 图表与文案都依赖 bags/shoes 的同比。
    current_bags_qty = sum(
        to_int(current_table3_by_category[name]["qty"]) for name in TABLE3_BAGS_CATEGORY_SET if name in current_table3_by_category
    )
    current_shoes_qty = sum(
        to_int(current_table3_by_category[name]["qty"]) for name in TABLE3_SHOES_CATEGORY_SET if name in current_table3_by_category
    )
    compare_bags_qty = sum(
        to_int(compare_table3_by_category[name]["qty"]) for name in TABLE3_BAGS_CATEGORY_SET if name in compare_table3_by_category
    )
    compare_shoes_qty = sum(
        to_int(compare_table3_by_category[name]["qty"]) for name in TABLE3_SHOES_CATEGORY_SET if name in compare_table3_by_category
    )
    current_bags_sales = sum(
        to_decimal(current_table3_by_category[name]["sales"]) for name in TABLE3_BAGS_CATEGORY_SET if name in current_table3_by_category
    )
    current_shoes_sales = sum(
        to_decimal(current_table3_by_category[name]["sales"]) for name in TABLE3_SHOES_CATEGORY_SET if name in current_table3_by_category
    )
    compare_bags_sales = sum(
        to_decimal(compare_table3_by_category[name]["sales"]) for name in TABLE3_BAGS_CATEGORY_SET if name in compare_table3_by_category
    )
    compare_shoes_sales = sum(
        to_decimal(compare_table3_by_category[name]["sales"]) for name in TABLE3_SHOES_CATEGORY_SET if name in compare_table3_by_category
    )

    shoes_sales_lfl_pct = round_half_up(safe_ratio(current_shoes_sales, compare_shoes_sales) * Decimal("100"))
    sales_lfl_pct = round_half_up(safe_ratio(summary_cur_sales, summary_prev_sales) * Decimal("100"))
    atv_lfl_pct = round_half_up(safe_ratio(cur_atv, prev_atv) * Decimal("100"))

    strict_expected = (
        period.report_year == 2026
        and period.month_num == 1
        and not args.disable_strict_january_check
    )
    strict_check = {
        "enabled": strict_expected,
        "passed": True,
        "errors": [],
    }
    if strict_expected:
        # 一月模板对齐要求“硬失败”：关键口径不一致直接中断，不继续渲染。
        if sales_10k != JAN_TEMPLATE_EXPECTED["sales_10k"]:
            strict_check["passed"] = False
            strict_check["errors"].append(
                f"sales_10k expected {JAN_TEMPLATE_EXPECTED['sales_10k']} but got {sales_10k}"
            )
        if activity_ratio_qty != JAN_TEMPLATE_EXPECTED["activity_ratio_qty"]:
            strict_check["passed"] = False
            strict_check["errors"].append(
                "activity_ratio_qty expected "
                f"{JAN_TEMPLATE_EXPECTED['activity_ratio_qty']} but got {activity_ratio_qty}"
            )
        if not strict_check["passed"]:
            raise SystemExit(
                "S11 一月模板强校验未通过: " + "; ".join(strict_check["errors"])
            )

    # S11 主叙述文本按模板句式替换关键数字，保持页面视觉风格不变。
    narrative_template = load_s11_narrative_template(workdir)
    narrative_text = rewrite_s11_narrative(
        narrative_template,
        sales_10k=sales_10k,
        sales_lfl_pct=sales_lfl_pct,
        atv_lfl_pct=atv_lfl_pct,
        activity_ratio_qty=activity_ratio_qty,
        shoes_sales_lfl_pct=shoes_sales_lfl_pct,
    )

    # 表1（Summary KPI）
    table1_rows = [
        [
            campaign_window.label,
            "SALES",
            "UV",
            "CON%",
            "TRAN",
            "ATV",
            "QTY",
        ],
        [
            f"Y-{period.report_year % 100:02d}",
            format_sales_2_with_comma(summary_cur_sales),
            format_int_with_comma(round_half_up(summary_cur_uv)),
            f"{(cur_con * Decimal('100')).quantize(Decimal('0.01'))}%",
            format_int_with_comma(round_half_up(summary_cur_tran)),
            format_plain_int(round_half_up(cur_atv)),
            format_int_with_comma(round_half_up(summary_cur_qty)),
        ],
        [
            f"Y-{period.compare_year % 100:02d}",
            format_sales_2_with_comma(summary_prev_sales),
            format_int_with_comma(round_half_up(summary_prev_uv)),
            f"{(prev_con * Decimal('100')).quantize(Decimal('0.01'))}%",
            format_int_with_comma(round_half_up(summary_prev_tran)),
            format_plain_int(round_half_up(prev_atv)),
            format_int_with_comma(round_half_up(summary_prev_qty)),
        ],
        [
            "LFL",
            ratio_percent_text(summary_cur_sales, summary_prev_sales),
            ratio_percent_text(summary_cur_uv, summary_prev_uv),
            ratio_percent_text(cur_con, prev_con),
            ratio_percent_text(summary_cur_tran, summary_prev_tran),
            ratio_percent_text(cur_atv, prev_atv),
            ratio_percent_text(summary_cur_qty, summary_prev_qty),
        ],
    ]

    # 表2（Type split）
    table2_rows = [
        [campaign_window.label, "Type", "qty", "qty%", "sales", "sales%"],
    ]
    show_ess_rows = ess_qty > 0 or ess_sales > 0
    for level1 in LEVEL1_ORDER:
        level_data = level1_bucket[level1]
        activity_qty_val = int(level_data["activity"]["qty"])
        activity_sales_val = to_decimal(level_data["activity"]["sales"])
        ess_qty_val = int(level_data["ess"]["qty"])
        ess_sales_val = to_decimal(level_data["ess"]["sales"])
        non_qty_val = int(level_data["non_activity"]["qty"])
        non_sales_val = to_decimal(level_data["non_activity"]["sales"])
        ttl_qty_val = int(level_data["ttl"]["qty"])
        ttl_sales_val = to_decimal(level_data["ttl"]["sales"])

        table2_rows.append(
            [
                level1,
                "活动款",
                format_int_with_comma(activity_qty_val),
                pct_of_total_text(Decimal(activity_qty_val), Decimal(total_qty)),
                format_sales_int_with_comma(activity_sales_val),
                pct_of_total_text(activity_sales_val, total_sales),
            ]
        )
        if show_ess_rows:
            table2_rows.append(
                [
                    level1,
                    "ESS",
                    format_int_with_comma(ess_qty_val),
                    pct_of_total_text(Decimal(ess_qty_val), Decimal(total_qty)),
                    format_sales_int_with_comma(ess_sales_val),
                    pct_of_total_text(ess_sales_val, total_sales),
                ]
            )
        table2_rows.append(
            [
                level1,
                "非活动款",
                format_int_with_comma(non_qty_val),
                pct_of_total_text(Decimal(non_qty_val), Decimal(total_qty)),
                format_sales_int_with_comma(non_sales_val),
                pct_of_total_text(non_sales_val, total_sales),
            ]
        )
        if not show_ess_rows:
            # Keep TTL on the last visible row of the level1 block when ESS is absent.
            table2_rows.append([level1, "", "", "", "", ""])
        table2_rows.append(
            [
                level1,
                "TTL",
                format_int_with_comma(ttl_qty_val),
                pct_of_total_text(Decimal(ttl_qty_val), Decimal(total_qty)),
                format_sales_int_with_comma(ttl_sales_val),
                pct_of_total_text(ttl_sales_val, total_sales),
            ]
        )

    table2_rows.append(
        [
            "Grandtotal  ",
            "Grandtotal  ",
            format_int_with_comma(total_qty),
            "100%",
            format_sales_int_with_comma(total_sales),
            "100%",
        ]
    )
    table2_rows.append(
        [
            "活动款",
            "活动款",
            format_plain_int(activity_qty),
            pct_of_total_text(Decimal(activity_qty), Decimal(total_qty)),
            format_sales_2_plain(activity_sales),
            pct_of_total_text(activity_sales, total_sales),
        ]
    )
    if show_ess_rows:
        table2_rows.append(
            [
                "ESS",
                "ESS",
                format_plain_int(ess_qty),
                pct_of_total_text(Decimal(ess_qty), Decimal(total_qty)),
                format_sales_2_plain(ess_sales),
                pct_of_total_text(ess_sales, total_sales),
            ]
        )
    table2_rows.append(
        [
            "非活动款",
            "非活动款",
            format_plain_int(non_activity_only_qty),
            pct_of_total_text(Decimal(non_activity_only_qty), Decimal(total_qty)),
            format_sales_2_plain(non_activity_only_sales),
            pct_of_total_text(non_activity_only_sales, total_sales),
        ]
    )
    if not show_ess_rows:
        # Leave the last summary item anchored to the template's bottom row.
        table2_rows.append(["", "", "", "", "", ""])
    table2_rows.append(
        [
            "独家款",
            "独家款",
            format_plain_int(exclusive_qty),
            pct_of_total_text(Decimal(exclusive_qty), Decimal(total_qty)),
            format_sales_2_plain(exclusive_sales),
            pct_of_total_text(exclusive_sales, total_sales),
        ]
    )
    while len(table2_rows) < S11_TABLE2_TOTAL_ROWS:
        table2_rows.append(["", "", "", "", "", ""])

    # 表3（三级分类）
    table3_rows = [
        ["三级分类", "Y-26 QTY", "Y-25 QTY", "QTY LFL", "Y-26 SALES", "Y-25 SALES", "SALES LFL"],
    ]
    for category in TABLE3_CATEGORY_ORDER:
        current_qty = Decimal(to_int(current_table3_by_category[category]["qty"]))
        current_sales = to_decimal(current_table3_by_category[category]["sales"])
        compare_qty = Decimal(to_int(compare_table3_by_category[category]["qty"]))
        compare_sales = to_decimal(compare_table3_by_category[category]["sales"])

        table3_rows.append(
            [
                category,
                format_plain_int(round_half_up(current_qty)),
                format_plain_int(round_half_up(compare_qty)),
                ratio_percent_text(current_qty, compare_qty),
                format_sales_trim_plain(current_sales),
                format_sales_trim_plain(compare_sales),
                ratio_percent_text(current_sales, compare_sales),
            ]
        )

    current_table3_total_qty = sum(
        Decimal(to_int(current_table3_by_category[name]["qty"])) for name in TABLE3_CATEGORY_ORDER
    )
    current_table3_total_sales = sum(
        to_decimal(current_table3_by_category[name]["sales"]) for name in TABLE3_CATEGORY_ORDER
    )
    compare_table3_total_qty = sum(
        Decimal(to_int(compare_table3_by_category[name]["qty"])) for name in TABLE3_CATEGORY_ORDER
    )
    compare_table3_total_sales = sum(
        to_decimal(compare_table3_by_category[name]["sales"]) for name in TABLE3_CATEGORY_ORDER
    )

    table3_rows.append(
        [
            "TTL",
            format_plain_int(round_half_up(current_table3_total_qty)),
            format_plain_int(round_half_up(compare_table3_total_qty)),
            ratio_percent_text(current_table3_total_qty, compare_table3_total_qty),
            format_sales_trim_plain(current_table3_total_sales),
            format_sales_trim_plain(compare_table3_total_sales),
            ratio_percent_text(current_table3_total_sales, compare_table3_total_sales),
        ]
    )

    qty_chart_series = [
        {
            "index": 1,
            "name": "",
            "x_values": ["BAGS", "SHOES"],
            "values": [current_bags_qty, current_shoes_qty],
        },
        {
            "index": 2,
            "name": "",
            "x_values": ["BAGS", "SHOES"],
            "values": [compare_bags_qty, compare_shoes_qty],
        },
        {
            "index": 3,
            "name": "",
            "values": [
                float(safe_ratio(Decimal(current_bags_qty), Decimal(compare_bags_qty))),
                float(safe_ratio(Decimal(current_shoes_qty), Decimal(compare_shoes_qty))),
            ],
        },
    ]
    sales_chart_series = [
        {
            "index": 1,
            "name": "",
            "x_values": ["BAGS", "SHOES"],
            "values": [float(current_bags_sales), float(current_shoes_sales)],
        },
        {
            "index": 2,
            "name": "",
            "x_values": ["BAGS", "SHOES"],
            "values": [float(compare_bags_sales), float(compare_shoes_sales)],
        },
        {
            "index": 3,
            "name": "",
            "values": [
                float(safe_ratio(current_bags_sales, compare_bags_sales)),
                float(safe_ratio(current_shoes_sales, compare_shoes_sales)),
            ],
        },
    ]

    # 图表在 COM 下直接写 Series 会受限，改为按数据库值生成图片替换。
    render_lfl_chart_image_with_line(
        output_path=qty_lfl_chart_image_path,
        title="QTY LFL",
        current_values=[float(current_bags_qty), float(current_shoes_qty)],
        compare_values=[float(compare_bags_qty), float(compare_shoes_qty)],
        lfl_values=[
            float(safe_ratio(Decimal(current_bags_qty), Decimal(compare_bags_qty))),
            float(safe_ratio(Decimal(current_shoes_qty), Decimal(compare_shoes_qty))),
        ],
    )
    render_lfl_chart_image_with_line(
        output_path=sales_lfl_chart_image_path,
        title="SALES LFL",
        current_values=[float(current_bags_sales), float(current_shoes_sales)],
        compare_values=[float(compare_bags_sales), float(compare_shoes_sales)],
        lfl_values=[
            float(safe_ratio(current_bags_sales, compare_bags_sales)),
            float(safe_ratio(current_shoes_sales, compare_shoes_sales)),
        ],
    )

    operations = {
        "replace_text": [
            {
                "slide": S11_NARRATIVE_SHAPE["slide"],
                "shape_id": S11_NARRATIVE_SHAPE["shape_id"],
                "shape_name": S11_NARRATIVE_SHAPE["shape_name"],
                "new_text": narrative_text,
            }
        ],
        "replace_tables": [
            {
                "slide": S11_TABLE1_SHAPE["slide"],
                "shape_id": S11_TABLE1_SHAPE["shape_id"],
                "shape_name": S11_TABLE1_SHAPE["shape_name"],
                "cells": build_table_cells(table1_rows),
            },
            {
                "slide": S11_TABLE2_SHAPE["slide"],
                "shape_id": S11_TABLE2_SHAPE["shape_id"],
                "shape_name": S11_TABLE2_SHAPE["shape_name"],
                "cells": build_table_cells(table2_rows),
            },
            {
                "slide": S11_TABLE3_SHAPE["slide"],
                "shape_id": S11_TABLE3_SHAPE["shape_id"],
                "shape_name": S11_TABLE3_SHAPE["shape_name"],
                "cells": build_table_cells(table3_rows),
            },
        ],
        "replace_charts": [],
        "replace_images": [
            {
                "slide": S11_CHART_QTY_SHAPE["slide"],
                "shape_id": S11_CHART_QTY_SHAPE["shape_id"],
                "shape_name": S11_CHART_QTY_SHAPE["shape_name"],
                "image_path": str(qty_lfl_chart_image_path),
            },
            {
                "slide": S11_CHART_SALES_SHAPE["slide"],
                "shape_id": S11_CHART_SALES_SHAPE["shape_id"],
                "shape_name": S11_CHART_SALES_SHAPE["shape_name"],
                "image_path": str(sales_lfl_chart_image_path),
            },
            {
                "slide": S11_TOP10_SHAPE["slide"],
                "shape_id": S11_TOP10_SHAPE["shape_id"],
                "shape_name": S11_TOP10_SHAPE["shape_name"],
                "image_path": str(top10_image_path),
            }
        ],
    }

    summary = {
        "shop_id": args.shop_id,
        "month": period.ym,
        "compare_month": period.compare_ym,
        "campaign_window": {
            "current_start": campaign_window.current_start.strftime("%Y-%m-%d"),
            "current_end": campaign_window.current_end.strftime("%Y-%m-%d"),
            "compare_start": campaign_window.compare_start.strftime("%Y-%m-%d"),
            "compare_end": campaign_window.compare_end.strftime("%Y-%m-%d"),
            "label": campaign_window.label,
        },
        "shop_report": {
            "rows": len(shop_rows),
            "compare_rows": len(compare_shop_rows),
            "total_qty": total_qty,
            "total_sales": float(total_sales),
            "sales_10k_floor": sales_10k,
            "activity_qty": activity_qty,
            "activity_sales": float(activity_sales),
            "non_activity_qty_for_ratio": non_activity_for_ratio_qty,
            "non_activity_sales_for_ratio": float(non_activity_for_ratio_sales),
            "non_activity_only_qty": non_activity_only_qty,
            "non_activity_only_sales": float(non_activity_only_sales),
            "ess_qty": ess_qty,
            "ess_sales": float(ess_sales),
            "show_ess_rows": show_ess_rows,
            "exclusive_qty": exclusive_qty,
            "exclusive_sales": float(exclusive_sales),
            "activity_ratio_qty": activity_ratio_qty,
            "activity_ratio_sales": activity_ratio_sales,
            "exclusive_ratio_qty": exclusive_ratio_qty,
            "by_memo": {
                memo: {
                    "rows": bucket["rows"],
                    "qty": bucket["qty"],
                    "sales": float(bucket["sales"]),
                }
                for memo, bucket in sorted(by_memo.items(), key=lambda item: item[0])
            },
            "top10_generated_image": str(top10_image_path),
            "top_rank_basis": "qty_desc_sales_desc",
            "top10_articles_by_qty": top10_rows,
            "top20_articles_by_qty": top_rows,
            "top10_articles_by_sales": top_rows_by_sales[:10],
            "top20_articles_by_sales": top_rows_by_sales,
        },
        "daily_report_summary": {
            "current": {
                "sales": float(summary_cur_sales),
                "uv": float(summary_cur_uv),
                "tran": round_half_up(summary_cur_tran),
                "qty": round_half_up(summary_cur_qty),
                "con_ratio": float(cur_con),
                "atv": float(cur_atv),
            },
            "compare": {
                "sales": float(summary_prev_sales),
                "uv": float(summary_prev_uv),
                "tran": round_half_up(summary_prev_tran),
                "qty": round_half_up(summary_prev_qty),
                "con_ratio": float(prev_con),
                "atv": float(prev_atv),
            },
            "lfl_percent": {
                "sales": sales_lfl_pct,
                "uv": round_half_up(safe_ratio(summary_cur_uv, summary_prev_uv) * Decimal("100")),
                "con": round_half_up(safe_ratio(cur_con, prev_con) * Decimal("100")),
                "tran": round_half_up(safe_ratio(summary_cur_tran, summary_prev_tran) * Decimal("100")),
                "atv": atv_lfl_pct,
                "qty": round_half_up(safe_ratio(summary_cur_qty, summary_prev_qty) * Decimal("100")),
            },
        },
        "table3_compare_source": table3_compare_source,
        "table3_category_compare": {
            name: {
                "current_qty": to_int(current_table3_by_category[name]["qty"]),
                "current_sales": float(to_decimal(current_table3_by_category[name]["sales"])),
                "compare_qty": to_int(compare_table3_by_category[name]["qty"]),
                "compare_sales": float(to_decimal(compare_table3_by_category[name]["sales"])),
            }
            for name in TABLE3_CATEGORY_ORDER
        },
        "chart_inputs": {
            "bags_qty": {"current": current_bags_qty, "compare": compare_bags_qty},
            "shoes_qty": {"current": current_shoes_qty, "compare": compare_shoes_qty},
            "bags_sales": {"current": float(current_bags_sales), "compare": float(compare_bags_sales)},
            "shoes_sales": {"current": float(current_shoes_sales), "compare": float(compare_shoes_sales)},
        },
        "strict_template_check": strict_check,
    }

    manifest_path = data_dir / f"s11-campaign-oms-shop-report.{period.ym}.json"
    manifest_path.write_text(
        json.dumps(
            {
                "source": {
                    "config_path": str(config_path),
                    "database": mysql_cfg["database"],
                    "tables": [
                        "oms_shop_report",
                        "oms_daily_report",
                        "oms_daily_report_detail",
                    ],
                    "period": {
                        "month_label": period.month_label,
                        "year": period.report_year,
                        "compare_year": period.compare_year,
                        "ym": period.ym,
                        "compare_ym": period.compare_ym,
                    },
                },
                "summary": summary,
                "narrative_preview": narrative_text,
            },
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )

    operations_path = workdir / "render-ops.campaign-s11.live.json"
    operations_path.write_text(
        json.dumps(operations, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

    result = {
        "operations_path": str(operations_path),
        "manifest_path": str(manifest_path),
        "summary": summary,
    }
    return result


def main() -> None:
    """函数说明：脚本主入口，负责串联参数解析与执行流程。"""
    args = parse_args()
    result = run_sync(args)
    print(json.dumps(result, ensure_ascii=False))


# 关键行注释：脚本直接运行时，从这里进入主流程。
if __name__ == "__main__":
    main()
