自动化代码审查

"""
earned_value_analysis.py
项目挣值分析工具 V2.1

改进点:

  1. 遵循 PEP 8 编码规范
  2. 拆分大型方法,提升可维护性
  3. 业务逻辑与 UI 解耦(校验抛异常,调用方处理)
  4. 使用 dataclass 组织计算结果
  5. 添加键盘快捷键与容差判断
  6. 数值格式化增强(千分位、容差区间)
    """

import math
import tkinter as tk
from dataclasses import dataclass
from tkinter import messagebox, ttk
from typing import Optional, Tuple

=============================================================================

数据模型

=============================================================================

@dataclass(frozen=True)
class EVMResult:
"""挣值分析(Earned Value Management)计算结果"""
pv: float # 计划价值
ev: float # 挣值
sv: float # 进度偏差
cv: float # 成本偏差
spi: float # 进度绩效指数
cpi: float # 成本绩效指数
eac: float # 完工估算
vac: float # 完工偏差

class EACMode:
"""EAC 计算模式常量"""
TYPICAL = "typical"
ATYPICAL = "atypical"
BOTH = "both"

=============================================================================

核心计算逻辑(纯函数,可独立测试)

=============================================================================

class EVMCalculator:
"""挣值分析计算器:不含任何 UI 依赖,便于单元测试"""

@staticmethod
def calculate(
bac: float,
ac: float,
pv_percent: float,
ev_percent: float,
eac_mode: str,
) -> EVMResult:
"""
执行挣值分析计算。

Args:
bac: 项目总预算 (Budget At Completion)
ac: 实际成本 (Actual Cost)
pv_percent: 计划完成百分比 (0~100)
ev_percent: 实际完成百分比 (0~100)
eac_mode: EAC 计算模式,取值为 EACMode 中的常量

Returns:
EVMResult: 封装完整的计算结果

Raises:
ValueError: 当输入不满足业务约束时抛出
"""
# 基础指标
pv = bac * pv_percent / 100.0
ev = bac * ev_percent / 100.0

偏差

sv = ev - pv
cv = ev - ac

绩效指数(PV 为 0 时数学上无定义,需提前拦截)

if pv == 0:
raise ValueError("计划价值 (PV) 为 0,无法计算 SPI。项目可能尚未开始。")
spi = ev / pv

CPI(AC 为 0 时按无穷大处理,表示目前零成本)

cpi = ev / ac if ac != 0 else math.inf

EAC 多模式计算

if eac_mode == EACMode.TYPICAL:
# 典型偏差:未来效率与当前一致
eac = bac / cpi if cpi != 0 and not math.isinf(cpi) else math.inf
elif eac_mode == EACMode.ATYPICAL:
# 非典型偏差:未来回归计划执行
eac = ac + (bac - ev)
elif eac_mode == EACMode.BOTH:
# 双重偏差:同时修正进度 + 成本
if cpi != 0 and spi != 0 and not math.isinf(cpi):
eac = ac + (bac - ev) / (cpi * spi)
else:
eac = math.inf
else:
raise ValueError(f"未知的 EAC 计算模式: {eac_mode}")

vac = bac - eac

return EVMResult(
pv=pv, ev=ev, sv=sv, cv=cv,
spi=spi, cpi=cpi, eac=eac, vac=vac,
)

=============================================================================

GUI 界面

=============================================================================

class EarnedValueAnalysisGUI:
"""
项目挣值分析工具图形界面

职责:仅负责界面展示与用户交互,所有计算委托给 EVMCalculator。
"""

界面常量

LABEL_WIDTH = 22
ENTRY_WIDTH = 25
RESULT_FONT = ("微软雅黑", 10)
DEFAULT_BUDGET = 100_000.0
TOLERANCE = 0.05 # SPI/CPI 容差区间(0.95 ~ 1.05 视为正常)

def init(self, root: tk.Tk) -> None:
self.root = root
self.root.title("项目挣值分析工具 V2.1")
self.root.geometry("720x650")
self.root.resizable(True, True)

结果标签引用(key -> Label widget)

self.result_labels: dict[str, ttk.Label] = {}

self._create_widgets()
self._bind_shortcuts()

-------------------------------------------------------------------------

界面构建(拆分为子方法)

-------------------------------------------------------------------------

def _create_widgets(self) -> None:
"""协调创建所有界面区域"""
main_frame = ttk.Frame(self.root, padding=15)
main_frame.pack(fill=tk.BOTH, expand=True)

配置网格列权重,实现自适应拉伸

for col in range(3):
main_frame.grid_columnconfigure(col, weight=1)

row = 0
row = self._create_basic_info_section(main_frame, row)
row = self._create_mode_section(main_frame, row)
row = self._create_button_section(main_frame, row)
row = self._create_result_section(main_frame, row)
row = self._create_status_section(main_frame, row)

