专栏文章 工作笔记:研发任务规范性与工时智能分析平台

大海 · 2025年11月19日 · 最后由 回家吃饭 回复于 2025年11月19日 · 557 次阅读

背景介绍

在公司持续推进研发效能提升与 DevOps 工程体系建设的过程中,研发流程的标准化成为关键抓手。长期以来,各项目团队虽已约定任务命名规范(如在 Jira 中使用 [task]、[study]、[research] 等前缀区分任务类型),并采用 Story Points 作为工时估算单位,但这些规范主要依赖人工遵守,缺乏系统性监控与量化评估机制,导致执行效果参差不齐,难以横向比较或持续优化。

同时,技术管理者在资源规划与过程改进中面临信息盲区:无法准确掌握各团队任务类型的分布情况、是否存在大量未按规范命名的任务、工时是否过度集中于少数人员或任务类型、是否存在资源过载或闲置等问题。这些问题制约了研发过程的透明度和管理决策的科学性。

为解决上述痛点,亟需构建一个能够自动采集、智能识别并量化分析研发任务规范性与工时投入的数据平台。在此背景下,研发任务规范性与工时智能分析平台被提出并实施,旨在将原本停留在 “口头约定” 或 “文档规范” 层面的流程要求,转化为可采集、可计算、可追溯、可对比的客观指标,从而支撑研发管理体系从经验驱动向数据驱动演进。

代码

主函数代码

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# =============================================================================
#  MIT License  © 2025
# =============================================================================
# @Time    : 2025-11-13 10:52
# @File    : JiraTagDashboard.py
# @Desc    : Jira Story Tag Dashboard
# -----------------------------------------------------------------------------

from flask import Flask, render_template, request
import re
import plotly.graph_objects as go
from datetime import datetime, timedelta
import logging
import requests
from requests.auth import HTTPBasicAuth
import urllib.parse

# ==================== 配置区 ====================
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

JIRA_URL = "*******************"
EMAIL = "*******************"
API_TOKEN = "*******************"

PROJECT_NAME_TO_KEY = {
    "CKOL": "NP24",
    "CKFM": "CKFM",
    "GovAPI": "QR247",
    "KCDP": "DPFIEA",
    "MOD": "CMSS",
    "KFPS": "C3ST",
    "Kedex": "CPF",
    "BPM": "CMS",
    "IOT": "IEDGE",
    "DML": "CDML",
    "DataAnalytic": "EB247",
    "SFM": "KCDO",
    "UserCenter": "KM",
    "SalesPortal": "CCRM",
    "BigScreen": "CNSERVP",
    "IoTEssential": "IE",
    "NewCKFM": "NCKFM",
}

PROJECT_NAMES = list(PROJECT_NAME_TO_KEY.keys())
VALID_PREFIXES = ['task', 'study', 'research', 'support', 'meeting', 'others']
SP_FIELD = "customfield_10026"

COLOR_PALETTE = [
    "#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
    "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
    "#aec7e8", "#ffbb78"
]

# ==================== 初始化 ====================
app = Flask(__name__)
auth = HTTPBasicAuth(EMAIL, API_TOKEN)
headers = {
    "Accept": "application/json",
    "Content-Type": "application/json"
}


# ==================== 工具函数 ====================
def extract_prefix(title):
    if not title:
        return None
    match = re.search(r'[\[【]([^\]】]+)[\]】]', title)
    if match:
        prefix = match.group(1).strip().lower()
        if prefix in VALID_PREFIXES:
            return prefix
    return None


def empty_result(name, key="INVALID"):
    return {
        'project_name': name,
        'project_key': key,
        'total_sp': 0,
        'valid_count': 0,
        'issue_count': 0,
        'prefix_pct': {p: 0.0 for p in VALID_PREFIXES}
    }


