Punch List Manager for Construction Closeout
Complete system for managing construction punch lists from creation through final acceptance.
Business Case
Problem: Punch list management is inefficient:
-
Paper lists get lost or outdated
-
Difficult to track completion status
-
Photos disconnected from items
-
Back-charges delayed due to poor documentation
-
Multiple walks create duplicate items
Solution: Digital punch list system that:
-
Creates items with photos and location markup
-
Assigns to responsible parties with deadlines
-
Tracks completion with before/after photos
-
Generates back-charge documentation
-
Provides real-time completion dashboards
ROI: 50% faster closeout, 80% reduction in disputed back-charges
Punch List Workflow
┌──────────────────────────────────────────────────────────────────────┐ │ PUNCH LIST WORKFLOW │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ CREATION ASSIGNMENT COMPLETION │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Walk │────────►│ Assign │────────►│ Correct │ │ │ │ Site │ │ Items │ │ Items │ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Log │ │ Notify │ │ Submit │ │ │ │ Items │ │ Parties │ │ Photo │ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ Photo │ │ Set │ │ Mark │ │ │ │ + Tag │ │ Deadline│ │ Complete│ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ │ │ ▼ │ │ VERIFICATION CLOSEOUT ┌─────────┐ │ │ ┌─────────┐ ┌─────────┐ │ Verify │ │ │ │ Re-walk │◄────────│ Accept │◄───────│ Work │ │ │ │ Site │ │ Items │ └─────────┘ │ │ └─────────┘ └─────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────┐ ┌─────────┐ │ │ │ New │ │ Final │ │ │ │ Items? │────NO──►│ Accept │ │ │ └────┬────┘ └─────────┘ │ │ │YES │ │ └──────────────► Back to CREATION │ │ │ └──────────────────────────────────────────────────────────────────────┘
Data Structure
from dataclasses import dataclass, field from datetime import datetime, date from enum import Enum from typing import List, Optional import uuid
class PunchItemStatus(Enum): OPEN = "Open" ASSIGNED = "Assigned" IN_PROGRESS = "In Progress" READY_FOR_VERIFICATION = "Ready for Verification" VERIFIED = "Verified" REJECTED = "Rejected" ACCEPTED = "Accepted"
class PunchItemPriority(Enum): CRITICAL = "Critical" # Life safety / code compliance HIGH = "High" # Affects occupancy MEDIUM = "Medium" # Standard punch LOW = "Low" # Minor / cosmetic OBSERVATION = "Observation"
class TradeCategory(Enum): GENERAL = "General Contractor" ELECTRICAL = "Electrical" PLUMBING = "Plumbing" HVAC = "HVAC" FIRE_PROTECTION = "Fire Protection" DRYWALL = "Drywall/Painting" FLOORING = "Flooring" MILLWORK = "Millwork/Casework" GLAZING = "Glazing" ROOFING = "Roofing" SITEWORK = "Sitework" LANDSCAPING = "Landscaping" CONTROLS = "Controls/BMS" OTHER = "Other"
@dataclass class PunchItem: item_id: str punch_list_id: str description: str location: str trade: TradeCategory priority: PunchItemPriority
# Location details
building: str = ""
floor: str = ""
room: str = ""
# Assignment
assigned_to: str = ""
assigned_date: date = None
due_date: date = None
# Documentation
photo_before: str = ""
photo_after: str = ""
drawing_markup: str = ""
spec_reference: str = ""
# Status tracking
status: PunchItemStatus = PunchItemStatus.OPEN
created_by: str = ""
created_date: date = field(default_factory=date.today)
# Completion
completed_by: str = ""
completed_date: date = None
completion_notes: str = ""
# Verification
verified_by: str = ""
verified_date: date = None
verification_notes: str = ""
# Back-charge
back_charge: bool = False
back_charge_amount: float = 0.0
back_charge_ref: str = ""
# History
history: List[dict] = field(default_factory=list)
@dataclass class PunchList: list_id: str project_id: str name: str walk_date: date walk_attendees: List[str]
items: List[PunchItem] = field(default_factory=list)
status: str = "Active" # Active, Complete
created_by: str = ""
created_date: date = field(default_factory=date.today)
area: str = "" # Building/floor/zone covered
list_type: str = "Punch" # Punch, Pre-Punch, Final
Python Implementation
import pandas as pd from datetime import datetime, date, timedelta from typing import List, Dict, Optional from collections import defaultdict
class PunchListManager: """Construction punch list management system"""
def __init__(self, project_id: str, storage_path: str = None):
self.project_id = project_id
self.storage_path = storage_path or f"punch_{project_id}"
self.punch_lists: Dict[str, PunchList] = {}
self.items: Dict[str, PunchItem] = {}
def create_punch_list(
self,
name: str,
walk_date: date,
attendees: List[str],
area: str = "",
list_type: str = "Punch",
created_by: str = ""
) -> PunchList:
"""Create new punch list from walk"""
list_id = f"PL-{datetime.now().strftime('%Y%m%d%H%M%S')}"
punch_list = PunchList(
list_id=list_id,
project_id=self.project_id,
name=name,
walk_date=walk_date,
walk_attendees=attendees,
area=area,
list_type=list_type,
created_by=created_by
)
self.punch_lists[list_id] = punch_list
return punch_list
def add_item(
self,
punch_list_id: str,
description: str,
location: str,
trade: TradeCategory,
priority: PunchItemPriority = PunchItemPriority.MEDIUM,
building: str = "",
floor: str = "",
room: str = "",
photo_before: str = "",
drawing_markup: str = "",
spec_reference: str = "",
created_by: str = ""
) -> PunchItem:
"""Add item to punch list"""
if punch_list_id not in self.punch_lists:
raise ValueError(f"Punch list {punch_list_id} not found")
# Generate item ID
punch_list = self.punch_lists[punch_list_id]
item_num = len(punch_list.items) + 1
item_id = f"{punch_list_id}-{item_num:04d}"
item = PunchItem(
item_id=item_id,
punch_list_id=punch_list_id,
description=description,
location=location,
trade=trade,
priority=priority,
building=building,
floor=floor,
room=room,
photo_before=photo_before,
drawing_markup=drawing_markup,
spec_reference=spec_reference,
created_by=created_by
)
# Add history entry
item.history.append({
'date': datetime.now(),
'action': 'Created',
'by': created_by,
'notes': ''
})
self.items[item_id] = item
punch_list.items.append(item)
return item
def assign_item(
self,
item_id: str,
assigned_to: str,
due_date: date = None,
assigned_by: str = ""
) -> PunchItem:
"""Assign item to responsible party"""
item = self.items.get(item_id)
if not item:
raise ValueError(f"Item {item_id} not found")
if due_date is None:
# Default due dates by priority
days = {
PunchItemPriority.CRITICAL: 1,
PunchItemPriority.HIGH: 3,
PunchItemPriority.MEDIUM: 7,
PunchItemPriority.LOW: 14,
PunchItemPriority.OBSERVATION: 30
}
due_date = date.today() + timedelta(days=days.get(item.priority, 7))
item.assigned_to = assigned_to
item.assigned_date = date.today()
item.due_date = due_date
item.status = PunchItemStatus.ASSIGNED
item.history.append({
'date': datetime.now(),
'action': 'Assigned',
'by': assigned_by,
'notes': f'Assigned to {assigned_to}, due {due_date}'
})
# Trigger notification
self._notify_assignment(item)
return item
def mark_complete(
self,
item_id: str,
completed_by: str,
photo_after: str = "",
completion_notes: str = ""
) -> PunchItem:
"""Mark item as completed by trade"""
item = self.items.get(item_id)
if not item:
raise ValueError(f"Item {item_id} not found")
item.completed_by = completed_by
item.completed_date = date.today()
item.photo_after = photo_after
item.completion_notes = completion_notes
item.status = PunchItemStatus.READY_FOR_VERIFICATION
item.history.append({
'date': datetime.now(),
'action': 'Completed',
'by': completed_by,
'notes': completion_notes
})
return item
def verify_item(
self,
item_id: str,
verified_by: str,
accepted: bool,
notes: str = ""
) -> PunchItem:
"""Verify completed item"""
item = self.items.get(item_id)
if not item:
raise ValueError(f"Item {item_id} not found")
item.verified_by = verified_by
item.verified_date = date.today()
item.verification_notes = notes
if accepted:
item.status = PunchItemStatus.ACCEPTED
action = 'Accepted'
else:
item.status = PunchItemStatus.REJECTED
action = 'Rejected'
# Re-assign for rework
item.assigned_date = date.today()
item.due_date = date.today() + timedelta(days=3)
item.history.append({
'date': datetime.now(),
'action': action,
'by': verified_by,
'notes': notes
})
return item
def add_back_charge(
self,
item_id: str,
amount: float,
reference: str = ""
) -> PunchItem:
"""Add back-charge to item"""
item = self.items.get(item_id)
if not item:
raise ValueError(f"Item {item_id} not found")
item.back_charge = True
item.back_charge_amount = amount
item.back_charge_ref = reference
item.history.append({
'date': datetime.now(),
'action': 'Back Charge',
'by': '',
'notes': f'Amount: ${amount:.2f}, Ref: {reference}'
})
return item
def get_items_by_trade(self, trade: TradeCategory) -> List[PunchItem]:
"""Get all items for a specific trade"""
return [i for i in self.items.values() if i.trade == trade]
def get_items_by_status(self, status: PunchItemStatus) -> List[PunchItem]:
"""Get items by status"""
return [i for i in self.items.values() if i.status == status]
def get_overdue_items(self) -> List[PunchItem]:
"""Get overdue items"""
today = date.today()
return [
i for i in self.items.values()
if i.status in [PunchItemStatus.OPEN, PunchItemStatus.ASSIGNED, PunchItemStatus.IN_PROGRESS]
and i.due_date and i.due_date < today
]
def get_statistics(self) -> dict:
"""Get punch list statistics"""
all_items = list(self.items.values())
if not all_items:
return {'total': 0}
by_status = defaultdict(int)
by_trade = defaultdict(lambda: {'total': 0, 'open': 0})
by_priority = defaultdict(int)
for item in all_items:
by_status[item.status.value] += 1
by_trade[item.trade.value]['total'] += 1
if item.status not in [PunchItemStatus.ACCEPTED, PunchItemStatus.VERIFIED]:
by_trade[item.trade.value]['open'] += 1
by_priority[item.priority.value] += 1
# Calculate completion rate
accepted = len([i for i in all_items if i.status == PunchItemStatus.ACCEPTED])
completion_rate = accepted / len(all_items) * 100 if all_items else 0
# Back charges
back_charge_items = [i for i in all_items if i.back_charge]
total_back_charges = sum(i.back_charge_amount for i in back_charge_items)
return {
'total': len(all_items),
'by_status': dict(by_status),
'by_trade': dict(by_trade),
'by_priority': dict(by_priority),
'completion_rate': round(completion_rate, 1),
'overdue': len(self.get_overdue_items()),
'back_charge_count': len(back_charge_items),
'back_charge_total': total_back_charges
}
def generate_trade_report(self, trade: TradeCategory) -> str:
"""Generate report for specific trade"""
items = self.get_items_by_trade(trade)
report = f"""
╔══════════════════════════════════════════════════════════════╗ ║ PUNCH LIST - {trade.value.upper():<30} ║ ║ Project: {self.project_id:<40} ║ ║ Date: {date.today().strftime('%d.%m.%Y'):<43} ║ ╠══════════════════════════════════════════════════════════════╣
Total Items: {len(items)} Open: {len([i for i in items if i.status not in [PunchItemStatus.ACCEPTED]])} Due Today: {len([i for i in items if i.due_date == date.today()])} Overdue: {len([i for i in items if i.due_date and i.due_date < date.today() and i.status not in [PunchItemStatus.ACCEPTED]])}
ITEMS REQUIRING ACTION ─────────────────────────────────────────────────────────────── """ for item in items: if item.status not in [PunchItemStatus.ACCEPTED]: overdue_flag = "🔴" if item.due_date and item.due_date < date.today() else "" report += f""" {overdue_flag} [{item.item_id}] {item.priority.value} Location: {item.location} Description: {item.description} Status: {item.status.value} Due: {item.due_date} """
report += """
╚══════════════════════════════════════════════════════════════╝ """ return report
def generate_summary_dashboard(self) -> str:
"""Generate overall punch list dashboard"""
stats = self.get_statistics()
report = f"""
╔══════════════════════════════════════════════════════════════════╗ ║ PUNCH LIST DASHBOARD ║ ║ Project: {self.project_id:<40} ║ ║ Date: {date.today().strftime('%d.%m.%Y'):<43} ║ ╠══════════════════════════════════════════════════════════════════╣
📊 OVERALL STATUS ─────────────────────────────────────────────────────────────────── Total Items: {stats['total']} Completion Rate: {stats['completion_rate']}% Overdue Items: {stats['overdue']}
📈 BY STATUS ─────────────────────────────────────────────────────────────────── """ for status, count in stats['by_status'].items(): bar = "█" * int(count / max(stats['by_status'].values()) * 20) if stats['by_status'] else "" report += f" {status:<25} {count:>5} {bar}\n"
report += """
🔧 BY TRADE (Open Items) ─────────────────────────────────────────────────────────────────── """ for trade, data in sorted(stats['by_trade'].items(), key=lambda x: x[1]['open'], reverse=True): if data['open'] > 0: report += f" {trade:<25} {data['open']:>5} open / {data['total']} total\n"
report += f"""
💰 BACK CHARGES ─────────────────────────────────────────────────────────────────── Items with Back Charges: {stats['back_charge_count']} Total Back Charges: ${stats['back_charge_total']:,.2f}
╚══════════════════════════════════════════════════════════════════╝ """ return report
def _notify_assignment(self, item: PunchItem):
"""Send notification for assigned item"""
print(f"📋 Punch item assigned: {item.item_id}")
print(f" To: {item.assigned_to}")
print(f" Due: {item.due_date}")
print(f" Location: {item.location}")
def export_to_excel(self, output_path: str) -> str:
"""Export punch list to Excel"""
records = []
for item in self.items.values():
records.append({
'Item ID': item.item_id,
'Description': item.description,
'Location': item.location,
'Building': item.building,
'Floor': item.floor,
'Room': item.room,
'Trade': item.trade.value,
'Priority': item.priority.value,
'Status': item.status.value,
'Assigned To': item.assigned_to,
'Due Date': item.due_date,
'Completed By': item.completed_by,
'Completed Date': item.completed_date,
'Back Charge': 'Yes' if item.back_charge else 'No',
'Back Charge Amount': item.back_charge_amount if item.back_charge else '',
'Photo Before': item.photo_before,
'Photo After': item.photo_after
})
df = pd.DataFrame(records)
df.to_excel(output_path, index=False)
return output_path
Usage Example
if name == "main": # Initialize manager manager = PunchListManager(project_id="PROJECT-2026-001")
# Create punch list from walk
punch_list = manager.create_punch_list(
name="Floor 5 Pre-Final Walk",
walk_date=date.today(),
attendees=["PM", "Architect", "GC Super"],
area="Building A, Floor 5",
list_type="Pre-Final",
created_by="PM"
)
# Add items
item1 = manager.add_item(
punch_list_id=punch_list.list_id,
description="Touch up paint at door frame Room 501",
location="Room 501, door frame",
trade=TradeCategory.DRYWALL,
priority=PunchItemPriority.LOW,
building="A",
floor="5",
room="501",
created_by="PM"
)
item2 = manager.add_item(
punch_list_id=punch_list.list_id,
description="Missing cover plate on electrical outlet",
location="Room 502, east wall",
trade=TradeCategory.ELECTRICAL,
priority=PunchItemPriority.MEDIUM,
building="A",
floor="5",
room="502",
created_by="PM"
)
# Assign items
manager.assign_item(
item_id=item1.item_id,
assigned_to="ABC Painting",
assigned_by="GC Super"
)
manager.assign_item(
item_id=item2.item_id,
assigned_to="XYZ Electric",
due_date=date.today() + timedelta(days=2),
assigned_by="GC Super"
)
# Mark complete
manager.mark_complete(
item_id=item1.item_id,
completed_by="ABC Painting",
completion_notes="Paint touched up"
)
# Verify
manager.verify_item(
item_id=item1.item_id,
verified_by="PM",
accepted=True,
notes="Looks good"
)
# Generate reports
print(manager.generate_summary_dashboard())
print(manager.generate_trade_report(TradeCategory.ELECTRICAL))
Telegram Bot Integration
name: Punch List Bot commands: /newitem: steps: - Ask: Photo of deficiency - Ask: Location (Building/Floor/Room) - Ask: Description - Ask: Trade (show buttons) - Ask: Priority (show buttons) - Confirm and create item
/myitems: - Show open items assigned to user - Buttons: [Mark Complete] [View Details]
/complete: - Select item from list - Ask for completion photo - Ask for notes - Submit for verification
/dashboard: - Show summary statistics - Open items by trade - Overdue items
"The last 10% of punch takes 50% of the time. Start early, stay organized."