def _create_basic_info_section(self, parent: ttk.Frame, start_row: int) -> int:
"""创建基础信息输入区,返回下一可用行号"""
ttk.Label(
parent,
text="📊 项目基础信息",
font=("微软雅黑", 12, "bold"),
).grid(row=start_row, column=0, columnspan=3, pady=(0, 10))

row = start_row + 1

项目总预算

ttk.Label(parent, text="项目总预算 (BAC):", width=self.LABEL_WIDTH).grid(
row=row, column=0, sticky=tk.W, pady=5
)
self.bac_entry = ttk.Entry(parent, width=self.ENTRY_WIDTH)
self.bac_entry.grid(row=row, column=1, sticky=tk.W, pady=5)
self.bac_entry.insert(0, f"{self.DEFAULT_BUDGET:,.0f}")
ttk.Label(parent, text="元").grid(row=row, column=2, sticky=tk.W)
row += 1

实际成本

ttk.Label(parent, text="实际成本 (AC):", width=self.LABEL_WIDTH).grid(
row=row, column=0, sticky=tk.W, pady=5
)
self.ac_entry = ttk.Entry(parent, width=self.ENTRY_WIDTH)
self.ac_entry.grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="元").grid(row=row, column=2, sticky=tk.W)
row += 1

计划完成百分比

ttk.Label(parent, text="计划完成百分比:", width=self.LABEL_WIDTH).grid(
row=row, column=0, sticky=tk.W, pady=5
)
self.pv_percent_entry = ttk.Entry(parent, width=self.ENTRY_WIDTH)
self.pv_percent_entry.grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="% (0~100)").grid(row=row, column=2, sticky=tk.W)
row += 1

实际完成百分比

ttk.Label(parent, text="实际完成百分比:", width=self.LABEL_WIDTH).grid(
row=row, column=0, sticky=tk.W, pady=5
)
self.ev_percent_entry = ttk.Entry(parent, width=self.ENTRY_WIDTH)
self.ev_percent_entry.grid(row=row, column=1, sticky=tk.W, pady=5)
ttk.Label(parent, text="% (0~100)").grid(row=row, column=2, sticky=tk.W)
row += 1

return row

def _create_mode_section(self, parent: ttk.Frame, start_row: int) -> int:
"""创建 EAC 计算模式选择区"""
ttk.Label(
parent,
text="📈 完工估算 (EAC) 计算模式",
font=("微软雅黑", 12, "bold"),
).grid(row=start_row, column=0, columnspan=3, pady=(15, 10))

self.eac_mode = tk.StringVar(value=EACMode.TYPICAL)
mode_frame = ttk.Frame(parent)
mode_frame.grid(row=start_row + 1, column=0, columnspan=3, pady=5)

modes = [
("典型偏差(未来按当前效率)", EACMode.TYPICAL),
("非典型偏差(未来按计划执行)", EACMode.ATYPICAL),
("双重偏差(同时考虑进度+成本)", EACMode.BOTH),
]
for text, value in modes:
ttk.Radiobutton(
mode_frame,
text=text,
variable=self.eac_mode,
value=value,
).pack(side=tk.LEFT, padx=5)

return start_row + 2

def _create_button_section(self, parent: ttk.Frame, start_row: int) -> int:
"""创建计算按钮区"""
self.calc_btn = ttk.Button(
parent,
text="🧮 开始计算",
command=self.calculate,
)
self.calc_btn.grid(row=start_row, column=0, columnspan=3, pady=15)
return start_row + 1

def _create_result_section(self, parent: ttk.Frame, start_row: int) -> int:
"""创建计算结果展示区"""
ttk.Label(
parent,
text="📋 计算结果",
font=("微软雅黑", 12, "bold"),
).grid(row=start_row, column=0, columnspan=3, pady=(10, 10))

result_names = [
"计划价值 (PV)",
"挣值 (EV)",
"进度偏差 (SV)",
"成本偏差 (CV)",
"进度绩效指数 (SPI)",
"成本绩效指数 (CPI)",
"完工估算 (EAC)",
"完工偏差 (VAC)",
]

row = start_row + 1
for name in result_names:
ttk.Label(
parent,
text=f"{name}:",
width=self.LABEL_WIDTH,
font=self.RESULT_FONT,
).grid(row=row, column=0, sticky=tk.W, pady=3)

label = ttk.Label(parent, text="--", font=self.RESULT_FONT)
label.grid(row=row, column=1, columnspan=2, sticky=tk.W)
self.result_labels[name] = label
row += 1

return row

def _create_status_section(self, parent: ttk.Frame, start_row: int) -> int:
"""创建项目状态分析区"""
ttk.Label(
parent,
text="📝 项目状态分析",
font=("微软雅黑", 12, "bold"),
).grid(row=start_row, column=0, columnspan=3, pady=(15, 10))

self.status_text = tk.Text(
parent,
height=6,
width=70,
font=("微软雅黑", 10),
wrap=tk.WORD,
)
self.status_text.grid(
row=start_row + 1, column=0, columnspan=3, pady=5, sticky=tk.NSEW
)
parent.grid_rowconfigure(start_row + 1, weight=1)