def parse_time_range(form_data):
    time_mode = form_data.get('time_mode', 'quick')
    now = datetime.now()
    if time_mode == 'custom':
        try:
            start_str = form_data.get('start_date', '').strip()
            end_str = form_data.get('end_date', '').strip()
            if start_str and end_str:
                start_date = datetime.strptime(start_str, '%Y-%m-%d')
                end_date = datetime.strptime(end_str, '%Y-%m-%d')
                if end_date > now:
                    end_date = now
                if (end_date - start_date).days > 180:
                    start_date = end_date - timedelta(days=180)
                if start_date > end_date:
                    start_date = end_date - timedelta(days=1)
                return start_date, end_date, 'custom', start_str, end_str
        except Exception as e:
            logger.error(f"日期解析错误: {e}")

    period = form_data.get('period', '1m')
    days_map = {'1w': 7, '1m': 30, '3m': 90, '6m': 180}
    days = days_map.get(period, 30)
    days = min(days, 180)
    start_date = now - timedelta(days=days)
    end_date = now
    return start_date, end_date, period, start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')


def generate_single_project_chart(data, color=None):
    fig = go.Figure()
    percentages = [data['prefix_pct'][p] for p in VALID_PREFIXES]
    sp_values = [data['prefix_pct'][p] * data['total_sp'] / 100.0 for p in VALID_PREFIXES]
    hover_texts = [
        f"类别: {p}<br>工作量占比: {pct:.1f}%<br>SP 值: {sp:.1f}"
        for p, pct, sp in zip(VALID_PREFIXES, percentages, sp_values)
    ]

    bar_color = color if color is not None else '#1f77b4'

    fig.add_trace(go.Bar(
        x=VALID_PREFIXES,
        y=percentages,
        text=[f"{pct:.1f}%" if pct > 0 else "" for pct in percentages],
        textposition='outside',
        hovertext=hover_texts,
        marker_color=bar_color
    ))

    subtitle = f"(总工时数: {data['total_sp']:.1f},已打标Story: {data['valid_count']} / {data['issue_count']})"
    full_title = f"<b>{data['project_name']}|任务分布</b> {subtitle}"
    fig.update_layout(
        title={'text': full_title, 'x': 0.5, 'xanchor': 'center', 'font': {'size': 16}},
        xaxis=dict(title="任务类型", showgrid=False),
        yaxis=dict(title="任务量占比 (%)", range=[0, 100], gridcolor='rgba(200,200,200,0.3)'),
        height=400,
        font=dict(family="Segoe UI, Microsoft YaHei, sans-serif"),
        plot_bgcolor='rgba(0,0,0,0)',
        paper_bgcolor='rgba(0,0,0,0)',
        margin=dict(l=50, r=50, t=60, b=60)
    )
    return fig.to_html(full_html=False, include_plotlyjs='cdn')


def generate_compare_charts_html(data_list):
    if not data_list:
        return ""
    html_parts = ['<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>']
    for idx, data in enumerate(data_list):
        color = COLOR_PALETTE[idx % len(COLOR_PALETTE)]
        chart_html = generate_single_project_chart(data, color=color)
        html_parts.append(chart_html)
        html_parts.append('<hr style="margin: 30px 0; border-color: #eee;">')
    return "\n".join(html_parts)


