夜雨聆风学习资料网

ARTICLE · 1199800

进度计划还在Excel手画?工期算错一处,技术分就丢一档

进度计划还在Excel手画?工期算错一处,技术分就丢一档

投标文件里最容易被糊弄过去的,是施工进度计划那张横道图。不少人拿Excel画:拉几根彩色条,标上日期,看着挺像回事。可评标专家看这张图的眼光跟你不一样——他第一眼找的不是图好不好看,是工序接不接得上、哪条链压不得。工期加错一天、前置漏一项、关键路径说不清,技术分就往下掉一档,报价磨回来的那点优势,可能不够填这个坑。

手画横道图,最容易栽在三个地方。

一是工序接不上。图上画着设备一进场就开始安装,实际采购周期十五天还没走完,行家一眼看穿你根本没排过产。

二是关键路径看不见。几十道工序里,真正决定总工期的往往就一条链。Excel里这条链藏在你的脑子里,改了三个日期之后,连你自己都说不清现在压着总工期的是哪几件事。

三是甲方一改日期全图重画。开工日期往后挪十天,几十根条一根根挪,两个小时就没了,还容易挪漏一根。

这三件事其实是同一道数学题:每个任务有工期、有前置,问哪条链决定总工期。这种题交给脚本算,一秒出答案,永远不会算错。今天把这套东西完整贴出来——一张CSV清单加一个脚本,自动算关键路径、标浮动时间、画横道图,照着跑就能复现我的输出。

一、清单就一张CSV

新建一个文本文件存成tasks.csv,三列:任务、工期、前置。前置可以写多个,用分号隔开(顺手兼容了顿号和全角分号,Excel里怎么顺手怎么填),没有前置就留空。

任务,工期,前置 进场准备与围挡,5, 临时设施搭建,7,进场准备与围挡 设备采购与运输,15,进场准备与围挡 基础施工,10,临时设施搭建 设备安装,12,设备采购与运输;基础施工 管线敷设,8,临时设施搭建 单机调试,6,设备安装 系统联调,7,单机调试;管线敷设 试运行,10,系统联调 竣工验收与培训,3,试运行 

这是一个虚构的十道工序项目,任务名和工期你可以直接换成自己项目的。逻辑关系是真实的:设备安装要等采购到货和基础施工都完成,系统联调要等单机调试和管线敷设都收尾。

换成自己的项目只需要改这个CSV:任务照你方案里的分项写,工期按你的施工组织设计填,前置照实填“谁干完我才能开工”。工期单位自己定,填工作日就是工作日,填自然日就是自然日,脚本只管做加减法。加任务就加一行,删任务就删一行,顺序乱填都没关系——脚本会自己排出先后,顺序填错了它还替你抓出来,这个后面讲。

二、跑一下

依赖只有matplotlib(画图用),装一次就好:pip install matplotlib。然后一行命令:

python bid_gantt.py tasks.csv -o 进度计划.png 

真实的运行输出:

任务                工期     最早开始     最早结束    可浮动 进场准备与围挡            5        0        5      0  ←关键 临时设施搭建             7        5       12      0  ←关键 设备采购与运输           15        5       20      2 基础施工              10       12       22      0  ←关键 设备安装              12       22       34      0  ←关键 管线敷设               8       12       20     20 单机调试               6       34       40      0  ←关键 系统联调               7       40       47      0  ←关键 试运行              10       47       57      0  ←关键 竣工验收与培训            3       57       60      0  ←关键  总工期:60 天 关键路径:进场准备与围挡 → 临时设施搭建 → 基础施工 → 设备安装 → 单机调试 → 系统联调 → 试运行 → 竣工验收与培训 横道图已生成:进度计划.png 

总工期60天,关键路径八道工序,脚本全标出来了。这张表真正值钱的是“可浮动”那一列。

设备采购与运输可浮动2天:采购环节卡住两天,总工期立刻顺延,这条链上的人一天都拖不起。管线敷设可浮动20天:现场人手紧、材料晚到,往后压20天完全不碰总工期——排资源、赶工期的时候,浮动时间就是你的回旋余地。

跟领导汇报、跟评标专家答疑,也是同一张底牌。专家问一句“为什么总工期是60天”,你把红色那条链一道道念给他听,比图上几十根花花绿绿的条有说服力得多。

