tech-bid-manageV1.120260424/utils/bid_appendix_content.py
2026-04-24 14:44:38 +08:00

1058 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
标准投标技术标常见附件:按章节标题识别类型,生成带固定表头的 [TABLE]/[FIGURE] 块。
内容结合项目摘要、清单摘要做概括性填充(不照搬示例工程数据)。
"""
from __future__ import annotations
import re
from datetime import date
from typing import Any, Optional
# 从标题中去掉行首编号(如 十二、附件一、)
_TITLE_NUM_RE = re.compile(
r'^[\s\u3000]*(?:[一二三四五六七八九十百零〇两]+|\d+)(?:[、.]\s*)+'
)
def _strip_title_prefix(title: str) -> str:
t = (title or '').strip()
while True:
m = _TITLE_NUM_RE.match(t)
if not m:
break
t = t[m.end() :].strip()
return t
def classify_standard_appendix(title: str) -> Optional[str]:
"""
识别常见附件类型。返回:
main_equipment | test_instruments | labor_plan | schedule_chart |
site_layout | temp_land
"""
t = _strip_title_prefix(title)
if not t:
return None
# 主要施工设备表
if '主要施工设备' in t or ('拟投入' in t and '设备' in t and '' in t):
if '仪器' not in t and '检测' not in t:
return 'main_equipment'
# 试验和检测仪器设备表
if ('试验' in t or '检测仪器' in t or '检测' in t) and '设备' in t and '' in t:
return 'test_instruments'
# 劳动力计划表
if '劳动力' in t and ('计划' in t or '' in t):
return 'labor_plan'
# 临时用地表
if '临时用地' in t:
return 'temp_land'
# 进度网络图 / 横道图 / 开完工日期
if ('进度' in t or '开工' in t or '完工' in t) and (
'网络' in t or '横道' in t or '计划' in t or '日期' in t or '' in t
):
return 'schedule_chart'
# 施工总平面图
if '施工总平面' in t or ('总平面' in t and '' in t):
return 'site_layout'
return None
def _ctx_hint(summary: str, boq: str, max_len: int = 400) -> str:
parts = []
if (summary or '').strip():
parts.append((summary or '').strip()[:max_len])
if (boq or '').strip():
parts.append(('清单摘要:' + (boq or '').strip())[:max_len])
return ''.join(parts) if parts else '按招标文件及施工组织要点配置'
def _project_text(summary: str, boq: str) -> str:
return f'{summary or ""}\n{boq or ""}'
def _text_deterministic_seed(text: str) -> int:
"""短文本时仍能得到稳定偏移,便于同项目多次生成一致。"""
raw = (text or '').strip()[:8000]
if not raw:
return 7
return sum(ord(c) * (i + 1) for i, c in enumerate(raw[:600])) % 997
def _equipment_scale_from_text(summary: str, boq: str) -> float:
"""设备台数规模系数,随项目信息量与关键词调整。"""
t = _project_text(summary, boq)
f = 1.0
if any(k in t for k in ('大规模', '重点', '总长', '线路', '隧道', '桥梁', '水利枢纽')):
f += 0.1
if any(k in t for k in ('小型', '维修', '改造', '零星', '装修')):
f -= 0.12
if len(t) > 3000:
f += 0.06
elif len(t) < 350:
f -= 0.06
return max(0.78, min(f, 1.22))
def _parse_area_sqm_near_keywords(text: str, keywords: tuple[str, ...]) -> Optional[int]:
"""在含关键词的行中抓取面积数字㎡、平方米、m² 等)。"""
if not text or not keywords:
return None
for line in text.splitlines():
if not any(k in line for k in keywords):
continue
m = re.search(r'(\d+(?:\.\d+)?)\s*(?:㎡|m2|m²|平方米)', line, re.I)
if m:
return max(1, int(round(float(m.group(1)))))
m2 = re.search(r'(\d+(?:\.\d+)?)\s*平方', line)
if m2:
return max(1, int(round(float(m2.group(1)))))
return None
def _area_m2_with_context(
text: str,
keywords: tuple[str, ...],
base_m2: int,
scale: float,
seed: int,
row_index: int,
lo: int,
hi: int,
) -> int:
hinted = _parse_area_sqm_near_keywords(text, keywords)
if hinted is not None:
return max(lo, min(hinted, hi))
v = int(round(base_m2 * scale))
v += ((seed >> (row_index * 4)) & 31) - 15
return max(lo, min(v, hi))
# 临时用地行定义(与临时用地表、施工总平面图共用)
TEMP_LAND_SPECS: list[dict] = [
{
'use': '项目部办公',
'kws': ('项目部办公', '办公区', '项目部'),
'base': 150,
'lo': 80,
'hi': 420,
'loc': '施工用地内近大门侧、与作业区相对独立',
'time': '全施工期',
},
{
'use': '门卫及保卫',
'kws': ('门卫', '保卫室', '门岗'),
'base': 18,
'lo': 10,
'hi': 55,
'loc': '施工现场主出入口内侧',
'time': '全施工期',
},
{
'use': '工人生活区',
'kws': ('生活区', '宿舍区', '民工宿舍'),
'base': 220,
'lo': 120,
'hi': 650,
'loc': '场区一侧,与加工堆场保持安全距离',
'time': '全施工期',
},
{
'use': '食堂与卫浴',
'kws': ('食堂', '伙房', '卫浴', '卫生间'),
'base': 42,
'lo': 25,
'hi': 120,
'loc': '生活区内上风向、排水接入沉淀设施',
'time': '全施工期',
},
{
'use': '材料堆场',
'kws': ('堆场', '材料堆场', '仓储'),
'base': 320,
'lo': 150,
'hi': 900,
'loc': '塔吊覆盖范围内、靠近施工道路',
'time': '分阶段布设,与进度同步',
},
{
'use': '钢木加工车间',
'kws': ('加工区', '加工车间', '钢筋加工', '木工棚'),
'base': 300,
'lo': 160,
'hi': 800,
'loc': '靠近堆场与环场道路,设防噪声围挡',
'time': '主体施工高峰期为主',
},
{
'use': '机械停放区',
'kws': ('机械停放', '设备停放', '停车场'),
'base': 200,
'lo': 100,
'hi': 550,
'loc': '硬化地坪、靠近出入口便于进退场',
'time': '全施工期',
},
{
'use': '临时道路及硬化场地',
'kws': ('临时道路', '施工道路', '硬化'),
'base': 980,
'lo': 400,
'hi': 2800,
'loc': '环场主干道及作业面连通支线',
'time': '随施工阶段动态调整',
},
]
def compute_temp_land_rows(summary: str, boq: str) -> list[dict[str, Any]]:
"""与临时用地表一致的用途、面积(㎡)、位置、时间(供总平面图文字勾连)。"""
ctx = _project_text(summary, boq)
seed = _text_deterministic_seed(ctx)
scale = _equipment_scale_from_text(summary, boq)
out: list[dict[str, Any]] = []
for i, spec in enumerate(TEMP_LAND_SPECS):
m2 = _area_m2_with_context(
ctx,
spec['kws'],
spec['base'],
scale,
seed,
i,
spec['lo'],
spec['hi'],
)
out.append(
{
'use': spec['use'],
'area_m2': m2,
'loc': spec['loc'],
'time': spec['time'],
}
)
return out
def _build_temp_land_table_md(title: str, summary: str, boq: str) -> str:
"""
临时用地表:面积(㎡) 给出具体整数;优先从摘要/清单中解析与用途相关的面积表述。
"""
clean = (title or '临时用地表').strip()
hint = _ctx_hint(summary, boq)
rows = compute_temp_land_rows(summary, boq)
header = '| 用途 | 面积(㎡) | 位置 | 需用时间 |'
sep = '|------|----------|------|----------|'
lines = [header, sep]
for r in rows:
lines.append(
'| {use} | {m2} | {loc} | {time} |'.format(
use=r['use'],
m2=r['area_m2'],
loc=r['loc'],
time=r['time'],
)
)
body = '\n'.join(lines)
return (
f'[TABLE:{clean}]\n'
f'{body}\n'
f'[/TABLE]\n'
f'(各项面积已按项目规模具体填写;若总平面布置或招标文件另有用地指标,以{hint}及审批总平面为准复核。)'
)
def _parse_qty_near_keywords(text: str, keywords: tuple[str, ...]) -> Optional[int]:
"""在含关键词的行中抓取「数字+台/套/辆」。"""
if not text or not keywords:
return None
for line in text.splitlines():
if not any(k in line for k in keywords):
continue
for m in re.finditer(r'(\d+)\s*(?:台|套|辆|组|把)', line):
n = int(m.group(1))
if 1 <= n <= 999:
return n
return None
def _parse_year_from_text(text: str) -> Optional[int]:
"""摘要/清单中出现的合理制造年份(近年出厂设备)。"""
years = [int(y) for y in re.findall(r'(20[1-2]\d)', text or '')]
if not years:
return None
y = max(years)
if 2015 <= y <= 2026:
return y
return None
def _pick_year(base_year: Optional[int], row_index: int, seed: int) -> int:
"""制造年份:优先项目文本;否则在近年区间内按行略作错落。"""
if base_year is not None:
return max(2018, min(base_year, 2025))
pool = [2019, 2020, 2021, 2022, 2023, 2024]
return pool[(seed + row_index * 17) % len(pool)]
def _qty_with_context(
text: str,
keywords: tuple[str, ...],
base_qty: int,
scale: float,
seed: int,
row_index: int,
) -> int:
hinted = _parse_qty_near_keywords(text, keywords)
if hinted is not None:
return max(1, min(hinted, 99))
q = int(round(base_qty * scale))
jitter = ((seed >> (row_index * 3)) & 3) - 1
q = max(1, q + jitter)
return min(q, 99)
# 主要施工设备行定义(设备表与总平面图「允许出现的机械」共用关键词)
MAIN_EQUIPMENT_SPECS: list[dict] = [
{
'name': '液压挖掘机',
'kws': ('挖掘机', '液压挖', '反铲', '土方机械'),
'q0': 4,
'models': ('SY235C-10', 'PC220-8MO', 'ZX240LC-5A'),
'powers': (125, 132, 128),
'cap': ('斗容约1.1m³', '斗容约1.0m³', '斗容约1.2m³'),
'part': '土方开挖、基坑与回填作业面',
'rmk': '自有或租赁按进度调配',
},
{
'name': '履带式推土机',
'kws': ('推土机', '平整'),
'q0': 2,
'models': ('SD16标准型', 'D6K LGP', 'TY160'),
'powers': (120, 104, 121),
'cap': ('额定功率匹配铲刀作业', '铲刀容量与土质匹配', '平整效率满足工序'),
'part': '场地平整、路基粗平',
'rmk': '与挖装设备流水衔接',
},
{
'name': '自卸汽车',
'kws': ('自卸', '渣土', '运输', '汽车'),
'q0': 10,
'models': ('东风天锦 15t', '重汽豪沃 18t', '解放J6P 20t'),
'powers': (180, 228, 265),
'cap': ('额定载重15t', '额定载重18t', '额定载重20t'),
'part': '土方、砂石料及混凝土运输',
'rmk': '道路与地磅条件满足通行',
},
{
'name': '轮式装载机',
'kws': ('装载机', '铲车'),
'q0': 3,
'models': ('ZL50GN', 'L956F', 'WA380-6'),
'powers': (162, 175, 142),
'cap': ('斗容约3.0m³', '斗容约2.8m³', '斗容约2.5m³'),
'part': '堆场装车、短驳与备料',
'rmk': '与运输车辆匹配',
},
{
'name': '振动压路机',
'kws': ('压路机', '碾压', '压实'),
'q0': 3,
'models': ('XS223J', 'CA30D', 'SR26M-C5'),
'powers': (118, 132, 129),
'cap': ('工作质量约22t', '激振力与遍数受控', '适用于基层与面层'),
'part': '路基、基层及面层压实',
'rmk': '试验段确定压实参数',
},
{
'name': '混凝土搅拌运输车',
'kws': ('搅拌车', '罐车', '商砼'),
'q0': 6,
'models': ('12m³ 陕汽底盘', '10m³ 三一重工', '12m³ 中联重科'),
'powers': (276, 257, 288),
'cap': ('几何容积12m³', '搅动与卸料满足泵送', 'GPS调度'),
'part': '商品混凝土水平运输',
'rmk': '与泵车浇筑节拍匹配',
},
{
'name': '汽车式起重机',
'kws': ('汽车吊', '起重机', '吊装'),
'q0': 2,
'models': ('QY25K5C 25t', 'STC250T4 25t', 'XCT25L5'),
'powers': (213, 206, 210),
'cap': ('最大起重量25t', '主臂长度满足构件', '支腿全伸作业'),
'part': '钢筋、模板及小型构件吊装',
'rmk': '专项方案与地基承载复核',
},
{
'name': '柴油发电机组',
'kws': ('发电机', '发电', '备用电源'),
'q0': 2,
'models': ('GF-200kW', 'GF-250kW', 'SC9D340D2'),
'powers': (200, 250, 308),
'cap': ('连续输出功率200kW', '连续输出功率250kW', '连续输出功率280kW'),
'part': '基坑降水、塔吊及高峰施工备用电',
'rmk': '一机一闸一漏一箱',
},
]
def _allowed_equipment_names(summary: str, boq: str, limit: int = 12) -> list[str]:
"""
总平面图允许绘制的机械:摘要/清单中出现与设备关键词匹配的机型名称;
不臆造未在资料中出现的设备类别。
"""
ctx = _project_text(summary, boq)
names: list[str] = []
for spec in MAIN_EQUIPMENT_SPECS:
if any(k in ctx for k in spec['kws']):
names.append(spec['name'])
# 清单中直接出现的机械用语(补充)
extra_tokens = (
('塔吊', '塔式起重机'),
('塔机', '塔式起重机'),
('施工电梯', '施工升降机'),
('泵车', '混凝土泵车'),
('车载泵', '混凝土泵车'),
('静压桩机', '静力压桩机'),
('旋挖钻', '旋挖钻机'),
('摊铺机', '沥青摊铺机'),
('铣刨机', '路面铣刨机'),
)
for tok, label in extra_tokens:
if tok in ctx and label not in names:
names.append(label)
seen: set[str] = set()
out: list[str] = []
for n in names:
if n not in seen:
seen.add(n)
out.append(n)
if len(out) >= limit:
break
return out
def _build_main_equipment_table_md(title: str, summary: str, boq: str) -> str:
"""
主要施工设备表:型号规格、数量、制造年份、定额功率等尽量给出具体数值;
优先从项目摘要/清单中解析台数线索,其余按工程规模系数与稳定随机偏移生成合理配置。
"""
clean = (title or '主要施工设备表').strip()
hint = _ctx_hint(summary, boq)
ctx = _project_text(summary, boq)
seed = _text_deterministic_seed(ctx)
scale = _equipment_scale_from_text(summary, boq)
year_hint = _parse_year_from_text(ctx)
specs = MAIN_EQUIPMENT_SPECS
header = (
'| 序号 | 机械或设备名称 | 型号规格 | 数量 | 国别产地 | 制造年份 | '
'定额功率KW | 生产能力 | 用于施工部位 | 备注 |'
)
sep = (
'|------|----------------|----------|------|----------|----------|'
'---------------|----------|--------------|------|'
)
lines = [header, sep]
for i, spec in enumerate(specs):
mi = (seed + i * 31) % len(spec['models'])
model = spec['models'][mi]
power = spec['powers'][mi]
cap = spec['cap'][mi]
qty = _qty_with_context(ctx, spec['kws'], spec['q0'], scale, seed, i)
qty_str = f'{qty}'
if spec['name'] in ('自卸汽车', '混凝土搅拌运输车'):
qty_str = f'{qty}'
elif spec['name'] in ('柴油发电机组',):
qty_str = f'{qty}'
year = _pick_year(year_hint, i, seed)
pwr_str = str(power)
lines.append(
'| {idx} | {name} | {model} | {qtys} | 中国 | {year} | {pwr} | {cap} | {part} | {rmk} |'.format(
idx=i + 1,
name=spec['name'],
model=model,
qtys=qty_str,
year=year,
pwr=pwr_str,
cap=cap,
part=spec['part'],
rmk=spec['rmk'],
)
)
body = '\n'.join(lines)
return (
f'[TABLE:{clean}]\n'
f'{body}\n'
f'[/TABLE]\n'
f'(型号、台数、功率等已按项目资料做具体化填写;若清单或技术条款另有约定,以{hint}为准最终核定。)'
)
def _instrument_used_hours(
year: int,
seed: int,
row_index: int,
powered: bool,
) -> str:
"""
已使用台时数:机动/电子类给具体整数(台时);非动力器具为 0。
随出厂年份与稳定种子变化,避免全表雷同。
"""
if not powered:
return '0'
ynow = date.today().year
age = max(0, min(ynow - year, 8))
base = 380 + (seed % 220) + row_index * 88
annual = 260 + ((seed >> (row_index + 2)) & 0xFF) % 240
h = int(base + age * annual)
h = max(160, min(h, 5600))
return str(h)
def _instrument_qty(
text: str,
keywords: tuple[str, ...],
base_qty: int,
scale: float,
seed: int,
row_index: int,
max_qty: int = 12,
) -> int:
hinted = _parse_qty_near_keywords(text, keywords)
if hinted is not None:
return max(1, min(hinted, max_qty))
q = int(round(base_qty * scale))
jitter = ((seed >> (row_index * 4)) & 3) - 1
q = max(1, q + jitter)
return min(q, max_qty)
def _build_test_instruments_table_md(title: str, summary: str, boq: str) -> str:
"""
试验和检测仪器设备表:型号规格、数量、制造年份、已使用台时数为具体数值;
台数优先从摘要/清单中含关键词的行解析。
"""
clean = (title or '试验和检测仪器设备表').strip()
hint = _ctx_hint(summary, boq)
ctx = _project_text(summary, boq)
seed = _text_deterministic_seed(ctx)
scale = _equipment_scale_from_text(summary, boq)
year_hint = _parse_year_from_text(ctx)
rows_spec: list[dict] = [
{
'name': '全站仪',
'kws': ('全站仪', '全站', '测量仪'),
'q0': 2,
'models': ('徕卡 TS09plus 1″', '南方 NTS-362R10', '苏一光 RTS112R10'),
'part': '施工控制测量、轴线与坐标放样',
'rmk': '检定合格且在有效期内',
'powered': True,
'unit': '',
},
{
'name': '水准仪',
'kws': ('水准仪', '水准'),
'q0': 3,
'models': ('苏一光 DSZ2', '徕卡 NA730', '天津赛博 DS32'),
'part': '高程传递、沉降与水准路线观测',
'rmk': 'i角定期校验',
'powered': True,
'unit': '',
},
{
'name': '钢卷尺',
'kws': ('钢卷尺', '卷尺'),
'q0': 8,
'models': ('5m Ⅰ级', '50m 标准钢卷尺', '30m Ⅰ级'),
'part': '距离量测、模板与预埋位置复核',
'rmk': '周期检定',
'powered': False,
'unit': '',
},
{
'name': '游标卡尺',
'kws': ('游标卡尺', '卡尺'),
'q0': 6,
'models': ('0150mm 0.02mm', '0200mm 0.02mm', '数显卡尺 0150mm'),
'part': '钢筋直径、螺栓与加工件尺寸抽检',
'rmk': '计量台账管理',
'powered': False,
'unit': '',
},
{
'name': '混凝土回弹仪',
'kws': ('回弹仪', '回弹'),
'q0': 2,
'models': ('ZC3-A 中回', 'HT-225A', '瑞士 Proceq Original Schmidt'),
'part': '结构混凝土强度现场抽检',
'rmk': '率定试验按期进行',
'powered': True,
'unit': '',
},
{
'name': '数字万用表',
'kws': ('万用表',),
'q0': 4,
'models': ('Fluke 117', '优利德 UT39C+', '胜利 VC890C+'),
'part': '临电线路、设备绝缘与电压电流测试',
'rmk': 'CAT III 安全等级满足现场',
'powered': True,
'unit': '',
},
{
'name': '接地电阻测试仪',
'kws': ('接地电阻', '接地测试'),
'q0': 2,
'models': ('ETCR2000A', 'Fluke 1623-2', '胜利 VC4105A'),
'part': '接地网、防雷及设备接地电阻测试',
'rmk': '三极法/钳形法按规范选用',
'powered': True,
'unit': '',
},
{
'name': '绝缘电阻测试仪',
'kws': ('绝缘电阻', '兆欧表'),
'q0': 2,
'models': ('Fluke 1507 1000V', '优利德 UT501A', '胜利 VC60B+'),
'part': '电缆、电机及配电回路绝缘测试',
'rmk': '送电前必测项目',
'powered': True,
'unit': '',
},
{
'name': '电液伺服压力试验机',
'kws': ('压力试验机', '试验机', '万能试验机'),
'q0': 1,
'models': ('YAW-3000 微机控制', 'WAW-1000 微机控制', 'YES-2000 数显'),
'part': '混凝土、砂浆试块抗压强度试验',
'rmk': '与标养室及见证取样制度配套',
'powered': True,
'unit': '',
},
{
'name': '超声波测厚仪',
'kws': ('测厚仪', '超声波测厚'),
'q0': 2,
'models': ('TT130 0.1mm', 'Olympus 38DL PLUS', '时代 TIME2130'),
'part': '钢管、钢板及防腐层厚度抽检',
'rmk': '耦合剂与探头匹配管材材质',
'powered': True,
'unit': '',
},
]
header = (
'| 序号 | 仪器设备名称 | 型号规格 | 数量 | 国别产地 | 制造年份 | '
'已使用台时数 | 用于施工部位 | 备注 |'
)
sep = (
'|------|--------------|----------|------|----------|----------|'
'--------------|--------------|------|'
)
lines = [header, sep]
for i, spec in enumerate(rows_spec):
mi = (seed + i * 29) % len(spec['models'])
model = spec['models'][mi]
qty_n = _instrument_qty(ctx, spec['kws'], spec['q0'], scale, seed, i, max_qty=15)
qty_str = f'{qty_n}{spec["unit"]}'
year = _pick_year(year_hint, i, seed)
hours = _instrument_used_hours(year, seed, i, spec['powered'])
rmk = spec['rmk']
lines.append(
'| {idx} | {name} | {model} | {qty} | 中国 | {year} | {hours} | {part} | {rmk} |'.format(
idx=i + 1,
name=spec['name'],
model=model,
qty=qty_str,
year=year,
hours=hours,
part=spec['part'],
rmk=rmk,
)
)
body = '\n'.join(lines)
return (
f'[TABLE:{clean}]\n'
f'{body}\n'
f'[/TABLE]\n'
f'型号、数量、出厂年份、已使用台时数已按项目资料具体填写机动器具台时为累计估值非机动为0最终以{hint}及设备台账、检定证书为准。)'
)
def _labor_peak_factor(summary: str, boq: str) -> float:
"""根据摘要/清单篇幅与关键词略调人数规模,避免所有项目同一套数字。"""
text = (summary or '') + (boq or '')
f = 1.0
if any(k in text for k in ('大规模', '重点工程', '总建筑面积', '线路长度', '合同额')):
f += 0.12
if any(k in text for k in ('小型', '维修', '改造', '零星')):
f -= 0.08
n = len(text)
if n > 2500:
f += 0.08
elif n < 400:
f -= 0.05
return max(0.82, min(f, 1.28))
# 各阶段人数基准(人):准备 / 建筑主体 / 临时工程 / 附属 / 收尾;峰值落在主体阶段
_LABOR_BASE_ROWS: list[tuple[str, tuple[int, int, int, int, int]]] = [
('测量工', (4, 8, 4, 6, 3)),
('挖掘机司机', (2, 8, 6, 2, 1)),
('装载机司机', (1, 5, 4, 2, 1)),
('自卸车司机', (2, 10, 6, 4, 2)),
('木工', (0, 8, 2, 4, 2)),
('砼工', (0, 20, 4, 8, 6)),
('钢筋工', (0, 18, 4, 8, 4)),
('电工', (3, 4, 3, 4, 3)),
('普工', (10, 30, 20, 20, 10)),
('试验工', (2, 4, 2, 3, 2)),
('仓管', (2, 3, 2, 3, 2)),
]
def _build_labor_plan_table_md(title: str, summary: str, boq: str) -> str:
clean = (title or '劳动力计划表').strip()
hint = _ctx_hint(summary, boq)
factor = _labor_peak_factor(summary, boq)
# 表头五列名称须与「按工程施工阶段投入劳动力情况」下子列一致(导出 Word 时做双层合并表头)
header = (
'| 工种 | 施工准备阶段 | 建筑工程施工阶段 | 临时工程施工阶段 | '
'其他附属相关工程 | 收尾阶段 |'
)
sep = '|------|--------------|------------------|------------------|------------------|----------|'
lines = [header, sep]
scaled_rows: list[tuple[int, int, int, int, int]] = []
for trade, nums in _LABOR_BASE_ROWS:
row = tuple(max(0, int(round(n * factor))) for n in nums)
scaled_rows.append(row)
a, b, c, d, e = row
lines.append(f'| {trade} | {a} | {b} | {c} | {d} | {e} |')
if scaled_rows:
tot = tuple(sum(r[j] for r in scaled_rows) for j in range(5))
lines.append(
f'| 合计 | {tot[0]} | {tot[1]} | {tot[2]} | {tot[3]} | {tot[4]} |'
)
body = '\n'.join(lines)
return (
f'[TABLE:{clean}]\n'
f'{body}\n'
f'[/TABLE]\n'
f'(上表为按施工阶段配置的**人数**估算,单位:人;高峰一般集中在建筑工程施工阶段,须结合工期与作业面以{hint}为准复核调整。)'
)
def _find_date_after_keyword(text: str, keywords: tuple[str, ...], window: int = 56) -> Optional[str]:
"""在关键词后若干字符内抓取 yyyy年mm月dd日 或 yyyy-mm-dd。"""
if not text:
return None
for kw in keywords:
start = 0
while True:
idx = text.find(kw, start)
if idx < 0:
break
frag = text[idx : idx + len(kw) + window]
dm = re.search(r'(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日', frag)
if dm:
return f'{dm.group(1)}{dm.group(2)}{dm.group(3)}'
dm2 = re.search(r'(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})', frag)
if dm2:
return f'{dm2.group(1)}{dm2.group(2)}{dm2.group(3)}'
start = idx + len(kw)
return None
def _parse_duration_calendar_days(text: str) -> Optional[int]:
if not text:
return None
for pat in (
r'(\d+)\s*日历天',
r'总工期[:为]?\s*(\d+)\s*天',
r'工期[(]?\s*含?[^)]*?[)]?\s*[:为]?\s*(\d+)\s*天',
r'计划工期[:为]?\s*(\d+)\s*天',
):
m = re.search(pat, text)
if m:
n = int(m.group(1))
if 1 <= n <= 3650:
return n
return None
def _parse_schedule_facts(summary: str, boq: str) -> dict[str, Any]:
"""从摘要+清单摘取开工、完工、日历天及里程碑行(摘不到则为空,禁止下游杜撰)。"""
ctx = _project_text(summary, boq)
start = _find_date_after_keyword(
ctx,
('计划开工', '开工日期', '拟定开工', '合同开工', '开工时间', '开工日'),
)
end = _find_date_after_keyword(
ctx,
('计划完工', '完工日期', '竣工日期', '交工日期', '合同完工', '竣工时间', '完工时间'),
)
duration = _parse_duration_calendar_days(ctx)
milestones: list[str] = []
for line in ctx.splitlines():
s = line.strip()
if any(
k in s
for k in (
'里程碑',
'节点工期',
'控制性节点',
'关键节点',
'主体封顶',
'竣工验收',
'中间验收',
)
):
if 8 < len(s) < 160:
milestones.append(s)
if len(milestones) >= 8:
break
return {
'start': start,
'end': end,
'duration_days': duration,
'milestones': milestones,
}
def _boq_line_cells(line: str) -> list[str]:
line = line.strip()
if line.startswith('|') and line.endswith('|'):
return [c.strip() for c in line[1:-1].split('|')]
return []
def _extract_boq_work_items(summary: str, boq: str, max_items: int = 14) -> list[str]:
"""
从工程量清单摘要/表格行中抽取可作为进度网络节点的分项名称。
仅使用文本中已出现的短语,不编造清单外工程。
"""
primary = (boq or '').strip() or (summary or '').strip()
secondary = (summary or '').strip() if primary != (summary or '').strip() else ''
candidates: list[str] = []
def _consume(text: str) -> None:
for raw in text.splitlines():
line = raw.strip()
if not line or len(line) < 4:
continue
if re.match(r'^\|[\s\-:|]+\|$', line):
continue
if line.startswith('|'):
cells = _boq_line_cells(line)
for c in cells:
c = c.replace('**', '').strip()
if 4 <= len(c) <= 80 and re.search(r'[\u4e00-\u9fff]', c):
if re.match(r'^(序号|编码|项目编码|名称|单位|工程量|单价|合价|分部|分项)$', c):
continue
if re.match(r'^[\d\.%]+$|^第?[一二三四五六七八九十]+[章节部分项、]', c):
continue
candidates.append(c)
continue
m = re.match(r'^[\d\.]+\s+(.+)', line)
if m:
frag = m.group(1).strip()[:80]
if re.search(r'[\u4e00-\u9fff]', frag):
candidates.append(frag)
continue
if re.match(r'^[(]?[一二三四五六七八九十百零〇\d]+[)]\s*[、..]?\s*.+', line) and len(line) < 100:
candidates.append(line[:80])
_consume(primary)
if secondary:
_consume(secondary)
seen: set[str] = set()
out: list[str] = []
for c in candidates:
key = c.strip()
if key and key not in seen:
seen.add(key)
out.append(key)
if len(out) >= max_items:
break
return out
def _schedule_figure_body(
clean: str,
hint: str,
sch: dict[str, Any],
nodes: list[str],
) -> str:
start_s = sch.get('start')
end_s = sch.get('end')
nd = sch.get('duration_days')
milestones = sch.get('milestones') or []
lines: list[str] = [
f'[FIGURE:{clean}]',
'【编制原则】进度网络/横道须与招标文件计划工期及工程量清单工作范围一致;下列日期、日历天与节点均**仅摘自**已解析的项目摘要与清单摘要,摘要未出现的**不得臆造**。',
'【计划工期(摘自资料)】',
]
if start_s:
lines.append(f'· 计划开工日期(摘录):{start_s}')
else:
lines.append('· 计划开工日期:摘要/清单未摘录到明确日期的,以招标文件、补遗书及合同协议书为准,**本图不填写具体开工日**。')
if end_s:
lines.append(f'· 计划完工/竣工日期(摘录):{end_s}')
else:
lines.append('· 计划完工/竣工日期:同上,以招标文件明示为准,**本图不填写具体完工日**。')
if nd:
lines.append(f'· 总工期(摘录):{nd} 日历天')
else:
lines.append('· 总工期:摘要未摘录日历天数的,**不绘制具体日历刻度**,仅表示工序逻辑先后。')
if milestones:
lines.append('【控制性节点/里程碑(摘自资料原文)】')
for m in milestones[:6]:
lines.append(f'· {m}')
else:
lines.append('【控制性节点】资料未摘录专项里程碑条目的,不强行列举;实施阶段按监理与招标人批复计划执行。')
lines.append('【工程量清单工作项与网络节点(图中工序名称仅限下列,禁止增加清单外内容)】')
if nodes:
for i, n in enumerate(nodes, 1):
lines.append(f'{i}. {n}')
else:
lines.append(
'(清单摘要未解析到分项名称时:网络图仅表示「施工准备 → 清单列项施工 → 验收移交」三阶段逻辑关系,'
'具体分项以招标工程量清单及图纸为准。)'
)
lines.append('【施工进度逻辑示意】')
if nodes:
chain = ''.join(nodes[:10])
lines.append(f'逻辑关系(示意):开始 → {chain} → 结束')
else:
lines.append('逻辑关系(示意):开始 → 施工准备 → 清单工程施工 → 竣工验收与移交 → 结束')
ndays = sch.get('duration_days')
if ndays and isinstance(ndays, int) and nodes:
lines.append(
f'横道比例说明:总控制工期 {ndays} 日历天(摘自资料);下列横道仅为相对比例示意,条形长度不代表未在招标文件中给出的细部作业日历。'
)
width = 36
n_show = min(len(nodes), 8)
for i, node in enumerate(nodes[:n_show]):
frac = (i + 1) / (n_show + 1)
filled = max(1, min(width, int(round(width * frac))))
bar = '' * filled + '' * (width - filled)
label = node[:18] + ('' if len(node) > 18 else '')
lines.append(f'{label:<20} {bar}')
elif nodes:
lines.append('(未摘录总日历天:横道仅表示先后顺序,不标注天数刻度。)')
for i, node in enumerate(nodes[:10], 1):
lines.append(f' {i}. {node}')
lines.append(f'(编制依据摘要:{hint}')
lines.append('[/FIGURE]')
return '\n'.join(lines)
def _build_schedule_figure_md(title: str, summary: str, boq: str) -> str:
clean = (title or '施工进度计划网络图').strip()
hint = _ctx_hint(summary, boq)
sch = _parse_schedule_facts(summary, boq)
nodes = _extract_boq_work_items(summary, boq)
return _schedule_figure_body(clean, hint, sch, nodes)
def _site_layout_ascii(zones: list[str]) -> str:
"""极简 ASCII仅标注已给出的用地用途名称。"""
if not zones:
return '(临时用地分区见临时用地表)'
z = zones[:8]
top = ''.join(f'[{a}]' for a in z[:4])
bot = ''
if len(z) > 4:
bot = ''.join(f'[{a}]' for a in z[4:])
parts = [top]
if bot:
parts.append('')
parts.append(' ' + bot)
return '\n'.join(parts)
def _build_site_layout_figure_md(title: str, summary: str, boq: str) -> str:
clean = (title or '施工总平面图').strip()
hint = _ctx_hint(summary, boq)
land_rows = compute_temp_land_rows(summary, boq)
equip = _allowed_equipment_names(summary, boq)
lines: list[str] = [
f'[FIGURE:{clean}]',
'【编制原则】总平面示意须与《临时用地表》分区及面积一致;图中出现的施工机械**仅限**下列清单,禁止绘制与项目资料无关的塔吊、挖掘机等设备剪影。',
'【临时用地分区与面积(与临时用地表同口径)】',
]
for r in land_rows:
lines.append(f'· {r["use"]}{r["area_m2"]}㎡;位置:{r["loc"]};时间:{r["time"]}')
lines.append('【允许绘制的施工机械(仅下列;与摘要/清单及拟投入设备语义匹配)】')
if equip:
for e in equip:
lines.append(f'· {e}')
else:
lines.append(
'· 资料未检出明确机械类别:总平面图中**不绘制具体机械外形**,仅保留「机械停放区」块状示意,设备以《主要施工设备表》及招标清单最终表述为准。'
)
zone_labels = [str(r['use']) for r in land_rows]
lines.append('【相对位置关系示意(非比例尺;分区名称与上表一致)】')
lines.append(_site_layout_ascii(zone_labels))
lines.append(
'文字要点:环场道路、门禁、办公生活区与作业区隔离、堆场与加工区相对位置、排水沉淀、消防设施布置等须符合招标文件及用地表;'
f'具体坐标以审批总平面为准。(依据摘要:{hint}'
)
lines.append('[/FIGURE]')
return '\n'.join(lines)
def build_standard_appendix_markdown(
kind: str,
title: str,
summary: str = '',
boq_summary: str = '',
) -> str:
"""生成完整单块 markdown含 [TABLE] 或 [FIGURE])。"""
clean = (title or '附件').strip()
hint = _ctx_hint(summary, boq_summary)
if kind == 'main_equipment':
return _build_main_equipment_table_md(clean, summary, boq_summary)
if kind == 'test_instruments':
return _build_test_instruments_table_md(clean, summary, boq_summary)
if kind == 'labor_plan':
return _build_labor_plan_table_md(clean, summary, boq_summary)
if kind == 'temp_land':
return _build_temp_land_table_md(clean, summary, boq_summary)
if kind == 'schedule_chart':
return _build_schedule_figure_md(clean, summary, boq_summary)
if kind == 'site_layout':
return _build_site_layout_figure_md(clean, summary, boq_summary)
return ''
def is_mandatory_bid_appendix(title: str) -> bool:
"""是否为应强制输出图/表块的标准附件(与「正文可选图表」开关解耦)。"""
return classify_standard_appendix(title) is not None