# ==================== 核心数据获取函数====================
def fetch_jira_data(project_name, start_date, end_date):
    project_key = PROJECT_NAME_TO_KEY.get(project_name)
    if not project_key:
        logger.warning(f"[{project_name}] 项目名称无效")
        return empty_result(project_name)

    logger.info(f"[{project_name}] 开始查询 Jira 数据,时间范围: {start_date.date()} ~ {end_date.date()}")

    # 构建 JQL
    jql = f'project = "{project_key}" AND (issuetype = Story OR issuetype = Task) AND created >= "{start_date.strftime("%Y-%m-%d")}" AND created <= "{end_date.strftime("%Y-%m-%d")}"'

    # === 第一阶段:使用 Enhanced Search (/search/jql) 获取所有 Story/Task ===
    issue_ids = []
    next_page_token = None
    max_results_per_page = 5000  # Enhanced Search 最大支持 5000

    while True:
        payload = {
            "jql": jql,
            "maxResults": max_results_per_page
        }
        if next_page_token:
            payload["nextPageToken"] = next_page_token

        try:
            url = f"{JIRA_URL}/rest/api/3/search/jql"
            resp = requests.post(url, json=payload, auth=auth, headers=headers, timeout=(10, 20))
            if resp.status_code != 200:
                logger.error(f"[{project_name}] Enhanced Search 失败: {resp.status_code} {resp.text}")
                return empty_result(project_name, project_key)

            data = resp.json()
            page_ids = [issue["id"] for issue in data.get("issues", [])]
            issue_ids.extend(page_ids)

            if "nextPageToken" not in data:
                break
            next_page_token = data["nextPageToken"]

        except Exception as e:
            logger.error(f"[{project_name}] Enhanced Search 异常: {e}")
            return empty_result(project_name, project_key)

    total_issues = len(issue_ids)
    if total_issues == 0:
        logger.info(f"[{project_name}] 未找到任何 issue")
        return empty_result(project_name, project_key)

    logger.info(f"[{project_name}] 已获取 {total_issues} 个 Story/Task任务")

    # === 第二阶段:使用 Bulk Fetch 获取 summary 和 SP 字段 ===
    all_issues = []
    batch_size = 100  # Bulk Fetch 最大支持 100 个 ID/请求

    for i in range(0, total_issues, batch_size):
        batch = issue_ids[i:i + batch_size]
        try:
            url = f"{JIRA_URL}/rest/api/3/issue/bulkfetch"
            payload = {
                "issueIdsOrKeys": batch,
                "fields": ["summary", SP_FIELD]
            }
            resp = requests.post(url, json=payload, auth=auth, headers=headers, timeout=(10, 30))
            if resp.status_code == 200:
                issues = resp.json().get("issues", [])
                all_issues.extend(issues)
            else:
                logger.error(
                    f"[{project_name}] Bulk Fetch 失败 (batch {i // batch_size + 1}): {resp.status_code} {resp.text}")
        except Exception as e:
            logger.error(f"[{project_name}] Bulk Fetch 异常 (batch {i // batch_size + 1}): {e}")

    logger.info(f"[{project_name}] 已加载 {len(all_issues)} 个任务数(Story/Task)与工时数据(SP)")

    # === 数据聚合逻辑 ===
    prefix_sum = {p: 0.0 for p in VALID_PREFIXES}
    total_sp = 0.0
    valid_count = 0

    for issue in all_issues:
        try:
            fields = issue.get("fields", {})
            summary = fields.get("summary", "") or ""
            sp_val_raw = fields.get(SP_FIELD)
            sp_val = float(sp_val_raw) if sp_val_raw is not None else 0.0
            prefix = extract_prefix(summary)
            if prefix:
                prefix_sum[prefix] += sp_val
                total_sp += sp_val
                valid_count += 1
        except Exception:
            continue

    logger.info(f"[{project_name}] 其中 {valid_count} 个任务符合命名规范,视为有效 Story")

    prefix_pct = {p: (prefix_sum[p] / total_sp) * 100 if total_sp > 0 else 0.0 for p in VALID_PREFIXES}

    return {
        'project_name': project_name,
        'project_key': project_key,
        'total_sp': round(total_sp, 2),
        'valid_count': valid_count,
        'issue_count': len(all_issues),
        'prefix_pct': prefix_pct
    }


# ==================== 并发辅助函数 ====================
def _fetch_safe(args):
    name, start, end = args
    try:
        return fetch_jira_data(name, start, end)
    except Exception as e:
        logger.error(f"[{name}] 并发获取失败: {e}")
        return empty_result(name)