脚本同时会画出横道图:关键路径红色,非关键工序蓝色,每根条旁边标着可浮动几天。甲方说开工日期改一改,你改CSV里的工期数字重跑一遍,几秒钟出新图,关键路径自动重算——压缩工期的时候哪几道工序必须加人加班,一目了然。

压缩工期还有个常见误区:不是所有工序加人都有用。想把总工期压到55天,只在关键路径上使劲才有效,给管线敷设多派一个班组、加班加点把8天干成5天,总工期一天都不会动,因为那条链上有20天浮动,本来就不压着竣工日期。反过来,设备采购卡两天工期就顺延两天。手画图的人经常在非关键链上白费劲,算过一遍浮动时间就不会了。

三、四个真实的坑

坑一:Excel“另存为CSV”默认带BOM头,用普通utf-8去读,第一列的列名“任务”前面会多出三个看不见的字节,脚本按列名取数直接扑空。所以读文件用utf-8-sig,BOM自动剥掉。

坑二:前置里的任务名手滑写错一个字,脚本不会替你猜。真实报错长这样:

错误:任务「甲」的前置「乙」不在清单里 

坑三:工序顺序填反了最容易看花眼。我故意把机电安装的前置写成装修收尾,装修收尾的前置又写成机电安装,人眼检查半天未必发现,脚本直接报:

错误:任务存在循环依赖,卡在:机电安装、装修收尾 

提交之前跑一次,比交标之后被专家问住强。

坑四:matplotlib默认字体不认中文,图画出来任务名全是方块。脚本里做了字体自动探测:Windows找微软雅黑、Mac找苹方、Linux把Noto黑体喂给它,拿过去基本不用改。

四、完整脚本

全文贴出,存成bid_gantt.py就能跑:

# -*- coding: utf-8 -*- """ 标书进度计划自动排版工具:读任务清单CSV,自动算关键路径、画横道图 用法:python bid_gantt.py tasks.csv [-o 进度计划.png] CSV列:任务,工期,前置(前置用分号/顿号分隔多个,无前置留空) """ import argparse import csv import sys from collections import deque  import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt from matplotlib import font_manager   def load_tasks(path):     """读CSV -> {任务名: (工期, [前置...])},兼容Excel导出的BOM和全角分号"""     tasks = {}     with open(path, encoding="utf-8-sig", newline="") as f:         for row in csv.DictReader(f):             name = (row.get("任务") or "").strip()             if not name:                 continue             dur = float((row.get("工期") or "").strip())             raw = (row.get("前置") or "").replace(";", ";").replace("、", ";")             deps = [d.strip() for d in raw.split(";") if d.strip()]             if name in tasks:                 sys.exit(f"错误:任务「{name}」重复出现")             tasks[name] = (dur, deps)     if not tasks:         sys.exit("错误:CSV里没有读到任何任务")     for name, (_, deps) in tasks.items():         for d in deps:             if d not in tasks:                 sys.exit(f"错误:任务「{name}」的前置「{d}」不在清单里")     return tasks   def topo_order(tasks):     """Kahn拓扑排序,顺便抓出循环依赖"""     indeg = {n: 0 for n in tasks}     children = {n: [] for n in tasks}     for name, (_, deps) in tasks.items():         indeg[name] = len(deps)         for d in deps:             children[d].append(name)     queue = deque(n for n, k in indeg.items() if k == 0)     order = []     while queue:         n = queue.popleft()         order.append(n)         for c in children[n]:             indeg[c] -= 1             if indeg[c] == 0:                 queue.append(c)     if len(order) != len(tasks):         stuck = sorted(n for n, k in indeg.items() if k > 0)         sys.exit(f"错误:任务存在循环依赖,卡在:{'、'.join(stuck)}")     return order, children   def schedule(tasks):     """正向算最早开始ES/最早结束EF,反向算最晚开始LS/浮动TF"""     order, children = topo_order(tasks)     es, ef = {}, {}     for n in order:         dur, deps = tasks[n]         es[n] = max((ef[d] for d in deps), default=0)         ef[n] = es[n] + dur     total = max(ef.values())     lf, ls = {}, {}     for n in reversed(order):         succ = children[n]         lf[n] = min((ls[s] for s in succ), default=total)         ls[n] = lf[n] - tasks[n][0]     tf = {n: ls[n] - es[n] for n in tasks}     critical = [n for n in order if abs(tf[n]) < 1e-9]     return es, ef, tf, total, critical   def pick_font():     """先把常见中文Hei体文件喂给matplotlib,找不到再按字体名兜底"""     for path in ["/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",                  "C:/Windows/Fonts/msyh.ttc",                  "C:/Windows/Fonts/simhei.ttf",                  "/System/Library/Fonts/PingFang.ttc"]:         try:             font_manager.fontManager.addfont(path)             return font_manager.FontProperties(fname=path).get_name()         except Exception:             continue     wanted = ["Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC",               "Noto Sans CJK JP", "Source Han Sans SC", "WenQuanYi Zen Hei"]     names = {f.name for f in font_manager.fontManager.ttflist}     for name in wanted:         if name in names:             return name     return "sans-serif"   def draw(tasks, es, ef, tf, total, critical, out_png):     plt.rcParams["font.family"] = pick_font()     plt.rcParams["axes.unicode_minus"] = False     order, _ = topo_order(tasks)     names = order[::-1]  # 首个任务画在最上面     fig, ax = plt.subplots(figsize=(11, 4 + 0.45 * len(names)))     for i, n in enumerate(names):         is_crit = abs(tf[n]) < 1e-9         color = "#C0392B" if is_crit else "#5B84A6"         ax.barh(i, tasks[n][0], left=es[n], height=0.55,                 color=color, alpha=0.9 if is_crit else 0.55, zorder=3)         label = f"工期{tasks[n][0]:g}天" + ("" if is_crit else f"(可浮动{tf[n]:g}天)")         ax.text(ef[n] + total * 0.008, i, label, va="center", fontsize=9,                 color="#7F1D1D" if is_crit else "#3C556B")     ax.set_yticks(range(len(names)))     ax.set_yticklabels(names, fontsize=10)     ax.set_xlabel(f"第几天(总工期 {total:g} 天)", fontsize=11)     ax.set_xlim(0, total * 1.18)     ax.grid(axis="x", linestyle="--", alpha=0.4, zorder=0)     ax.set_title("施工进度计划横道图(红色为关键路径)", fontsize=13, pad=12)     fig.tight_layout()     fig.savefig(out_png, dpi=150)     print(f"横道图已生成:{out_png}")   def main():     ap = argparse.ArgumentParser(description="标书进度计划:关键路径计算+横道图")     ap.add_argument("csvfile", help="任务清单CSV(列:任务,工期,前置)")     ap.add_argument("-o", "--output", default="进度计划.png", help="输出图片文件名")     args = ap.parse_args()      tasks = load_tasks(args.csvfile)     es, ef, tf, total, critical = schedule(tasks)      print(f"{'任务':<14}{'工期':>6}{'最早开始':>9}{'最早结束':>9}{'可浮动':>7}")     for n, (dur, _) in tasks.items():         flag = "  ←关键" if abs(tf[n]) < 1e-9 else ""         print(f"{n:<14}{dur:>6g}{es[n]:>9g}{ef[n]:>9g}{tf[n]:>7g}{flag}")     print(f"\n总工期:{total:g} 天")     print("关键路径:" + " → ".join(critical))     draw(tasks, es, ef, tf, total, critical, args.output)   if __name__ == "__main__":     main() 

核心逻辑三步:Kahn算法做拓扑排序顺便抓循环依赖;正向扫一遍算每个任务最早开始时间,总工期就出来了;反向扫一遍算最晚开始时间,两个一减就是浮动时间,浮动为零的任务串起来就是关键路径。

收尾

这套东西也不止用在投标上。装修公司给业主排工期、市场部筹备一场发布会、产品团队排上线计划,全都是同一道题:一堆任务、一堆先后关系、一条决定完工日期的链。谁先算清楚,谁就先拿到主动权。

工期的底气不是画出来的,是算出来的。Excel手画的人改一个日期要重排整张图,你改一个数字重跑一遍;他答不上哪条链压不得,你随口就是一串工序名。评标桌上比的就是这个。

相关学习资料