#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""产品系列资料补齐 V03：项目级甘特工作台 + 项目标准日期填写表
每个任务按具体项目列示：冻融/加速/效期/运输/系列建档/DG/NA
项目后贴标签：核心缺失项 / 建档类 / 专项参数
"""
import base64
import datetime as dt
import json
import math
import os
import sys

import openpyxl
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter

sys.path.insert(0, '/Users/liuxinyuan/Desktop/Hermes输出-工作类/脚本')
import brand_kit as BK

ROOT = '/Users/liuxinyuan/Desktop/Hermes输出-工作类'
SRC = os.path.join(ROOT, '销售分析', '产品系列资料归档与缺口清单_v3_2026-10-26.xlsx')
OUT_HTML = os.path.join(ROOT, '销售分析', '产品系列资料补齐_项目级甘特工作台_20261030_V03.html')
OUT_XLSX = os.path.join(ROOT, '经营分析', '产品系列资料补齐_项目标准日期填写表_20261030_V03.xlsx')

START = '2026-11-01'
WINDOW_END = '2027-01-31'
HOLIDAY = '2027-01-01'

ITEMS = [
    {'key': 'freeze', 'name': '冻融稳定性', 'type': '核心缺失项', 'default': 2},
    {'key': 'accel', 'name': '加速稳定性', 'type': '核心缺失项', 'default': 2},
    {'key': 'shelf', 'name': '效期/长期/实时稳定性', 'type': '核心缺失项', 'default': 2},
    {'key': 'transport', 'name': '运输/物流稳定性', 'type': '核心缺失项', 'default': 2},
    {'key': 'archive', 'name': '系列档案建档', 'type': '建档类', 'default': 2},
    {'key': 'dg', 'name': 'DG可冻干专项参数', 'type': '专项参数', 'default': 2},
    {'key': 'na', 'name': 'NA低背景专项参数', 'type': '专项参数', 'default': 2},
]
ITEM_BY_NAME = {x['name']: x['key'] for x in ITEMS}
ITEM_BY_KEY = {x['key']: x for x in ITEMS}
RESOURCE_NAMES = ['研发-占位A', '研发-占位B', '研发-占位C', '产品-占位D', '产品-占位E', '研发-占位F', '产品-占位G', '资源-占位H']


def load_tasks():
    ws = openpyxl.load_workbook(SRC, data_only=True)['系列缺口任务清单']
    tasks = []
    for r in ws.iter_rows(min_row=2, values_only=True):
        if not r[0]:
            continue
        missing = [x.strip() for x in str(r[9] or '').split('、') if x.strip() and x.strip() != '无']
        special = str(r[10] or '')
        keys = []
        for m in missing:
            if m in ITEM_BY_NAME:
                keys.append(ITEM_BY_NAME[m])
        if int(r[8] or 0) == 0:
            keys.append('archive')
        if 'DG' in special:
            keys.append('dg')
        if 'NA' in special:
            keys.append('na')
        # de-dup, keep order
        keys = list(dict.fromkeys(keys))
        tasks.append({
            'id': str(r[0]), 'rep': str(r[1] or ''), 'series': str(r[2] or ''),
            'priority': str(r[11] or 'P2'), 'sales': float(r[5] or 0),
            'customers': int(r[6] or 0), 'docs': int(r[8] or 0),
            'missing': str(r[9] or ''), 'special': special,
            'action': str(r[12] or ''), 'n_items': len(keys), 'item_keys': keys,
        })
    return tasks


def item_days(p):
    return {x['key']: float(p.get(x['key'], 2)) for x in ITEMS}


def calc(t, p):
    days = item_days(p)
    base = sum(days[k] for k in t['item_keys'])
    buf_raw = base * float(p.get('buffer', 30)) / 100.0
    buffer = int(math.ceil(buf_raw - 1e-9))
    total = int(math.ceil(base + buf_raw - 1e-9))
    return {'base': round(base, 2), 'buffer': buffer, 'total': total}


def workdays(start, count):
    d = dt.date.fromisoformat(start)
    out = []
    while len(out) < count:
        if d.weekday() < 5 and d.isoformat() != HOLIDAY:
            out.append(d)
        d += dt.timedelta(days=1)
    return out


def schedule(tasks, p, resources):
    cal = workdays(p.get('start', START), 900)
    res = RESOURCE_NAMES[:int(resources)]
    free = {r: 0 for r in res}
    order = {'P0': 0, 'P1': 1, 'P2': 2}
    out = []
    for t in sorted(tasks, key=lambda x: (order.get(x['priority'], 9), -x['sales'])):
        c = calc(t, p)
        r = min(res, key=lambda x: (free[x], res.index(x)))
        s = free[r]
        e = s + c['total'] - 1
        row = dict(t)
        row.update(c)
        row.update({'owner': r, 'start': cal[s].isoformat(), 'end': cal[e].isoformat(),
                    's': s, 'e': e, 'window_status': '超窗' if cal[e].isoformat() > WINDOW_END else '窗口内'})
        out.append(row)
        free[r] = e + 1
    return out, cal


def build_xlsx(tasks, p):
    counts = {x['key']: sum(1 for t in tasks if x['key'] in t['item_keys']) for x in ITEMS}
    wb = openpyxl.Workbook()
    H = Font(name='Arial', bold=True, color='FFFFFF', size=10)
    HF = PatternFill('solid', fgColor='1F4E79')
    label_fill = PatternFill('solid', fgColor='FCE4D6')
    input_fill = PatternFill('solid', fgColor='FFF2CC')
    core_fill = PatternFill('solid', fgColor='DDEBF7')
    archive_fill = PatternFill('solid', fgColor='E2EFDA')
    special_fill = PatternFill('solid', fgColor='FCE4D6')
    warn_fill = PatternFill('solid', fgColor='F8CBAD')
    ok_fill = PatternFill('solid', fgColor='C6EFCE')

    def header(ws, n):
        for c in ws[1][:n]:
            c.font = H; c.fill = HF; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
        ws.freeze_panes = 'A2'; ws.sheet_view.showGridLines = False

    # 1 项目标准日期
    ws = wb.active; ws.title = '项目标准日期'
    ws.append(['项目名称', '类型标签', '适用任务数', '标准人日（天/项）', '填写说明'])
    for it in ITEMS:
        ws.append([it['name'], it['type'], counts[it['key']], it['default'], '请填你确认的标准人日；当前2D为占位值'])
    header(ws, 5)
    for i in range(2, 2 + len(ITEMS)):
        ws.cell(i, 4).fill = input_fill
        ws.cell(i, 4).font = Font(bold=True, size=11)
        ws.cell(i, 2).fill = core_fill if ws.cell(i, 2).value == '核心缺失项' else (archive_fill if ws.cell(i, 2).value == '建档类' else special_fill)
        for c in ws[i]:
            c.alignment = Alignment(vertical='center', wrap_text=True)
    for i, w in enumerate([24, 12, 10, 17, 45], 1):
        ws.column_dimensions[get_column_letter(i)].width = w

    # 2 任务项目明细
    ws2 = wb.create_sheet('任务项目明细')
    ws2.append(['任务ID', '优先级', '代表货号', '产品系列', '具体项目（含标签）', '项目数', '当前基准人日', '当前计划工期'])
    for t in tasks:
        names = []
        for k in t['item_keys']:
            it = ITEM_BY_KEY[k]
            names.append(f"{it['name']}[{it['type']}]")
        c = calc(t, p)
        ws2.append([t['id'], t['priority'], t['rep'], t['series'], '；'.join(names), t['n_items'], c['base'], c['total']])
    header(ws2, 8)
    for row in ws2.iter_rows(min_row=2, max_row=ws2.max_row):
        for c in row:
            c.alignment = Alignment(vertical='top', wrap_text=True)
        row[4].fill = label_fill
    for i, w in enumerate([8, 7, 11, 28, 72, 7, 12, 12], 1):
        ws2.column_dimensions[get_column_letter(i)].width = w

    # 3 项目出现矩阵
    ws3 = wb.create_sheet('项目出现矩阵')
    ws3.append(['任务ID', '代表货号'] + [it['name'] for it in ITEMS] + ['项目数'])
    for t in tasks:
        ws3.append([t['id'], t['rep']] + [1 if it['key'] in t['item_keys'] else 0 for it in ITEMS] + [t['n_items']])
    header(ws3, 2 + len(ITEMS) + 1)
    for i, w in enumerate([8, 11] + [11] * len(ITEMS) + [7], 1):
        ws3.column_dimensions[get_column_letter(i)].width = w

    # 4 甘特预览（当前2D占位）
    ws4 = wb.create_sheet('甘特预览_2D占位')
    sched, _ = schedule(tasks, p, int(p.get('resources', 5)))
    ws4.append(['任务ID', '优先级', '代表货号', '产品系列', '具体项目', '基准人日', '容错人日', '计划工期', '负责人', '开始', '完成', '窗口状态'])
    for t in sched:
        ws4.append([t['id'], t['priority'], t['rep'], t['series'],
                    '；'.join(f"{ITEM_BY_KEY[k]['name']}[{ITEM_BY_KEY[k]['type']}]" for k in t['item_keys']),
                    t['base'], t['buffer'], t['total'], t['owner'], t['start'], t['end'], t['window_status']])
    header(ws4, 12)
    for row in ws4.iter_rows(min_row=2, max_row=ws4.max_row):
        for c in row:
            c.alignment = Alignment(vertical='top', wrap_text=True)
        row[11].fill = ok_fill if row[11].value == '窗口内' else warn_fill
    for i, w in enumerate([8, 7, 11, 28, 72, 9, 9, 9, 14, 11, 11, 9], 1):
        ws4.column_dimensions[get_column_letter(i)].width = w

    # 5 优先级汇总
    ws5 = wb.create_sheet('优先级汇总')
    ws5.append(['优先级', '任务数', '基准人日', '容错人日', '计划人日', '占计划%', '最早开始', '最晚完成', '任务ID'])
    all_total = sum(t['total'] for t in sched)
    for pr in ['P0', 'P1', 'P2', 'P3']:
        rows = [t for t in sched if t['priority'] == pr]
        base = sum(t['base'] for t in rows); buf = sum(t['buffer'] for t in rows); tot = sum(t['total'] for t in rows)
        ws5.append([pr, len(rows), base, buf, tot, round(tot / all_total * 100, 1) if all_total else 0,
                    min((t['start'] for t in rows), default='—'), max((t['end'] for t in rows), default='—'),
                    '、'.join(t['id'] for t in rows) or '—'])
    header(ws5, 9)
    for row in ws5.iter_rows(min_row=2, max_row=ws5.max_row):
        for c in row:
            c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
    for i, w in enumerate([8, 8, 10, 10, 10, 9, 11, 11, 60], 1):
        ws5.column_dimensions[get_column_letter(i)].width = w

    # 6 说明
    ws5 = wb.create_sheet('填写说明')
    notes = [
        ['用途', '这是项目级标准日期填写表；请先在“项目标准日期”填7个项目的人日，再据此重算甘特。'],
        ['项目标签', '核心缺失项=冻融/加速/效期/运输；建档类=系列档案建档；专项参数=DG可冻干/NA低背景。'],
        ['任务拆解', '每个任务的具体项目在“任务项目明细”逐行列示，不再汇总成“核心缺失项”或“无资料建档”。'],
        ['当前2D', '所有项目暂按2D占位；这不是最终标准，等待你填写后替换。'],
        ['容错', '计划工期=ceil(Σ项目标准人日×(1+30%))；每个任务单独向上取整。'],
        ['窗口', '5人方案最晚2027-02-15，超4项；6人方案最晚2027-01-22，可落窗。'],
        ['下一步', '把“项目标准日期”7个数字发我，或在HTML工作台直接填；我据此锁最终甘特。'],
    ]
    for row in notes:
        ws5.append(row)
    ws5.column_dimensions['A'].width = 14; ws5.column_dimensions['B'].width = 105
    for row in ws5.iter_rows(min_row=1, max_row=ws5.max_row):
        row[0].font = Font(bold=True)
        row[1].alignment = Alignment(wrap_text=True, vertical='top')
    ws5.sheet_view.showGridLines = False

    wb.save(OUT_XLSX)


def build_html(tasks, p):
    data = json.dumps(tasks, ensure_ascii=False)
    items = json.dumps(ITEMS, ensure_ascii=False)
    defaults = json.dumps({'buffer': 30, 'resources': 5, 'start': START, 'window_end': WINDOW_END, 'itemDays': {x['key']: 2 for x in ITEMS}}, ensure_ascii=False)
    html = r'''<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>产品系列资料补齐 · 项目级甘特工作台</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}html,body{max-width:100%;overflow-x:hidden}
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:#f4f7fb;color:#1c2b3a}
.wrap{max-width:1560px;margin:0 auto;padding:16px 22px 48px;overflow:hidden}
h1{font-size:23px;color:#12446e;margin:6px 0 4px}.sub{font-size:12.5px;color:#5b7186;margin-bottom:12px;line-height:1.7}
.kpis{display:grid;grid-template-columns:repeat(6,1fr);gap:9px;margin:10px 0 13px}
.kpi{background:#fff;border-radius:10px;padding:10px 13px;box-shadow:0 1px 3px rgba(16,42,67,.08);border-left:4px solid #2e86c1}
.kpi b{display:block;font-size:20px;color:#12446e;line-height:1.2}.kpi span{font-size:10.5px;color:#6b8199}
.kpi.warn{border-left-color:#c0392b}.kpi.ok{border-left-color:#2e8b57}
.logic{background:#fff;border:1px solid #dce8f3;border-radius:12px;padding:13px 15px;margin:11px 0 13px;box-shadow:0 1px 3px rgba(16,42,67,.05)}
.logic h2{font-size:14px;color:#12446e;margin-bottom:3px}.logic .hint{font-size:11px;color:#73899d;margin-bottom:9px;line-height:1.6}
.itemtable{width:100%;border-collapse:collapse;margin:5px 0 10px}
.itemtable th{background:#1f4e79;color:#fff;font-size:11px;font-weight:600;padding:6px 8px;text-align:left;border:1px solid #d9e2f3}
.itemtable td{padding:5px 8px;border:1px solid #e3edf5;font-size:12px;vertical-align:middle}
.itemtable tr:nth-child(even) td{background:#f8fbfe}
.itemtable input{width:80px;border:1px solid #e0b84c;background:#fff9e6;border-radius:5px;padding:4px 6px;font-size:13px;font-weight:700;color:#8a5a00}
.tag{display:inline-block;font-size:9.5px;border-radius:10px;padding:1px 6px;margin-left:5px;white-space:nowrap}
.tag.core{background:#ddebf7;color:#1f4e79}.tag.archive{background:#e2efda;color:#2e6b3d}.tag.special{background:#fce4d6;color:#a0522d}
.tag.placeholder{background:#fff2cc;color:#8a5a00;margin-left:4px}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:3px}
.field{background:#f8fbfe;border:1px solid #e3edf5;border-radius:8px;padding:8px 9px}
.field label{display:block;font-size:11px;color:#5b7186;margin-bottom:4px}
.field input,.field select{width:100%;border:1px solid #cfdce8;border-radius:5px;background:#fff;padding:5px 7px;font-size:13px;color:#17364f}
.formula{margin-top:9px;background:#f2f7fc;border-left:3px solid #2e86c1;padding:8px 11px;font-size:11.5px;color:#33566f;line-height:1.7}
.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:9px}.actions button{border:1px solid #cfdce8;background:#fff;color:#33566f;padding:6px 13px;border-radius:15px;font-size:12px;cursor:pointer}
.actions button.primary{background:#12446e;color:#fff;border-color:#12446e}#status{font-size:11.5px;font-weight:600}
.ctrl{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:7px 0 9px}.ctrl button{border:1px solid #cfdce8;background:#fff;color:#33566f;padding:6px 13px;border-radius:16px;font-size:12px;cursor:pointer}.ctrl button.on{background:#12446e;color:#fff;border-color:#12446e}
.ctrl input{border:1px solid #cfdce8;border-radius:16px;padding:6px 13px;font-size:12px;width:210px}.legend{margin-left:auto;font-size:11px;color:#5b7186}
.dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin:0 4px 0 9px}
.board{background:#fff;border-radius:12px;box-shadow:0 2px 8px rgba(16,42,67,.07);overflow:hidden;max-width:100%}.months{display:flex;border-bottom:1px solid #e3ecf4;background:#f9fbfd;min-width:max-content}.mh{flex:none;text-align:center;font-size:11px;color:#41637e;padding:7px 0;border-right:1px solid #e3ecf4;font-weight:600}
.scroll{overflow-x:auto;max-width:100%;-webkit-overflow-scrolling:touch}.grid2{position:relative;width:max-content;min-width:100%}
.timeline{position:relative;height:26px;border-bottom:1px solid #e7eef6;background:#fbfdff;margin-left:360px}.tw{position:absolute;top:6px;font-size:9px;color:#8ba2b8;transform:translateX(1px)}.tw.wk{color:#41637e;font-weight:700;border-left:1px solid #dbe6f0;height:26px;top:0;padding-top:8px}
.row{display:flex;border-bottom:1px solid #eef4f9;min-height:72px}.row:hover{background:#f6faff}.task{width:360px;flex:none;padding:8px 12px;border-right:1px solid #e7eef6}
.tname{font-size:12.5px;font-weight:600;color:#17364f;line-height:1.4}.tser{font-weight:400;color:#7690a6;font-size:10.5px;margin-left:4px}.tmeta{font-size:10.5px;color:#8aa1b6;margin-top:3px;line-height:1.5}
.titems{display:flex;gap:3px;flex-wrap:wrap;margin-top:5px}.chip{display:inline-flex;align-items:center;gap:3px;border-radius:9px;padding:1px 5px;font-size:9.5px;line-height:1.5;border:1px solid #dbe6f0;background:#f4f9fd;color:#33566f}.chip em{font-style:normal;font-size:8.5px;color:#7a8fa2}.chip.core{border-color:#c9dff0;background:#edf6fc}.chip.archive{border-color:#cfe8d6;background:#f2fbf4}.chip.special{border-color:#f3d8c9;background:#fff6f1}
.pri{display:inline-block;font-size:9px;color:#fff;border-radius:3px;padding:1px 5px;margin-right:4px}.pP0{background:#c0392b}.pP1{background:#e67e22}.pP2{background:#2e86c1}.lane{position:relative;flex:1;min-width:__N__px}
.bar{position:absolute;top:24px;height:22px;border-radius:4px;color:#fff;font-size:10px;display:flex;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.12);background:#2e86c1}.bar .base{height:100%;display:flex;align-items:center;justify-content:flex-end;padding-right:4px;font-size:10px;white-space:nowrap}.bar .buf{height:100%;background:repeating-linear-gradient(135deg,rgba(255,255,255,.48) 0 4px,rgba(255,255,255,.16) 4px 8px);border-left:1px dashed rgba(255,255,255,.75)}.bar .lab{position:absolute;right:4px;top:4px;font-size:9.5px;color:#fff;text-shadow:0 1px 1px rgba(0,0,0,.25)}
.bar.pP0{background:#c0392b}.bar.pP1{background:#e67e22}.bar.pP2{background:#2e86c1}
.prio{background:#fff;border-radius:12px;padding:13px 15px;margin:13px 0;box-shadow:0 1px 3px rgba(16,42,67,.07)}
.prio h2{font-size:14px;color:#12446e;margin-bottom:3px}.prio .hint{font-size:11px;color:#73899d;margin-bottom:8px;line-height:1.6}
.psum{width:100%;border-collapse:collapse;margin:3px 0 10px}.psum th{background:#1f4e79;color:#fff;font-size:10.5px;padding:5px 6px;border:1px solid #d9e2f3;text-align:center}.psum td{padding:5px 6px;border:1px solid #e3edf5;font-size:11px;text-align:center}.psum tr:nth-child(even) td{background:#f8fbfe}
.ptable{border:1px solid #e3edf5;border-radius:8px;margin:8px 0;overflow:hidden}.ptable h3{font-size:12px;color:#33566f;background:#f7fafc;padding:6px 9px;border-bottom:1px solid #e3edf5;margin:0}.ptable h3 .pbadge{margin-right:5px}
.pbadge{display:inline-block;color:#fff;border-radius:3px;padding:1px 6px;font-size:10px;font-weight:700}.pP0{background:#c0392b}.pP1{background:#e67e22}.pP2{background:#2e86c1}.pP3{background:#6b8199}
.ptable .scrollx{overflow-x:auto}.ptable table{width:100%;border-collapse:collapse;min-width:820px}.ptable th{background:#eef5fb;color:#33566f;font-size:10px;padding:5px 6px;border-bottom:1px solid #e3edf5;text-align:left;white-space:nowrap}.ptable td{padding:5px 6px;border-bottom:1px solid #eef4f9;font-size:10.5px;vertical-align:top}.ptable tr:last-child td{border-bottom:0}.ptable .no{color:#8aa1b6;text-align:center;padding:12px}.ptable .num{text-align:right;white-space:nowrap}.ptable .chip{font-size:8.5px;padding:0 4px}.ptable .chip em{font-size:8px}
.six{background:#fff;border-radius:12px;padding:12px 15px;margin:13px 0;box-shadow:0 1px 3px rgba(16,42,67,.07)}.six h2{font-size:14px;color:#12446e;margin-bottom:3px}.six .hint{font-size:11px;color:#73899d;margin-bottom:7px}
.sixrow{display:flex;gap:7px;flex-wrap:wrap;align-items:center;border-top:1px solid #eef4f9;padding:6px 0;font-size:11.5px;color:#33566f}.sixrow b{color:#17364f}.sixrow .tag{font-size:10px}.sixrow .plus{color:#2e86c1;font-weight:700}
.notes{background:#fff;border-radius:10px;padding:11px 14px;margin-top:12px;font-size:11.5px;color:#5b7186;line-height:1.85;box-shadow:0 1px 3px rgba(16,42,67,.05)}.notes b{color:#33566f}
.empty{padding:22px;text-align:center;color:#8aa1b6;font-size:12px}
@media(max-width:900px){.kpis{grid-template-columns:repeat(3,1fr)}}@media(max-width:760px){.kpis{grid-template-columns:repeat(2,1fr)}.grid{grid-template-columns:1fr}.task{width:205px}.timeline{margin-left:205px}.tname{font-size:11.5px}.tser{display:none}.tmeta{font-size:10px}.wrap{padding:14px 12px 36px}h1{font-size:18px}.legend{margin-left:0;width:100%}.itemtable{display:block;overflow-x:auto;white-space:nowrap}}
</style></head><body><div class="wrap">
<h1>产品系列资料补齐 · 项目级甘特工作台</h1>
<div class="sub">每个任务按具体项目列示：冻融 / 加速 / 效期 / 运输 / 系列建档 / DG / NA ｜ 项目后贴类型标签 ｜ 项目标准日期由你填写，甘特图实时重算</div>
<div class="kpis" id="kpis"></div>
<section class="logic">
<h2>项目标准日期填写框</h2>
<div class="hint">请在“标准人日”填每个项目的标准天数；当前 2D 是占位值，不是最终标准。修改后所有任务、资源排队和甘特条实时变化。</div>
<table class="itemtable"><thead><tr><th>具体项目名称</th><th>类型标签</th><th>适用任务数</th><th>标准人日（天/项）</th></tr></thead><tbody id="itemRows"></tbody></table>
<div class="grid">
<div class="field"><label>容错率（%）</label><input id="buffer" type="number" min="0" max="100" step="1"></div>
<div class="field"><label>资源数（人）</label><select id="resources"></select></div>
<div class="field"><label>排期开始日</label><input id="start" type="date"></div>
</div>
<div class="formula" id="formula"></div>
<div class="actions"><button class="primary" id="fit">切到 6 人 · 满足窗口</button><button id="reset">重置默认</button><span id="status"></span></div>
</section>
<div class="ctrl"><button class="on" data-pri="all">全部</button><button data-pri="P0">P0</button><button data-pri="P1">P1</button><button data-pri="P2">P2</button><button data-pri="P3">P3</button><input id="q" placeholder="搜索货号 / 系列…"><span class="legend"><span class="dot" style="background:#c0392b"></span>P0 <span class="dot" style="background:#e67e22"></span>P1 <span class="dot" style="background:#2e86c1"></span>P2 <span class="dot" style="background:#6b8199"></span>P3 ｜ 实色=基准，斜纹=容错</span></div>
<div class="board"><div class="scroll"><div class="grid2" id="board"></div></div></div>
<section class="prio"><h2>P0 / P1 / P2 / P3 分组表</h2><div class="hint">按当前参数和资源方案实时汇总；每个优先级单独一张明细表。当前源任务池只有 P0/P1/P2，P3 行保留为 0，不虚构任务。</div><div id="prioSummary"></div><div id="prioTables"></div></section>
<section class="six"><h2>基准=6D 的任务构成</h2><div class="hint">只展示当前参数下基准恰好为6D的任务；每个项目名称后面带类型标签。</div><div id="sixd"></div></section>
<section class="notes"><b>口径：</b>基准 = Σ 每个具体项目的标准人日；计划 = ceil(基准×(1+容错率))。每个任务由1名资源独占；P0→P1→P2，同优先级按 2026 销售额降序；只排周一至周五，跳过 2027-01-01。
<br><b>标签：</b>核心缺失项=冻融/加速/效期/运输；建档类=系列档案建档；专项参数=DG可冻干/NA低背景。
<br><b>注意：</b>当前所有项目 2D 是占位值，请填写你的标准日期后再锁最终版；5人方案30%容错会到 2027-02-15，6人方案可到 2027-01-22。</section>
</div>
<script>
const TASKS=__TASKS__,ITEMS=__ITEMS__,DEFAULTS=__DEFAULTS__;
const SLOT=9,HOLIDAY='2027-01-01',RESOURCE_NAMES=['研发-占位A','研发-占位B','研发-占位C','产品-占位D','产品-占位E','研发-占位F','产品-占位G','资源-占位H'];
const ITEM_MAP=Object.fromEntries(ITEMS.map(x=>[x.key,x])),PRI_ORDER={P0:0,P1:1,P2:2,P3:3};
let filterPri='all',query='';
function parseDate(s){const [y,m,d]=s.split('-').map(Number);return new Date(y,m-1,d);}function fmt(d){return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');}function addDays(d,n){const x=new Date(d);x.setDate(x.getDate()+n);return x;}function isWD(d){return d.getDay()!==0&&d.getDay()!==6&&fmt(d)!==HOLIDAY;}function workdays(start,count){const out=[];let d=new Date(start);while(out.length<count){if(isWD(d))out.push(new Date(d));d=addDays(d,1);}return out;}
function fmtNum(n){return Number(n).toLocaleString('zh-CN',{maximumFractionDigits:1});}
function readItemDays(){const o={};document.querySelectorAll('[data-item]').forEach(el=>{let v=parseFloat(el.value);if(!isFinite(v)||v<0)v=0;o[el.dataset.item]=v;});return o;}
function params(){return {itemDays:readItemDays(),buffer:Math.max(0,Math.min(100,parseFloat(document.getElementById('buffer').value)||0)),resources:parseInt(document.getElementById('resources').value,10)||5,start:document.getElementById('start').value||DEFAULTS.start};}
function calc(t,p){let base=0;t.item_keys.forEach(k=>base+=p.itemDays[k]||0);const raw=base*p.buffer/100,buffer=Math.ceil(raw-1e-9),total=Math.ceil(base+raw-1e-9);return {base,buffer,total};}
function schedule(p){const cal=workdays(parseDate(p.start),900);const res=RESOURCE_NAMES.slice(0,p.resources);const free=Object.fromEntries(res.map(r=>[r,0]));const rows=[...TASKS].sort((a,b)=>(PRI_ORDER[a.priority]-PRI_ORDER[b.priority])||(b.sales-a.sales));const out=[];for(const t of rows){const c=calc(t,p);let owner=res[0];for(const r of res){if(free[r]<free[owner]||(free[r]===free[owner]&&res.indexOf(r)<res.indexOf(owner)))owner=r;}const s=free[owner],e=s+c.total-1;out.push({...t,...c,owner,start:fmt(cal[s]),end:fmt(cal[e]),s,e});free[owner]=e+1;}const last=out.reduce((a,b)=>a.end>b.end?a:b).end;return {rows:out,cal,last};}
function chips(t){return t.item_keys.map(k=>{const it=ITEM_MAP[k],cls=it.type==='核心缺失项'?'core':(it.type==='建档类'?'archive':'special');return `<span class="chip ${cls}" title="${it.name}｜${it.type}">${it.name}<em>${it.type}</em></span>`;}).join('');}
function render(){const p=params(),sch=schedule(p);const base=sch.rows.reduce((a,x)=>a+x.base,0),buffer=sch.rows.reduce((a,x)=>a+x.buffer,0),total=sch.rows.reduce((a,x)=>a+x.total,0);const over=sch.rows.filter(x=>x.end>DEFAULTS.window_end).length;const bp=base?buffer/base*100:0;document.getElementById('kpis').innerHTML=[`<div class="kpi"><b>${sch.rows.length}</b><span>任务数（P0 ${sch.rows.filter(x=>x.priority==='P0').length} / P1 ${sch.rows.filter(x=>x.priority==='P1').length} / P2 ${sch.rows.filter(x=>x.priority==='P2').length} / P3 ${sch.rows.filter(x=>x.priority==='P3').length}）</span></div>`,`<div class="kpi"><b>${fmtNum(base)}</b><span>基准人日（项目合计）</span></div>`,`<div class="kpi"><b>${buffer}</b><span>容错人日（实际 ${bp.toFixed(1)}%）</span></div>`,`<div class="kpi"><b>${total}</b><span>计划人日</span></div>`,`<div class="kpi ${over?'warn':'ok'}"><b>${sch.last.slice(5)}</b><span>最晚完成</span></div>`,`<div class="kpi ${over?'warn':'ok'}"><b>${over?'超窗 '+over+' 项':'窗口内'}</b><span>目标 2027-01-31</span></div>`].join('');document.getElementById('formula').innerHTML=`基准 = <b>Σ 项目标准人日</b>　｜　计划 = <b>ceil(基准 × (1+${p.buffer}%))</b>　｜　当前 ${sch.rows.length} 项，基准 ${fmtNum(base)}D，容错 ${buffer}D，计划 ${total}D`;const st=document.getElementById('status');if(over){st.style.color='#c0392b';st.textContent=`${p.resources}人方案：最晚 ${sch.last}，${over}项超出 2027-01-31；${p.resources<6?'可点“切到 6 人”':'需增加资源或缩范围'}`;}else{st.style.color='#2e8b57';st.textContent=`${p.resources}人方案：最晚 ${sch.last}，在 2027-01-31 窗口内`;}renderBoard(sch);renderPriority(sch);renderSix(sch);}
function visibleRows(sch){return sch.rows.filter(x=>(filterPri==='all'||x.priority===filterPri)&&(!query||((x.rep+' '+x.series).toLowerCase().includes(query))));}
function renderBoard(sch){const maxIdx=Math.max(...sch.rows.map(x=>x.e)),days=sch.cal.slice(0,maxIdx+1),months=[];days.forEach(d=>{const k=d.getFullYear()+'-'+(d.getMonth()+1);let m=months.find(x=>x.k===k);if(!m){m={k,label:`${d.getFullYear()}年${d.getMonth()+1}月`,n:0};months.push(m);}m.n++;});let html='<div class="months"><div style="width:360px;flex:none;border-right:1px solid #e3ecf4"></div>';months.forEach(m=>html+=`<div class="mh" style="width:${m.n*SLOT}px">${m.label}</div>`);html+='</div><div class="timeline">';days.forEach((d,i)=>{const mon=d.getDay()===1;if(mon||i===0)html+=`<div class="tw ${mon?'wk':''}" style="left:${i*SLOT}px">${d.getDate()}</div>`;});html+='</div>';const rows=visibleRows(sch);if(!rows.length)html+='<div class="empty">没有匹配任务</div>';rows.forEach(x=>{const baseW=x.base*SLOT,bufW=x.buffer*SLOT;html+=`<div class="row"><div class="task"><div class="tname"><span class="pri p${x.priority}">${x.priority}</span>${x.rep}<span class="tser">${x.series}</span></div><div class="tmeta">${x.owner} · 基准${fmtNum(x.base)}D + 容错${x.buffer}D = ${x.total}D · ${x.start.slice(5)}→${x.end.slice(5)} · ${x.sales}万 · ${x.customers}家</div><div class="titems">${chips(x)}</div></div><div class="lane" style="min-width:${days.length*SLOT}px"><div class="bar p${x.priority}" style="left:${x.s*SLOT}px;width:${x.total*SLOT-2}px" title="${x.rep}｜${x.series}\n${x.item_keys.map(k=>ITEM_MAP[k].name).join(' + ')} = ${fmtNum(x.base)}D；+${x.buffer}D容错 = ${x.total}D\n${x.owner}｜${x.start} → ${x.end}"><div class="base" style="width:${baseW}px">${fmtNum(x.base)}D</div><div class="buf" style="width:${bufW}px"></div><span class="lab">${x.total}D</span></div></div></div>`;});document.getElementById('board').innerHTML=html;}
function renderPriority(sch){
  const ps=['P0','P1','P2','P3'];
  const allTotal=sch.rows.reduce((a,x)=>a+x.total,0);
  const sum=ps.map(pr=>{const rows=sch.rows.filter(x=>x.priority===pr);const base=rows.reduce((a,x)=>a+x.base,0),buf=rows.reduce((a,x)=>a+x.buffer,0),tot=rows.reduce((a,x)=>a+x.total,0);const starts=rows.map(x=>x.start).sort(),ends=rows.map(x=>x.end).sort();return {pr,n:rows.length,base,buf,tot,pct:allTotal?tot/allTotal*100:0,first:starts[0]||'—',last:ends[ends.length-1]||'—',ids:rows.map(x=>x.id).join('、')||'—'};});
  let h='<div class="scrollx"><table class="psum"><thead><tr><th>优先级</th><th>任务数</th><th>基准人日</th><th>容错人日</th><th>计划人日</th><th>占计划%</th><th>最早开始</th><th>最晚完成</th><th>任务ID</th></tr></thead><tbody>';
  sum.forEach(r=>{h+=`<tr><td><span class="pbadge p${r.pr}">${r.pr}</span></td><td>${r.n}</td><td class="num">${fmtNum(r.base)}</td><td class="num">${r.buf}</td><td class="num">${r.tot}</td><td>${r.pct.toFixed(1)}%</td><td>${r.first}</td><td>${r.last}</td><td style="text-align:left">${r.ids}</td></tr>`;});
  h+='</tbody></table></div>';
  document.getElementById('prioSummary').innerHTML=h;
  let d='';
  ps.forEach(pr=>{const rows=sch.rows.filter(x=>x.priority===pr);const base=rows.reduce((a,x)=>a+x.base,0),buf=rows.reduce((a,x)=>a+x.buffer,0),tot=rows.reduce((a,x)=>a+x.total,0);
    d+=`<div class="ptable"><h3><span class="pbadge p${pr}">${pr}</span>${pr} 任务 <span style="font-weight:400;color:#8aa1b6">｜ ${rows.length} 项 · 基准 ${fmtNum(base)}D · 容错 ${buf}D · 计划 ${tot}D</span></h3><div class="scrollx"><table><thead><tr><th>任务ID</th><th>系列 / 代表货号</th><th>具体项目（带标签）</th><th>基准</th><th>容错</th><th>计划</th><th>负责人</th><th>起止</th></tr></thead><tbody>`;
    if(!rows.length){d+=`<tr><td colspan="8" class="no">当前任务池无 ${pr} 任务</td></tr>`;}
    rows.forEach(x=>{d+=`<tr><td><b>${x.id}</b></td><td>${x.series}<br><span style="color:#8aa1b6">${x.rep}</span></td><td>${chips(x)}</td><td class="num">${fmtNum(x.base)}D</td><td class="num">${x.buffer}D</td><td class="num"><b>${x.total}D</b></td><td>${x.owner}</td><td>${x.start.slice(5)}→${x.end.slice(5)}</td></tr>`;});
    d+='</tbody></table></div></div>';
  });
  document.getElementById('prioTables').innerHTML=d;
}
function renderSix(sch){const six=sch.rows.filter(x=>Math.abs(x.base-6)<1e-9);if(!six.length){document.getElementById('sixd').innerHTML='<div class="empty">当前参数下没有基准=6D的任务</div>';return;}let h='';six.forEach(x=>{const parts=x.item_keys.map(k=>`${ITEM_MAP[k].name}<span class="tag ${ITEM_MAP[k].type==='核心缺失项'?'core':(ITEM_MAP[k].type==='建档类'?'archive':'special')}">${ITEM_MAP[k].type}</span>`);h+=`<div class="sixrow"><b>${x.id} ${x.rep}</b><span class="tag" style="background:#eaf3fb;color:#33566f">${x.series}</span>${parts.join('<span class="plus"> + </span>')}<span class="plus"> = </span><b>6D</b><span class="tag placeholder">+容错 ${x.buffer}D</span><span class="plus"> → </span><b>${x.total}D</b><span style="color:#8aa1b6">${x.start.slice(5)}→${x.end.slice(5)}</span></div>`;});document.getElementById('sixd').innerHTML=h;}
function initItems(){const tb=document.getElementById('itemRows');tb.innerHTML=ITEMS.map(it=>`<tr><td><b>${it.name}</b></td><td><span class="tag ${it.type==='核心缺失项'?'core':(it.type==='建档类'?'archive':'special')}">${it.type}</span></td><td>${TASKS.filter(t=>t.item_keys.includes(it.key)).length} 项</td><td><input data-item="${it.key}" type="number" min="0" max="60" step="0.5" value="${it.default}"> <span class="tag placeholder">暂填2D</span></td></tr>`).join('');document.querySelectorAll('[data-item]').forEach(el=>el.addEventListener('input',render));}
function init(){document.getElementById('buffer').value=DEFAULTS.buffer;document.getElementById('start').value=DEFAULTS.start;const sel=document.getElementById('resources');for(let i=1;i<=8;i++){const o=document.createElement('option');o.value=i;o.textContent=i+' 人';sel.appendChild(o);}sel.value=DEFAULTS.resources;initItems();document.getElementById('buffer').addEventListener('input',render);document.getElementById('resources').addEventListener('input',render);document.getElementById('start').addEventListener('input',render);document.querySelectorAll('.ctrl button').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.ctrl button').forEach(x=>x.classList.remove('on'));b.classList.add('on');filterPri=b.dataset.pri;render();}));document.getElementById('q').addEventListener('input',e=>{query=e.target.value.trim().toLowerCase();render();});document.getElementById('reset').addEventListener('click',()=>{document.querySelectorAll('[data-item]').forEach(el=>el.value=ITEM_MAP[el.dataset.item].default);document.getElementById('buffer').value=DEFAULTS.buffer;document.getElementById('resources').value=DEFAULTS.resources;document.getElementById('start').value=DEFAULTS.start;render();});document.getElementById('fit').addEventListener('click',()=>{document.getElementById('resources').value=6;render();});render();}
init();
</script></body></html>'''
    html = html.replace('__TASKS__', data).replace('__ITEMS__', items).replace('__DEFAULTS__', defaults).replace('__N__', '620')
    html = BK.inject(html, theme='light', tag='营销中心 · 产品资料')
    open(OUT_HTML, 'w', encoding='utf-8').write(html)


if __name__ == '__main__':
    tasks = load_tasks()
    p = {x['key']: 2 for x in ITEMS}
    p.update({'buffer': 30, 'resources': 5, 'start': START, 'window_end': WINDOW_END})
    build_xlsx(tasks, p)
    build_html(tasks, p)
    print('tasks', len(tasks), 'items', len(ITEMS))
    print('xlsx', OUT_XLSX)
    print('html', OUT_HTML)