# ==================== 路由 ====================
@app.route('/', methods=['GET', 'POST'])
def single_view():
    now = datetime.now()
    if request.method == 'POST':
        selected_name = request.form.get('project', PROJECT_NAMES[0])
        if selected_name not in PROJECT_NAME_TO_KEY:
            selected_name = PROJECT_NAMES[0]
        start_date, end_date, period_or_mode, start_str, end_str = parse_time_range(request.form)
        time_mode = 'custom' if period_or_mode == 'custom' else 'quick'

        data = fetch_jira_data(selected_name, start_date, end_date)
        chart_html = generate_single_project_chart(data)
        last_update = datetime.now().strftime('%Y-%m-%d %H:%M')
    else:
        selected_name = PROJECT_NAMES[0]
        period_or_mode = '1m'
        time_mode = 'quick'
        start_str = (now - timedelta(days=30)).strftime('%Y-%m-%d')
        end_str = now.strftime('%Y-%m-%d')
        chart_html = None
        last_update = None

    return render_template(
        'index.html',
        tab='single',
        project_names=PROJECT_NAMES,
        selected_name=selected_name,
        period=period_or_mode,
        time_mode=time_mode,
        start_date_str=start_str,
        end_date_str=end_str,
        chart_html=chart_html,
        last_update=last_update
    )


@app.route('/compare', methods=['GET', 'POST'])
def compare_view():
    from concurrent.futures import ThreadPoolExecutor
    now = datetime.now()
    if request.method == 'POST':
        selected_names = request.form.getlist('projects')
        selected_names = [n for n in selected_names if n in PROJECT_NAME_TO_KEY]
        start_date, end_date, period_or_mode, start_str, end_str = parse_time_range(request.form)
        time_mode = 'custom' if period_or_mode == 'custom' else 'quick'

        if selected_names:
            with ThreadPoolExecutor(max_workers=min(5, len(selected_names))) as executor:
                tasks = [(name, start_date, end_date) for name in selected_names]
                futures = [executor.submit(_fetch_safe, task) for task in tasks]
                data_list = [f.result() for f in futures]
            chart_html = generate_compare_charts_html(data_list)
        else:
            chart_html = ""
        last_update = datetime.now().strftime('%Y-%m-%d %H:%M')
    else:
        selected_names = []
        period_or_mode = '1m'
        time_mode = 'quick'
        start_str = (now - timedelta(days=30)).strftime('%Y-%m-%d')
        end_str = now.strftime('%Y-%m-%d')
        chart_html = None
        last_update = None

    return render_template(
        'index.html',
        tab='compare',
        project_names=PROJECT_NAMES,
        selected_names=selected_names,
        period=period_or_mode,
        time_mode=time_mode,
        start_date_str=start_str,
        end_date_str=end_str,
        chart_html=chart_html,
        last_update=last_update
    )


# ==================== 启动 ====================
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=9014, debug=False)

HTML 代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>研发任务规范性与工时智能分析平台</title>
    <link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
    <style>
        body {
            background-color: #f5f7fa;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            padding-top: 24px;
            color: #333;
        }

        .page-header {
            margin-bottom: 28px;
        }
        .page-header h1 {
            font-weight: 600;
            color: #2c3e50;
            font-size: 1.8rem;
        }

        .form-card {
            background: white;
            border-radius: 12px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.06);
            padding: 24px;
            margin-bottom: 28px;
            transition: box-shadow 0.2s ease;
        }
        .form-card:hover {
            box-shadow: 0 6px 16px rgba(0,0,0,0.08);
        }

        .chart-card {
            background: white;
            border-radius: 12px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.06);
            padding: 24px;
        }

        .section-title {
            font-size: 1.1rem;
            font-weight: 600;
            margin-bottom: 16px;
            color: #495057;
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .section-title::before {
            content: "";
            display: inline-block;
            width: 4px;
            height: 16px;
            background: #4a90e2;
            border-radius: 2px;
        }

        .project-checkbox-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
            gap: 14px;
            padding: 12px;
            border: 1px solid #e0e6ed;
            border-radius: 8px;
            background-color: #fafcff;
            margin-top: 10px;
        }
        .project-checkbox-item {
            display: flex;
            align-items: center;
            padding: 10px 12px;
            border: 1px solid #e9ecef;
            border-radius: 8px;
            background: #ffffff;
            font-size: 0.95rem;
            cursor: pointer;
            transition: all 0.18s ease;
        }
        .project-checkbox-item:hover {
            background: #f0f7ff;
            border-color: #4a90e2;
        }
        .project-checkbox-item input[type="checkbox"] {
            margin-right: 8px;
            transform: scale(1.15);
        }

        .time-mode-group .form-check {
            margin-right: 20px;
        }

        .submit-section {
            margin-top: 20px;
            display: flex;
            align-items: center;
            gap: 16px;
            flex-wrap: wrap;
        }
        .btn-primary {
            padding-left: 24px;
            padding-right: 24px;
            font-weight: 500;
        }

        .text-hint {
            font-size: 0.875rem;
            color: #6c757d;
        }

        @media (max-width: 768px) {
            .project-checkbox-grid {
                grid-template-columns: 1fr;
            }
            .time-mode-group .form-check {
                margin-right: 0;
                margin-bottom: 8px;
            }
        }
    </style>
