Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,189 +1,484 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 6 |
from pathlib import Path
|
|
|
|
|
|
|
| 7 |
|
| 8 |
import gradio as gr
|
| 9 |
import pandas as pd
|
| 10 |
-
|
| 11 |
-
from
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
try:
|
| 28 |
-
progress(0.
|
| 29 |
-
if
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
shutil.copy2(boq_file, boq_path)
|
| 39 |
-
|
| 40 |
-
# Copy SOR if provided
|
| 41 |
-
sor_path = None
|
| 42 |
-
if sor_file:
|
| 43 |
-
sor_path = Path(temp_dir) / f"sor{Path(sor_file.name).suffix}"
|
| 44 |
-
shutil.copy2(sor_file, sor_path)
|
| 45 |
-
|
| 46 |
-
progress(0.20, desc="Parsing BOQ...")
|
| 47 |
-
boq_df, boq_meta = BOQEngine.load_boq(str(boq_path))
|
| 48 |
-
|
| 49 |
-
# tender id from content, fallback to job_id
|
| 50 |
-
tender_id = None
|
| 51 |
-
for col in boq_df.columns:
|
| 52 |
-
if "tender" in col.lower() and "id" in col.lower():
|
| 53 |
-
val = boq_df[col].dropna().iloc[0] if not boq_df[col].dropna().empty else None
|
| 54 |
-
if val and re.search(r"\d{6,}", str(val)):
|
| 55 |
-
tender_id = re.search(r"\d{6,}", str(val)).group()
|
| 56 |
-
break
|
| 57 |
-
if not tender_id:
|
| 58 |
-
tender_id = job_id
|
| 59 |
-
|
| 60 |
-
text_sample = " ".join(boq_df.iloc[:5, :5].astype(str).values.flatten())
|
| 61 |
-
_, end_date = BDParsers.extract_dates_bd(text_sample)
|
| 62 |
-
|
| 63 |
-
progress(0.40, desc="Loading SOR...")
|
| 64 |
sor_rates = {}
|
| 65 |
-
if
|
| 66 |
try:
|
| 67 |
-
sor_rates =
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
f
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
except Exception as e:
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
return out, "Done"
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def ui_finalize(tid, zone, reviewer, notes):
|
| 130 |
-
tid = (tid or "").strip()
|
| 131 |
-
if not tid:
|
| 132 |
-
raise gr.Error("Provide a Tender ID (same as shown in Dashboard).")
|
| 133 |
-
df, summary = finalize_tender(tid, zone or None, reviewer=reviewer or "Reviewer", notes=notes or "")
|
| 134 |
-
return df, summary
|
| 135 |
-
|
| 136 |
-
|
| 137 |
def create_ui():
|
| 138 |
theme = gr.themes.Soft(primary_hue="blue", secondary_hue="slate")
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
return demo
|
| 185 |
|
| 186 |
-
|
|
|
|
|
|
|
| 187 |
if __name__ == "__main__":
|
|
|
|
| 188 |
demo = create_ui()
|
| 189 |
-
demo.launch(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
๐ง๐ฉ Bangladesh e-Procurement Automation Pro
|
| 4 |
+
Single-File Gradio App for Hugging Face Spaces
|
| 5 |
+
BWDB/LGED/WDB Tender Processing | BOQ โ Excel/Word โ ZIP
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os, re, uuid, shutil, tempfile, zipfile, json
|
| 9 |
from pathlib import Path
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from typing import List, Dict, Any, Optional, Tuple
|
| 12 |
|
| 13 |
import gradio as gr
|
| 14 |
import pandas as pd
|
| 15 |
+
import numpy as np
|
| 16 |
+
from openpyxl import Workbook
|
| 17 |
+
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers
|
| 18 |
+
from openpyxl.utils import get_column_letter
|
| 19 |
+
from docx import Document
|
| 20 |
+
from pdfplumber import open as pdf_open
|
| 21 |
+
from loguru import logger
|
| 22 |
+
|
| 23 |
+
# ============================================================================
|
| 24 |
+
# CONFIGURATION
|
| 25 |
+
# ============================================================================
|
| 26 |
+
logger.remove()
|
| 27 |
+
logger.add(lambda msg: print(msg, flush=True), level="INFO", format="{time:HH:mm:ss} | {level} | {message}")
|
| 28 |
+
|
| 29 |
+
CONFIG = {
|
| 30 |
+
"zones": ["Zone-A", "Zone-B", "Zone-C", "Zone-D", "Zone-E"],
|
| 31 |
+
"default_zone": "Zone-A",
|
| 32 |
+
"currency_fmt": '#,##0.00',
|
| 33 |
+
"qty_fmt": '#,##0.000',
|
| 34 |
+
"pct_fmt": '0.00%',
|
| 35 |
+
"bdt": 'Tk. ',
|
| 36 |
+
"categories": {
|
| 37 |
+
"Earthwork": ["earth work", "excavation", "filling", "cutting", "bundh", "ditch", "leveling", "dredg"],
|
| 38 |
+
"Filtering Works": ["filter", "sand", "geotextile", "jhama chips", "chips", "non-woven", "khoa"],
|
| 39 |
+
"C.C Blocks": ["c.c. block", "concrete block", "cement concrete", "1:3:5.5", "1:2:4", "block size", "manufacturing"],
|
| 40 |
+
"Geo-Bags": ["geo-textile bag", "geobag", "geo-bag", "geo tube", "wt: 250kg", "wt: 125kg"],
|
| 41 |
+
"Laying & Dumping": ["laying", "dumping", "placing", "barge", "total station", "labour charge"],
|
| 42 |
+
"Others": []
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# ============================================================================
|
| 47 |
+
# UTILITIES
|
| 48 |
+
# ============================================================================
|
| 49 |
+
def clean_val(val: Any, as_float: bool = False) -> Any:
|
| 50 |
+
if val is None: return None
|
| 51 |
+
s = str(val).strip()
|
| 52 |
+
if not s or s.lower() in ['-', 'na', 'n/a', 'null', 'none', '']: return None
|
| 53 |
+
if as_float:
|
| 54 |
+
s = re.sub(r'[^\d.\-]', '', s.replace(',', '').replace('Tk.', '').replace('BDT', ''))
|
| 55 |
+
try: return float(s) if s else None
|
| 56 |
+
except: return None
|
| 57 |
+
return s
|
| 58 |
+
|
| 59 |
+
def categorize_item(desc: str, code: str) -> str:
|
| 60 |
+
txt = (desc or " " + " " + (code or "")).lower()
|
| 61 |
+
for cat, kws in CONFIG['categories'].items():
|
| 62 |
+
if any(kw in txt for kw in kws): return cat
|
| 63 |
+
return "Others"
|
| 64 |
+
|
| 65 |
+
def extract_dates(text: str) -> Tuple[str, str]:
|
| 66 |
+
dates = re.findall(r'(\d{2}-[A-Za-z]{3}-\d{4})', text)
|
| 67 |
+
if len(dates) >= 2: return dates[0], dates[1]
|
| 68 |
+
elif dates: return dates[0], (datetime.strptime(dates[0], "%d-%b-%Y") + timedelta(days=730)).strftime("%d-%b-%Y")
|
| 69 |
+
return "15-Oct-2024", "31-May-2026"
|
| 70 |
+
|
| 71 |
+
# ============================================================================
|
| 72 |
+
# BOQ PROCESSING
|
| 73 |
+
# ============================================================================
|
| 74 |
+
def load_boq(file_path: str) -> pd.DataFrame:
|
| 75 |
+
ext = Path(file_path).suffix.lower()
|
| 76 |
+
if ext in ['.xlsx', '.xls']:
|
| 77 |
+
return pd.read_excel(file_path)
|
| 78 |
+
elif ext == '.pdf':
|
| 79 |
+
tables = []
|
| 80 |
+
with pdf_open(file_path) as pdf:
|
| 81 |
+
for page in pdf.pages:
|
| 82 |
+
tables.extend(page.extract_tables() or [])
|
| 83 |
+
if not tables: raise ValueError("No tables found in BOQ PDF")
|
| 84 |
+
return pd.DataFrame(tables[0][1:], columns=tables[0][0])
|
| 85 |
+
raise ValueError("Unsupported BOQ format")
|
| 86 |
+
|
| 87 |
+
def load_sor(file_path: str, zone: str) -> Dict[str, float]:
|
| 88 |
+
df = pd.read_excel(file_path)
|
| 89 |
+
df.columns = [str(c).strip() for c in df.columns]
|
| 90 |
+
zone_col = next((c for c in df.columns if zone.lower() in c.lower()), None)
|
| 91 |
+
if not zone_col:
|
| 92 |
+
code_col = next((c for c in df.columns if 'code' in c.lower()), df.columns[0])
|
| 93 |
+
idx = df.columns.get_loc(code_col)
|
| 94 |
+
if idx + 1 < len(df.columns): zone_col = df.columns[idx + 1]
|
| 95 |
+
if not zone_col: raise ValueError(f"Zone '{zone}' column not found in SOR")
|
| 96 |
+
df = df.dropna(subset=[zone_col])
|
| 97 |
+
df['code_norm'] = df.iloc[:, 0].astype(str).str.strip().str.upper()
|
| 98 |
+
df[zone_col] = pd.to_numeric(df[zone_col], errors='coerce')
|
| 99 |
+
return dict(zip(df['code_norm'], df[zone_col]))
|
| 100 |
+
|
| 101 |
+
def process_boq(df: pd.DataFrame, sor_rates: Dict[str, float], zone: str) -> List[Dict]:
|
| 102 |
+
cols = [c.lower().strip() for c in df.columns]
|
| 103 |
+
cmap = {}
|
| 104 |
+
for std in ['item no.', 'item code', 'item description', 'unit', 'quantity', 'bwdb rate', 'quoted rate']:
|
| 105 |
+
matches = [c for c in df.columns if std.replace('.', '').replace(' ', '') in c.lower().replace('.', '').replace(' ', '')]
|
| 106 |
+
if matches: cmap[std] = matches[0]
|
| 107 |
+
df = df.rename(columns=cmap)
|
| 108 |
+
|
| 109 |
+
items = []
|
| 110 |
+
for _, row in df.iterrows():
|
| 111 |
+
item = {
|
| 112 |
+
'item_no': clean_val(row.get('item no.')),
|
| 113 |
+
'item_code': clean_val(row.get('item code')),
|
| 114 |
+
'description': clean_val(row.get('item description')),
|
| 115 |
+
'unit': clean_val(row.get('unit')),
|
| 116 |
+
'quantity': clean_val(row.get('quantity'), as_float=True) or 0,
|
| 117 |
+
'bwdb_rate': clean_val(row.get('bwdb rate'), as_float=True) or 0,
|
| 118 |
+
'quoted_rate': clean_val(row.get('quoted rate'), as_float=True) or 0,
|
| 119 |
+
}
|
| 120 |
+
if item['quoted_rate'] == 0 and item['item_code']:
|
| 121 |
+
item['quoted_rate'] = sor_rates.get(str(item['item_code']).strip().upper(), item['bwdb_rate'])
|
| 122 |
+
item['bwdb_amount'] = item['quantity'] * item['bwdb_rate'] if item['bwdb_rate'] else 0
|
| 123 |
+
item['quoted_amount'] = item['quantity'] * item['quoted_rate'] if item['quoted_rate'] else 0
|
| 124 |
+
item['diff_pct'] = ((item['quoted_rate'] - item['bwdb_rate']) / item['bwdb_rate'] * 100) if item['bwdb_rate'] else 0
|
| 125 |
+
item['category'] = categorize_item(item['description'], item['item_code'])
|
| 126 |
+
|
| 127 |
+
# CFT conversion for BWDB items
|
| 128 |
+
if item['category'] == 'Earthwork' and item['quantity']: item['cft_equiv'] = item['quantity'] * 35.315
|
| 129 |
+
elif item['category'] == 'C.C Blocks' and item['quantity']: item['cft_equiv'] = item['quantity'] * 1.13
|
| 130 |
+
if item.get('cft_equiv') and item['quoted_amount']: item['rate_per_cft'] = item['quoted_amount'] / item['cft_equiv']
|
| 131 |
+
items.append(item)
|
| 132 |
+
return items
|
| 133 |
+
|
| 134 |
+
# ============================================================================
|
| 135 |
+
# EXCEL GENERATION (quot/sum_quot/Analysis)
|
| 136 |
+
# ============================================================================
|
| 137 |
+
def create_quot_tab(wb, items: List[Dict], tender_id: str, pkg: str, loc: str, sec: str, wt: str, start: str, end: str, pct: float):
|
| 138 |
+
ws = wb.active
|
| 139 |
+
ws.title = "quot"
|
| 140 |
+
ws.merge_cells('A1:C1'); ws.cell(1, 1).value = f"Tender ID: {tender_id}"; ws.cell(1, 1).font = Font(bold=True, size=12)
|
| 141 |
+
ws.merge_cells('D1:F1'); ws.cell(1, 4).value = f"Quoted Rate: {pct}%"; ws.cell(1, 4).font = Font(bold=True)
|
| 142 |
+
|
| 143 |
+
ws.append(["Package No.", "Lot Description", "Location", "Tender Security", "Work Time", "Quoted Rate (%)"])
|
| 144 |
+
hdr = ws.max_row
|
| 145 |
+
for c in range(1, 7):
|
| 146 |
+
cell = ws.cell(hdr, c); cell.font = Font(bold=True, color="FFFFFF"); cell.fill = PatternFill(start_color="5B9BD5", end_color="5B9BD5", fill_type="solid")
|
| 147 |
+
cell.alignment = Alignment(horizontal='center'); cell.border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
| 148 |
+
|
| 149 |
+
ws.append([pkg, (loc or "")[:80], sec, sec, f"{start} to {end}", f"{pct}%"])
|
| 150 |
+
for c in [4, 6]: ws.cell(ws.max_row, c).number_format = CONFIG['pct_fmt'] if c == 6 else CONFIG['currency_fmt']
|
| 151 |
+
|
| 152 |
+
ws.append([])
|
| 153 |
+
headers = ["Item No.", "Item Code", "Item Description", "Quantity", "Unit", "BWDB Rate", "BWDB Amount", "Quoted Rate", "Quoted Amount", "%"]
|
| 154 |
+
ws.append(headers); hdr = ws.max_row
|
| 155 |
+
for c in range(1, 11):
|
| 156 |
+
cell = ws.cell(hdr, c); cell.font = Font(bold=True, color="FFFFFF"); cell.fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
|
| 157 |
+
cell.alignment = Alignment(horizontal='center'); cell.border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
| 158 |
+
|
| 159 |
+
start_row = hdr + 1
|
| 160 |
+
for it in items:
|
| 161 |
+
ws.append([it['item_no'], it['item_code'], (it['description'] or "")[:60], it['quantity'], it['unit'], it['bwdb_rate'], it['bwdb_amount'], it['quoted_rate'], it['quoted_amount'], it['diff_pct']])
|
| 162 |
+
r = ws.max_row
|
| 163 |
+
for c in [4, 7, 9]:
|
| 164 |
+
if ws.cell(r, c).value is not None: ws.cell(r, c).number_format = CONFIG['currency_fmt']
|
| 165 |
+
if ws.cell(r, 10).value is not None:
|
| 166 |
+
ws.cell(r, 10).number_format = CONFIG['pct_fmt']
|
| 167 |
+
if it['diff_pct'] < 0: ws.cell(r, 10).fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
|
| 168 |
+
elif it['diff_pct'] > 0: ws.cell(r, 10).fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
|
| 169 |
+
|
| 170 |
+
last = ws.max_row
|
| 171 |
+
ws.merge_cells(f'A{last+1}:C{last+1}'); ws.cell(last+1, 1).value = "TOTAL"; ws.cell(last+1, 1).font = Font(bold=True)
|
| 172 |
+
ws.cell(last+1, 7).value = f"=SUM(G{start_row}:G{last})"; ws.cell(last+1, 7).number_format = CONFIG['currency_fmt']
|
| 173 |
+
ws.cell(last+1, 9).value = f"=SUM(I{start_row}:I{last})"; ws.cell(last+1, 9).number_format = CONFIG['currency_fmt']
|
| 174 |
+
ws.cell(last+1, 10).value = f"=(I{last+1}-G{last+1})/G{last+1}"; ws.cell(last+1, 10).number_format = CONFIG['pct_fmt']
|
| 175 |
+
for c in [1, 7, 9, 10]: ws.cell(last+1, c).fill = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
| 176 |
+
for i, w in enumerate([6, 12, 55, 12, 8, 12, 15, 12, 15, 10], 1): ws.column_dimensions[get_column_letter(i)].width = w
|
| 177 |
+
ws.auto_filter.ref = f"A{hdr}:J{last+1}"
|
| 178 |
+
|
| 179 |
+
def create_sum_quot_tab(wb, items: List[Dict]):
|
| 180 |
+
ws = wb.create_sheet("sum_quot")
|
| 181 |
+
ws.merge_cells('A1:K1'); ws.cell(1, 1).value = "Main Item Short Listing (Quoted Rate)"; ws.cell(1, 1).font = Font(bold=True, size=12)
|
| 182 |
+
ws.append(["Category", "Total Quantity", "Unit Price (Avg)", "Total Price", "cft Equiv", "Rate/cft"])
|
| 183 |
+
hdr = ws.max_row
|
| 184 |
+
for c in range(1, 7):
|
| 185 |
+
cell = ws.cell(hdr, c); cell.font = Font(bold=True, color="FFFFFF"); cell.fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
|
| 186 |
+
cell.alignment = Alignment(horizontal='center'); cell.border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
| 187 |
+
|
| 188 |
+
grouped = {}
|
| 189 |
+
for it in items:
|
| 190 |
+
cat = it['category']
|
| 191 |
+
if cat not in grouped: grouped[cat] = {"qty": 0, "amt": 0, "cft": 0}
|
| 192 |
+
grouped[cat]["qty"] += it['quantity']
|
| 193 |
+
grouped[cat]["amt"] += it['quoted_amount'] or 0
|
| 194 |
+
if it.get('cft_equiv'): grouped[cat]["cft"] += it['cft_equiv']
|
| 195 |
+
|
| 196 |
+
row = hdr + 1
|
| 197 |
+
for cat in ['Earthwork', 'Filtering Works', 'C.C Blocks', 'Geo-Bags', 'Laying & Dumping', 'Others']:
|
| 198 |
+
if cat not in grouped: continue
|
| 199 |
+
data = grouped[cat]
|
| 200 |
+
avg_rate = data["amt"] / data["qty"] if data["qty"] else 0
|
| 201 |
+
rate_cft = data["amt"] / data["cft"] if data["cft"] else None
|
| 202 |
+
ws.append([cat, data["qty"], avg_rate, data["amt"], data["cft"], rate_cft])
|
| 203 |
+
r = ws.max_row
|
| 204 |
+
for c in [3, 4, 6]:
|
| 205 |
+
if ws.cell(r, c).value is not None: ws.cell(r, c).number_format = CONFIG['currency_fmt']
|
| 206 |
+
row = r
|
| 207 |
+
for i, w in enumerate([25, 18, 18, 20, 12, 15], 1): ws.column_dimensions[get_column_letter(i)].width = w
|
| 208 |
+
|
| 209 |
+
def create_analysis_tab(wb, items: List[Dict]):
|
| 210 |
+
ws = wb.create_sheet("Analysis")
|
| 211 |
+
ws.merge_cells('A1:F1'); ws.cell(1, 1).value = "CC BLOCK CASTING (1:3:5.5): 40mm stone chips, sand> 1.5"; ws.cell(1, 1).font = Font(bold=True)
|
| 212 |
+
ws.merge_cells('A2:F2'); ws.cell(2, 1).value = "Analysis basis: 100 cft"
|
| 213 |
+
|
| 214 |
+
headers = ["", "Materials, Equipments & Labor", "Volume", "Basic", "Rate", "Amount"]
|
| 215 |
+
ws.append(headers); row = ws.max_row
|
| 216 |
+
for c in range(1, 7):
|
| 217 |
+
cell = ws.cell(row, c); cell.font = Font(bold=True, color="FFFFFF"); cell.fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
|
| 218 |
+
cell.alignment = Alignment(horizontal='center'); cell.border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
| 219 |
+
|
| 220 |
+
costs = [("Stone Chips [25-40 mm]", "90.00", "Cft@Tk.", "195.00", "=C4*E4"),
|
| 221 |
+
("Cement: PCC", "13.00", "Bags@Tk.", "495.00", "=C5*E5"),
|
| 222 |
+
("Sand: FM>=1.5", "50.00", "Cft@Tk.", "40.00", "=C6*E6"),
|
| 223 |
+
("Diesel/Mobil/Polyethene, etc", "1.00", "L.S.", "3000.00", "=E7")]
|
| 224 |
+
|
| 225 |
+
for label, vol, basis, rate, formula in costs:
|
| 226 |
+
ws.append(["", label, float(vol) if vol.replace('.','').isdigit() else vol, basis, float(rate) if rate.replace('.','').isdigit() else rate, formula])
|
| 227 |
+
r = ws.max_row
|
| 228 |
+
for c in range(1, 7): ws.cell(r, c).border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
|
| 229 |
+
if c in [3, 5, 6]: ws.cell(r, c).alignment = Alignment(horizontal='right')
|
| 230 |
+
ws.cell(r, 6).number_format = CONFIG['currency_fmt']
|
| 231 |
+
|
| 232 |
+
row = ws.max_row + 1
|
| 233 |
+
ws.merge_cells(f'A{row}:B{row}'); ws.cell(row, 1).value = "Per 100 Cft rate"; ws.cell(row, 6).value = "=SUM(F4:F7)"; ws.cell(row, 6).number_format = CONFIG['currency_fmt']; ws.cell(row, 6).font = Font(bold=True)
|
| 234 |
+
row += 1; ws.merge_cells(f'A{row}:B{row}'); ws.cell(row, 1).value = "Per Cft rate"; ws.cell(row, 6).value = "=F8/100"; ws.cell(row, 6).number_format = CONFIG['currency_fmt']
|
| 235 |
+
row += 1; ws.merge_cells(f'A{row}:B{row}'); ws.cell(row, 1).value = "VAT, IT & Others (27%)"; ws.cell(row, 6).value = "=F9*0.27"; ws.cell(row, 6).number_format = CONFIG['currency_fmt']
|
| 236 |
+
row += 1; ws.merge_cells(f'A{row}:B{row}'); ws.cell(row, 1).value = "Per Cft rate with VAT"; ws.cell(row, 1).font = Font(bold=True); ws.cell(row, 6).value = "=F9+F10"; ws.cell(row, 6).number_format = CONFIG['currency_fmt']; ws.cell(row, 6).font = Font(bold=True)
|
| 237 |
+
|
| 238 |
+
for i, w in enumerate([5, 45, 12, 12, 12, 15], 1): ws.column_dimensions[get_column_letter(i)].width = w
|
| 239 |
+
|
| 240 |
+
def generate_excel(items: List[Dict], tender_id: str, pkg: str, loc: str, sec: str, wt: str, start: str, end: str, pct: float, out_path: str):
|
| 241 |
+
wb = Workbook()
|
| 242 |
+
if "Sheet" in wb.sheetnames: del wb["Sheet"]
|
| 243 |
+
create_quot_tab(wb, items, tender_id, pkg, loc, sec, wt, start, end, pct)
|
| 244 |
+
create_sum_quot_tab(wb, items)
|
| 245 |
+
create_analysis_tab(wb, items)
|
| 246 |
+
wb.properties.title = f"BOQ-{tender_id}"; wb.properties.created = datetime.now()
|
| 247 |
+
wb.save(out_path)
|
| 248 |
+
|
| 249 |
+
# ============================================================================
|
| 250 |
+
# WORD TEMPLATE FILLING
|
| 251 |
+
# ============================================================================
|
| 252 |
+
def fill_docx(template_path: str, replacements: Dict[str, str], out_path: str) -> str:
|
| 253 |
+
doc = Document(template_path)
|
| 254 |
+
for ph, val in replacements.items():
|
| 255 |
+
target = f"{{{ph}}}"
|
| 256 |
+
for p in doc.paragraphs:
|
| 257 |
+
if target in p.text: p.text = p.text.replace(target, str(val))
|
| 258 |
+
for t in doc.tables:
|
| 259 |
+
for row in t.rows:
|
| 260 |
+
for cell in row.cells:
|
| 261 |
+
if target in cell.text: cell.text = cell.text.replace(target, str(val))
|
| 262 |
+
doc.save(out_path)
|
| 263 |
+
return out_path
|
| 264 |
+
|
| 265 |
+
def fill_templates(template_dir: Optional[str], replacements: Dict[str, str], out_dir: str) -> Dict[str, str]:
|
| 266 |
+
results = {}
|
| 267 |
+
if not template_dir or not Path(template_dir).exists(): return results
|
| 268 |
+
for tmpl in Path(template_dir).glob("*.docx"):
|
| 269 |
+
out = Path(out_dir) / f"{replacements.get('TENDER_ID', 'TENDER')}_{tmpl.name}"
|
| 270 |
+
fill_docx(str(tmpl), replacements, str(out))
|
| 271 |
+
results[tmpl.name] = str(out)
|
| 272 |
+
return results
|
| 273 |
+
|
| 274 |
+
# ============================================================================
|
| 275 |
+
# WORK PLAN GENERATOR
|
| 276 |
+
# ============================================================================
|
| 277 |
+
def generate_work_plan(start: str, end: str, out_path: str):
|
| 278 |
+
try: s = datetime.strptime(start, "%d-%b-%Y")
|
| 279 |
+
except: s = datetime(2024, 10, 15)
|
| 280 |
+
try: e = datetime.strptime(end, "%d-%b-%Y")
|
| 281 |
+
except: e = s + timedelta(days=730)
|
| 282 |
+
|
| 283 |
+
wb = Workbook(); ws = wb.active; ws.title = "Work Schedule"
|
| 284 |
+
ws.merge_cells('A1:E1'); ws.cell(1, 1).value = f"Work Schedule: {start} to {end}"; ws.cell(1, 1).font = Font(bold=True)
|
| 285 |
+
|
| 286 |
+
months = []; curr = s.replace(day=1)
|
| 287 |
+
while curr <= e: months.append(curr.strftime("%b-%Y")); curr = (curr + timedelta(days=32)).replace(day=1)
|
| 288 |
+
|
| 289 |
+
activities = ["Site Preparation & Mobilization", "Procurement of Manpower, Equipment, Materials", "Earthworks",
|
| 290 |
+
"Dumping & Placing Geo-Textile Bags", "Manufacturing of C.C. Blocks", "Supply of Sand Filter, Geo-Textile",
|
| 291 |
+
"Dumping & Placing C.C Blocks", "CC Work", "De-mobilization & Site Handover"]
|
| 292 |
+
|
| 293 |
+
row = 3; ws.append(["Sl. No.", "Activity"] + months)
|
| 294 |
+
for idx, act in enumerate(activities, 1):
|
| 295 |
+
row += 1; ws.cell(row, 1).value = chr(96 + idx) if idx <= 26 else f"{idx}"; ws.cell(row, 2).value = act
|
| 296 |
+
start_m = min(idx, len(months) - 6)
|
| 297 |
+
for i in range(start_m, min(start_m + 6, len(months))): ws.cell(row, i+3).value = "โ"; ws.cell(row, i+3).alignment = Alignment(horizontal='center')
|
| 298 |
+
|
| 299 |
+
for i in range(1, 3 + len(months)): ws.column_dimensions[get_column_letter(i)].width = 8 if i > 2 else (5 if i == 1 else 45)
|
| 300 |
+
wb.save(out_path)
|
| 301 |
+
|
| 302 |
+
# ============================================================================
|
| 303 |
+
# MAIN PIPELINE
|
| 304 |
+
# ============================================================================
|
| 305 |
+
def process_tender(boq_file, sor_file, zone="Zone-A", template_dir=None, progress=gr.Progress()):
|
| 306 |
+
temp = tempfile.mkdtemp(prefix="bd_tender_")
|
| 307 |
+
out_dir = Path(temp) / "outputs"; out_dir.mkdir()
|
| 308 |
+
|
| 309 |
try:
|
| 310 |
+
progress(0.1, desc="๐ฅ Loading BOQ...")
|
| 311 |
+
df = load_boq(boq_file.name if hasattr(boq_file, 'name') else boq_file)
|
| 312 |
+
|
| 313 |
+
# Extract tender metadata
|
| 314 |
+
text_sample = ' '.join(df.iloc[:5, :5].astype(str).values.flatten())
|
| 315 |
+
tender_id = re.search(r'\d{6,}', text_sample)
|
| 316 |
+
tender_id = tender_id.group() if tender_id else str(uuid.uuid4())[:8]
|
| 317 |
+
start_date, end_date = extract_dates(text_sample)
|
| 318 |
+
|
| 319 |
+
progress(0.3, desc="๐ Matching SOR Rates...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
sor_rates = {}
|
| 321 |
+
if sor_file:
|
| 322 |
try:
|
| 323 |
+
sor_rates = load_sor(sor_file.name if hasattr(sor_file, 'name') else sor_file, zone)
|
| 324 |
+
except Exception as e: logger.warning(f"SOR loading failed: {e}")
|
| 325 |
+
|
| 326 |
+
progress(0.5, desc="๐งฎ Processing Items...")
|
| 327 |
+
items = process_boq(df, sor_rates, zone)
|
| 328 |
+
if not items: raise ValueError("No BOQ items extracted - check file format")
|
| 329 |
+
|
| 330 |
+
# Calculate totals
|
| 331 |
+
total_quoted = sum(i['quoted_amount'] or 0 for i in items)
|
| 332 |
+
total_bwdb = sum(i['bwdb_amount'] or 0 for i in items)
|
| 333 |
+
savings = total_bwdb - total_quoted
|
| 334 |
+
|
| 335 |
+
progress(0.7, desc="๐ Generating Excel...")
|
| 336 |
+
excel_path = out_dir / f"BOQ_{tender_id}.xlsx"
|
| 337 |
+
generate_excel(items, tender_id, "PKG-01", "Site", "0.00", start_date, start_date, end_date, -1.37, str(excel_path))
|
| 338 |
+
|
| 339 |
+
progress(0.8, desc="๐
Generating Work Plan...")
|
| 340 |
+
wp_path = out_dir / f"WorkPlan_{tender_id}.xlsx"
|
| 341 |
+
generate_work_plan(start_date, end_date, str(wp_path))
|
| 342 |
+
|
| 343 |
+
# Fill Word templates
|
| 344 |
+
word_files = {}
|
| 345 |
+
replacements = {
|
| 346 |
+
"TENDER_ID": tender_id, "PACKAGE": "PKG-01", "WORK": "River bank protection work",
|
| 347 |
+
"DEADLINE": end_date, "SECURITY": "7000000.00", "ZONE": zone,
|
| 348 |
+
"DIVISION": "Bhola O&M Division-1", "DATE": datetime.now().strftime("%d-%b-%Y"),
|
| 349 |
+
"COMPANY": "HB-NT JV", "BANK": "SBAC Bank Limited"
|
| 350 |
+
}
|
| 351 |
+
if template_dir:
|
| 352 |
+
progress(0.9, desc="๐ Filling Templates...")
|
| 353 |
+
word_files = fill_templates(template_dir, replacements, str(out_dir))
|
| 354 |
+
|
| 355 |
+
progress(1.0, desc="๐ฆ Packaging...")
|
| 356 |
+
zip_path = Path(temp) / f"BD_Tender_{tender_id}.zip"
|
| 357 |
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
| 358 |
+
for f in out_dir.rglob("*"):
|
| 359 |
+
if f.is_file(): zf.write(f, f.relative_to(out_dir))
|
| 360 |
+
|
| 361 |
+
summary = f"""โ
BD e-Procurement Processing Complete!
|
| 362 |
+
|
| 363 |
+
๐ Tender: {tender_id} | Zone: {zone}
|
| 364 |
+
๐ Items: {len(items)} | Total Quoted: {CONFIG['bdt']}{total_quoted:,.2f}
|
| 365 |
+
๐ฐ BWDB Total: {CONFIG['bdt']}{total_bwdb:,.2f} | Savings: {CONFIG['bdt']}{savings:,.2f}
|
| 366 |
+
|
| 367 |
+
๐ Generated Files:
|
| 368 |
+
โข BOQ_{tender_id}.xlsx (quot/sum_quot/Analysis tabs)
|
| 369 |
+
โข WorkPlan_{tender_id}.xlsx (Gantt chart)
|
| 370 |
+
{chr(10).join(f"โข {k}" for k in word_files.keys()) if word_files else ""}
|
| 371 |
+
|
| 372 |
+
โจ Features Applied:
|
| 373 |
+
โ Zone-based rate matching ({zone})
|
| 374 |
+
โ BD BOQ categorization (Earthwork, C.C Blocks, Geo-Bags, etc.)
|
| 375 |
+
โ cft conversions & per-cft rates
|
| 376 |
+
โ Professional Excel formatting (Tk. currency, %)
|
| 377 |
+
โ Word template placeholder replacement ({{TENDER_ID}}, {{ZONE}}, etc.)
|
| 378 |
+
โ Work Plan Gantt chart generation
|
| 379 |
+
|
| 380 |
+
โฑ Processing completed successfully!
|
| 381 |
+
"""
|
| 382 |
+
return summary, str(zip_path)
|
| 383 |
+
|
| 384 |
except Exception as e:
|
| 385 |
+
logger.exception(f"Pipeline failed: {e}")
|
| 386 |
+
error_msg = f"""โ Processing Failed
|
| 387 |
+
|
| 388 |
+
Error: {str(e)}
|
| 389 |
+
|
| 390 |
+
๐ก Troubleshooting:
|
| 391 |
+
โข Ensure BOQ has columns: Item Code, Description, Quantity, Unit, Rate
|
| 392 |
+
โข Schedule of Rates must have 'Item Code' + Zone columns (Zone-A, Zone-B, etc.)
|
| 393 |
+
โข Word templates must use {{PLACEHOLDER}} syntax (case-sensitive)
|
| 394 |
+
โข File size limit: 25MB per file
|
| 395 |
+
|
| 396 |
+
Enable DEBUG_MODE=true in Space Settings for detailed logs.
|
| 397 |
+
"""
|
| 398 |
+
return error_msg, None
|
| 399 |
+
finally:
|
| 400 |
+
shutil.rmtree(temp, ignore_errors=True)
|
| 401 |
+
|
| 402 |
+
# ============================================================================
|
| 403 |
+
# GRADIO UI (Gradio 6.0+ Compatible)
|
| 404 |
+
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
def create_ui():
|
| 406 |
theme = gr.themes.Soft(primary_hue="blue", secondary_hue="slate")
|
| 407 |
+
custom_css = """
|
| 408 |
+
.bd-header { background: linear-gradient(135deg, #006a4e, #f42a41); color: white; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
| 409 |
+
.upload-card { border: 2px dashed #cbd5e1; border-radius: 10px; padding: 1.2rem; background: #f8fafc; }
|
| 410 |
+
.status-box { background: #f1f5f9; padding: 0.8rem; border-radius: 6px; font-family: monospace; white-space: pre-wrap; }
|
| 411 |
+
"""
|
| 412 |
+
|
| 413 |
+
with gr.Blocks(title="๐ง๐ฉ BD e-Procurement Automation Pro") as demo:
|
| 414 |
+
gr.Markdown("""<div class="bd-header"><h1>๐๏ธ Bangladesh e-Procurement Automation Pro</h1><p>BWDB/LGED/WDB Tender Processing | BOQ โ Excel/Word โ ZIP Package</p></div>""")
|
| 415 |
+
|
| 416 |
+
with gr.Row():
|
| 417 |
+
with gr.Column(scale=1):
|
| 418 |
+
with gr.Group(elem_classes="upload-card"):
|
| 419 |
+
gr.Markdown("**๐ Required Files**")
|
| 420 |
+
boq_file = gr.File(label="๐ BOQ (Excel/PDF)", file_types=[".xlsx", ".xls", ".pdf"], description="Bill of Quantities with Item Code, Description, Quantity, Unit, Rate")
|
| 421 |
+
sor_file = gr.File(label="๐ Schedule of Rates (Excel)", file_types=[".xlsx", ".xls"], description="Must contain 'Item Code' + Zone columns (Zone-A, Zone-B, etc.)")
|
| 422 |
+
zone = gr.Dropdown(choices=CONFIG['zones'], value=CONFIG['default_zone'], label="๐ Select Zone for Rate Matching")
|
| 423 |
+
|
| 424 |
+
with gr.Group(elem_classes="upload-card"):
|
| 425 |
+
gr.Markdown("**๐ Optional Templates**")
|
| 426 |
+
template_dir = gr.File(label="Word Templates Folder", file_types=[".docx"], description="Upload JV_DEED, POA, BG, Credit Line, etc. with {{PLACEHOLDERS}}")
|
| 427 |
+
|
| 428 |
+
process_btn = gr.Button("๐ Process & Download Package", variant="primary", size="lg")
|
| 429 |
+
|
| 430 |
+
with gr.Column(scale=1):
|
| 431 |
+
gr.Markdown("### ๐ค Output Package")
|
| 432 |
+
status = gr.Textbox(label="Processing Status", lines=10, interactive=False, elem_classes=["status-box"])
|
| 433 |
+
out_zip = gr.File(label="๐ฆ Download Complete ZIP", interactive=False)
|
| 434 |
+
gr.Markdown("""
|
| 435 |
+
**๐ Package Includes:**
|
| 436 |
+
โข `BOQ_<ID>.xlsx` โ quot/sum_quot/Analysis tabs with formulas
|
| 437 |
+
โข `WorkPlan_<ID>.xlsx` โ Monthly Gantt chart
|
| 438 |
+
โข Filled Word docs โ JV_DEED, POA, BG, Credit Line, etc.
|
| 439 |
+
|
| 440 |
+
**โจ BD-Specific Features:**
|
| 441 |
+
โข Zone-A/B/C/D/E rate matching
|
| 442 |
+
โข Earthwork/C.C Blocks/Geo-Bags categorization
|
| 443 |
+
โข cft conversions & per-cft rate calculations
|
| 444 |
+
โข Tk. currency formatting & % variance highlighting
|
| 445 |
+
โข BWDB/LGED item code validation
|
| 446 |
+
""")
|
| 447 |
+
|
| 448 |
+
process_btn.click(fn=process_tender, inputs=[boq_file, sor_file, zone, template_dir], outputs=[status, out_zip])
|
| 449 |
+
|
| 450 |
+
gr.Markdown("""---
|
| 451 |
+
### ๐ Word Template Placeholders
|
| 452 |
+
Use these in your Word templates:
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
### ๐ ๏ธ Troubleshooting
|
| 456 |
+
โข **BOQ not parsing?** Ensure columns: Item Code, Description, Quantity, Unit, Rate
|
| 457 |
+
โข **Zone not found?** SOR must have column named 'Zone-A', 'Zone-B', etc.
|
| 458 |
+
โข **Template not filling?** Use `{{PLACEHOLDER}}` syntax (case-sensitive, double braces)
|
| 459 |
+
โข **Timeout?** Reduce file size or upgrade to HF Pro tier
|
| 460 |
+
""")
|
| 461 |
+
|
| 462 |
return demo
|
| 463 |
|
| 464 |
+
# ============================================================================
|
| 465 |
+
# LAUNCH
|
| 466 |
+
# ============================================================================
|
| 467 |
if __name__ == "__main__":
|
| 468 |
+
logger.info("๐ง๐ฉ Starting BD e-Procurement Automation Pro")
|
| 469 |
demo = create_ui()
|
| 470 |
+
demo.launch(
|
| 471 |
+
server_name="0.0.0.0",
|
| 472 |
+
server_port=int(os.environ.get("PORT", 7860)),
|
| 473 |
+
root_path=os.environ.get("SPACE_PREFIX", ""),
|
| 474 |
+
share=False,
|
| 475 |
+
show_error=False,
|
| 476 |
+
max_threads=2,
|
| 477 |
+
# โ
Gradio 6.0+ compatibility: theme/css in launch()
|
| 478 |
+
theme=gr.themes.Soft(primary_hue="blue", secondary_hue="slate"),
|
| 479 |
+
css="""
|
| 480 |
+
.bd-header { background: linear-gradient(135deg, #006a4e, #f42a41); color: white; padding: 1rem; border-radius: 8px; margin-bottom: 1rem; }
|
| 481 |
+
.upload-card { border: 2px dashed #cbd5e1; border-radius: 10px; padding: 1.2rem; background: #f8fafc; }
|
| 482 |
+
.status-box { background: #f1f5f9; padding: 0.8rem; border-radius: 6px; font-family: monospace; white-space: pre-wrap; }
|
| 483 |
+
"""
|
| 484 |
+
)
|