投标文件里最容易被糊弄过去的,是施工进度计划那张横道图。不少人拿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手画的人改一个日期要重排整张图,你改一个数字重跑一遍;他答不上哪条链压不得,你随口就是一串工序名。评标桌上比的就是这个。