</head>
<body>
<div class="container">
    <div class="page-header">
        <h1 class="text-center fw-bold">研发任务规范性与工时智能分析平台</h1>
    </div>

    <ul class="nav nav-tabs mb-4">
        <li class="nav-item">
            <a class="nav-link {% if tab == 'single' %}active{% endif %}" href="{{ url_for('single_view') }}">单项目分析</a>
        </li>
        <li class="nav-item">
            <a class="nav-link {% if tab == 'compare' %}active{% endif %}" href="{{ url_for('compare_view') }}">多项目对比</a>
        </li>
    </ul>

    <div class="form-card">
        <form id="analysisForm" method="post">

            {% if tab == 'single' %}
            <div class="mb-4">
                <div class="section-title">选择项目</div>
                <div class="col-md-6">
                    <select name="project" class="form-select" onchange="handleQuickSubmit()">
                        {% for name in project_names %}
                        <option value="{{ name }}" {% if name == selected_name %}selected{% endif %}>{{ name }}</option>
                        {% endfor %}
                    </select>
                </div>
            </div>
            {% elif tab == 'compare' %}
            <div class="mb-4">
                <div class="section-title">
                    选择多个项目
                    <button type="button" class="btn btn-sm btn-outline-secondary" id="toggleSelectAll">全选</button>
                </div>
                <div class="col-md-12">
                    <div class="project-checkbox-grid" id="projectCheckboxGrid">
                        {% for name in project_names %}
                        <label class="project-checkbox-item">
                            <input type="checkbox" name="projects" value="{{ name }}"
                                   {% if name in selected_names %}checked{% endif %}>
                            <span>{{ name }}</span>
                        </label>
                        {% endfor %}
                    </div>
                    <p class="text-hint mt-2">点击项目名称可勾选或取消。至少选择一个项目以进行分析。</p>
                </div>
            </div>
            {% endif %}

            <div class="mb-4">
                <div class="section-title">时间范围设置</div>
                <div class="time-mode-group">
                    <div class="form-check form-check-inline">
                        <input class="form-check-input" type="radio" name="time_mode" id="quick" value="quick"
                               {% if time_mode == 'quick' %}checked{% endif %}
                               onchange="toggleTimeInputs(); handleQuickSubmit();">
                        <label class="form-check-label" for="quick">快速选择</label>
                    </div>
                    <div class="form-check form-check-inline">
                        <input class="form-check-input" type="radio" name="time_mode" id="custom" value="custom"
                               {% if time_mode == 'custom' %}checked{% endif %}
                               onchange="toggleTimeInputs();">
                        <label class="form-check-label" for="custom">自定义日期</label>
                    </div>
                </div>
            </div>

            <div class="mb-4" id="quickPeriodSection" style="{% if time_mode != 'quick' %}display:none;{% endif %}">
                <label class="form-label">快速周期</label>
                <div class="col-md-6">
                    <select name="period" class="form-select" onchange="handleQuickSubmit()">
                        <option value="1w" {% if period == '1w' %}selected{% endif %}>最近1周</option>
                        <option value="1m" {% if period == '1m' %}selected{% endif %}>最近1个月</option>
                        <option value="3m" {% if period == '3m' %}selected{% endif %}>最近3个月</option>
                        <option value="6m" {% if period == '6m' %}selected{% endif %}>最近6个月</option>
                    </select>
                </div>
            </div>

            <div class="row mb-4" id="customDateSection" style="{% if time_mode != 'custom' %}display:none;{% endif %}">
                <div class="col-md-3">
                    <label class="form-label">开始日期</label>
                    <input type="date" name="start_date" class="form-control" value="{{ start_date_str }}">
                </div>
                <div class="col-md-3">
                    <label class="form-label">结束日期</label>
                    <input type="date" name="end_date" class="form-control" value="{{ end_date_str }}">
                </div>
            </div>

            <div class="submit-section">
                <button type="submit" class="btn btn-primary">查询</button>
                {% if last_update and time_mode != 'custom' %}
                <span class="text-muted">最后更新:{{ last_update }}</span>
                {% endif %}
            </div>
        </form>
    </div>

    <!-- 描述性内容 -->
    {% if tab == 'single' and chart_html %}
    <div class="alert alert-info mb-4">
        <strong>项目说明:</strong>
        当前分析基于 Jira 中 <code>{{ selected_name }}</code> 项目的 Story 类型任务,
        时间范围为 {{ start_date_str }} 至 {{ end_date_str }}。
        任务类型通过标题中的前缀识别([task]、[study]、[research]、[support]、[meeting]和[others]),
        SP(Story Points)值来自自定义字段 <code>customfield_10026</code></div>
    {% elif tab == 'compare' and chart_html %}
    <div class="alert alert-info mb-4">
        <strong>多项目对比说明:</strong>
        同时分析多个项目的 Story 任务工作量分布。每个项目独立计算其各类工作占比。
        下方为各项目的独立分析图表,便于清晰查看每个项目的投入结构。
    </div>
    {% endif %}

    <!-- 图表展示 -->
    {% if chart_html %}
    <div class="chart-card">
        {{ chart_html|safe }}
    </div>
    {% elif tab == 'compare' and not selected_names %}
    <div class="chart-card text-center py-5 text-muted">
        <p>请选择至少一个项目以查看分析结果。</p>
    </div>
    {% elif not chart_html %}
    <div class="chart-card text-center py-5 text-muted">
        <p>请点击上方“查询”按钮以加载分析结果。</p>
    </div>
    {% endif %}