return start_row + 2

-------------------------------------------------------------------------

交互与快捷键

-------------------------------------------------------------------------

def _bind_shortcuts(self) -> None:
"""绑定键盘快捷键"""
self.root.bind("", lambda _event: self.calculate())
self.root.bind("", lambda _event: self.root.destroy())

-------------------------------------------------------------------------

输入校验(纯逻辑,异常由调用方处理)

-------------------------------------------------------------------------

def _parse_inputs(self) -> Tuple[float, float, float, float]:
"""
解析并校验用户输入。

Returns:
(bac, ac, pv_percent, ev_percent)

Raises:
ValueError: 当输入格式错误或超出业务约束时抛出
"""
bac_str = self.bac_entry.get().strip().replace(",", "")
ac_str = self.ac_entry.get().strip().replace(",", "")
pv_str = self.pv_percent_entry.get().strip()
ev_str = self.ev_percent_entry.get().strip()

if not all([bac_str, ac_str, pv_str, ev_str]):
raise ValueError("所有输入项不能为空!")

try:
bac = float(bac_str)
ac = float(ac_str)
pv_percent = float(pv_str)
ev_percent = float(ev_str)
except ValueError as exc:
raise ValueError("请输入有效的数字!") from exc

if bac <= 0:
raise ValueError("项目总预算必须大于 0!")
if ac < 0:
raise ValueError("实际成本不能为负数!")
if not (0 <= pv_percent <= 100):
raise ValueError("计划完成百分比必须在 0~100 之间!")
if not (0 <= ev_percent <= 100):
raise ValueError("实际完成百分比必须在 0~100 之间!")

return bac, ac, pv_percent, ev_percent

-------------------------------------------------------------------------

计算与展示

-------------------------------------------------------------------------

def calculate(self) -> None:
"""触发计算并更新界面"""
try:
bac, ac, pv_percent, ev_percent = self._parse_inputs()
result = EVMCalculator.calculate(
bac, ac, pv_percent, ev_percent, self.eac_mode.get()
)
except ValueError as exc:
messagebox.showerror("输入错误", str(exc))
return
except ZeroDivisionError:
messagebox.showerror("计算错误", "发生除以零错误,请检查输入数据。")
return

self._update_results(result)
self._generate_status_analysis(result)

def _update_results(self, result: EVMResult) -> None:
"""将计算结果格式化后更新到界面"""
mapping = {
"计划价值 (PV)": result.pv,
"挣值 (EV)": result.ev,
"进度偏差 (SV)": result.sv,
"成本偏差 (CV)": result.cv,
"进度绩效指数 (SPI)": result.spi,
"成本绩效指数 (CPI)": result.cpi,
"完工估算 (EAC)": result.eac,
"完工偏差 (VAC)": result.vac,
}

for name, value in mapping.items():
if isinstance(value, float) and math.isinf(value):
text = "无法计算(无有效数据)"
elif "指数" in name:
text = f"{value:.3f}"
else:
# 金额类:千分位 + 两位小数 + 元
sign = "+" if value > 0 else ("" if value == 0 else "")
text = f"{sign}{value:,.2f} 元"

self.result_labels[name].config(text=text)

def _generate_status_analysis(self, result: EVMResult) -> None:
"""生成项目状态分析文本"""
spi = result.spi
cpi = result.cpi
tol = self.TOLERANCE

状态判断(带容差)

if spi > 1 + tol:
spi_status = "✅ 进度提前"
elif spi < 1 - tol:
spi_status = "⚠️ 进度滞后"
else:
spi_status = "➖ 进度正常"

if cpi > 1 + tol:
cpi_status = "✅ 成本节约"
elif cpi < 1 - tol:
cpi_status = "⚠️ 成本超支"
else:
cpi_status = "➖ 成本正常"

模式说明

mode_text = {
EACMode.TYPICAL: "典型偏差(未来效率与当前一致)",
EACMode.ATYPICAL: "非典型偏差(未来回归计划执行)",
EACMode.BOTH: "双重偏差(同时修正进度+成本)",
}.get(self.eac_mode.get(), "未知模式")

analysis = (
f"📌 项目综合状态:{spi_status} | {cpi_status}\n"
f"📐 EAC 计算模式:{mode_text}\n\n"
f"💡 解读:\n"
f"• SPI > 1 进度提前,SPI < 1 进度滞后(容差 ±{tol:.0%})\n"
f"• CPI > 1 成本节约,CPI < 1 成本超支(容差 ±{tol:.0%})\n"
f"• EAC 为项目预计总成本,VAC 为预计总盈亏"
)

self.status_text.delete(1.0, tk.END)
self.status_text.insert(tk.END, analysis)

=============================================================================

程序入口

=============================================================================

if name == "main":
window = tk.Tk()
app = EarnedValueAnalysisGUI(window)
window.mainloop()

posted @ 2026-05-26 09:18  鱼一直摸  阅读(3)  评论(0)    收藏  举报