自动化代码审查
"""
earned_value_analysis.py
项目挣值分析工具 V2.1
改进点:
- 遵循 PEP 8 编码规范
- 拆分大型方法,提升可维护性
- 业务逻辑与 UI 解耦(校验抛异常,调用方处理)
- 使用 dataclass 组织计算结果
- 添加键盘快捷键与容差判断
- 数值格式化增强(千分位、容差区间)
"""
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("
self.root.bind("
-------------------------------------------------------------------------
输入校验(纯逻辑,异常由调用方处理)
-------------------------------------------------------------------------
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()

浙公网安备 33010602011771号