</div>

<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<script>
function toggleTimeInputs() {
    const isCustom = document.getElementById('custom').checked;
    const quickSection = document.getElementById('quickPeriodSection');
    const customSection = document.getElementById('customDateSection');

    if (isCustom) {
        if (quickSection) quickSection.style.display = 'none';
        if (customSection) customSection.style.display = 'flex';
    } else {
        if (quickSection) quickSection.style.display = 'block';
        if (customSection) customSection.style.display = 'none';
    }
}

function handleQuickSubmit() {
}

document.getElementById('toggleSelectAll')?.addEventListener('click', function () {
    const container = document.getElementById('projectCheckboxGrid');
    if (!container) return;
    const checkboxes = container.querySelectorAll('input[type="checkbox"]');
    const allChecked = Array.from(checkboxes).every(cb => cb.checked);
    const newChecked = !allChecked;
    checkboxes.forEach(cb => cb.checked = newChecked);
    this.textContent = newChecked ? '取消全选' : '全选';
});

document.addEventListener('DOMContentLoaded', function () {
    toggleTimeInputs();

    // 提交时显示“查询中...”
    const form = document.getElementById('analysisForm');
    if (form) {
        form.addEventListener('submit', function () {
            const submitBtn = form.querySelector('.btn-primary');
            if (submitBtn) {
                submitBtn.disabled = true;
                submitBtn.textContent = '查询中...';
            }
        });
    }
});
</script>
</body>
</html>

最终成果


如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 2 条回复 时间 点赞

这种平台,emm 太反人性了

回复内容未通过审核,暂不显示
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册