diff --git a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js index 7c9f5ed1a1..3c10101488 100644 --- a/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js +++ b/src/bonsai/bonsai/bim/data/webui/static/js/utilities/costui.js @@ -250,6 +250,14 @@ export class CostUI { }, }); + CostUI.addRibbonButton({ + text: "Download CSV", + icon: "fa-solid fa-file-csv", + callback: () => { + CostUI.downloadCsv(); + }, + }); + CostUI.addRibbonButton({ text: "Hide Schedules", icon: "fa-regular fa-eye-slash", @@ -523,6 +531,81 @@ export class CostUI { } } + static downloadCsv() { + const tables = document.querySelectorAll("table[id^='cost-items-']"); + if (tables.length === 0) { + alert("No cost schedule loaded to export!"); + return; + } + tables.forEach((table) => { + const scheduleId = table.id.split("-").pop(); + const csv = CostUI.tableToCsv(table); + if (csv === null) { + return; + } + const nameEl = document.querySelector( + "#cost-schedule-container-" + scheduleId + " .form-header span" + ); + const scheduleName = nameEl + ? nameEl.textContent + : "cost_schedule_" + scheduleId; + CostUI.triggerCsvDownload(csv, scheduleName + ".csv"); + }); + } + + static tableToCsv(table) { + const escapeCsvCell = (value) => { + const text = (value === null || value === undefined ? "" : value) + .toString() + .trim(); + if (/[",\n]/.test(text)) { + return '"' + text.replace(/"/g, '""') + '"'; + } + return text; + }; + + const cellText = (cell) => { + const input = cell.querySelector("input"); + return input ? input.value : cell.innerText; + }; + + // The Actions column only holds buttons (edit/delete/etc), not data. + const isDataColumn = (column) => column && column !== "Actions"; + + const headerCells = Array.from(table.querySelectorAll("thead th")).filter( + (th) => isDataColumn(th.getAttribute("data-column")) + ); + if (headerCells.length === 0) { + return null; + } + + const rows = [headerCells.map((th) => escapeCsvCell(th.textContent)).join(",")]; + + table.querySelectorAll("tbody tr").forEach((row) => { + const cells = Array.from(row.children).filter((cell) => + isDataColumn(cell.getAttribute("data-column")) + ); + if (cells.length === 0) { + return; // e.g. the "No cost items found" placeholder row. + } + rows.push(cells.map((cell) => escapeCsvCell(cellText(cell))).join(",")); + }); + + return rows.join("\n"); + } + + static triggerCsvDownload(csvContent, filename) { + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + static createCostTable({ costSchedule, currency, callbacks }) { const preferences = CostUI.getColumnPreferences(); const isScheduleOfRates = costSchedule.PredefinedType === "SCHEDULEOFRATES"; diff --git a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py index 9db4a45385..622fefa211 100644 --- a/src/ifc5d/ifc5d/ifc5Dspreadsheet.py +++ b/src/ifc5d/ifc5d/ifc5Dspreadsheet.py @@ -386,9 +386,22 @@ class Ifc5Dwriter: "PredefinedType": cost_schedule.PredefinedType, } - # Bookkeeping columns needed for the .csv round trip (csv2ifc) but noise - # in presentation formats (.ods / .xlsx). - INTERNAL_COLUMNS = ("Id", "ItemIsASum", "Hierarchy", "Index", "Quantities") + # Presentation formats (.ods / .xlsx) mirror exactly what the Bonsai cost + # panel shows for a cost item: ID (Identification), Name, Quantity, + # Value (RateSubtotal) and the calculated Total Cost. Everything else + # (internal bookkeeping columns, Description, Unit, per-category cost + # breakdowns) is bonsai/csv2ifc round-trip plumbing and stays out of the + # presentation formats. The .csv format keeps the full column set since + # csv2ifc reads those extra columns back in on import. + PRESENTATION_COLUMNS = ("Identification", "Name", "Quantity", "RateSubtotal", "TotalPrice") + + # Header text as shown in presentation formats, matching the Bonsai cost + # panel's own column labels (see BIM_UL_cost_items_trait.draw_header). + PRESENTATION_LABELS = { + "Identification": "ID", + "RateSubtotal": "Value", + "TotalPrice": "Total Cost", + } def multiply_cells(self, cell1, cell2): return "={}*{}".format(cell1, cell2) @@ -397,15 +410,18 @@ class Ifc5Dwriter: return "=SUM({})".format(",".join(list_of_cells)) def get_visible_headers(self, schedule_id: int) -> list[str]: - """Headers for presentation formats, without the internal bookkeeping columns.""" - return [h for h in self.sheet_data[schedule_id]["headers"] if h not in self.INTERNAL_COLUMNS] + """Internal column keys shown in presentation formats, in panel order.""" + headers = self.sheet_data[schedule_id]["headers"] + return [h for h in self.PRESENTATION_COLUMNS if h in headers] + + def get_display_label(self, column: str) -> str: + """Header text to write for a column in presentation formats.""" + return self.PRESENTATION_LABELS.get(column, column) def is_numeric_column(self, column: str) -> bool: return column in ("Quantity", "RateSubtotal", "TotalPrice") or column.endswith(" Cost") - def get_total_price_formula( - self, schedule_id: int, cost_item_index: int, first_data_row: int - ) -> Union[str, None]: + def get_total_price_formula(self, schedule_id: int, cost_item_index: int, first_data_row: int) -> Union[str, None]: """Spreadsheet formula for the TotalPrice cell of a cost item, or None for a plain value. Sum items get ``=SUM(...)`` over the TotalPrice cells of their direct @@ -430,16 +446,9 @@ class Ifc5Dwriter: total_col = col("TotalPrice") return self.sum_cells(["{}{}".format(total_col, r) for r in child_rows]) return None - if ( - "Quantity" in headers - and "RateSubtotal" in headers - and item.get("Quantity") - and item.get("RateSubtotal") - ): + if "Quantity" in headers and "RateSubtotal" in headers and item.get("Quantity") and item.get("RateSubtotal"): row = first_data_row + cost_item_index - return self.multiply_cells( - "{}{}".format(col("Quantity"), row), "{}{}".format(col("RateSubtotal"), row) - ) + return self.multiply_cells("{}{}".format(col("Quantity"), row), "{}{}".format(col("RateSubtotal"), row)) return None def get_cell_position(self, schedule_id, attribute): @@ -578,7 +587,7 @@ class Ifc5DOdsWriter(Ifc5Dwriter): header_row = TableRow() for header in self.get_visible_headers(cost_schedule.id()): - add_cell(type="text", value=header, row=header_row, style="fed8b1") + add_cell(type="text", value=self.get_display_label(header), row=header_row, style="fed8b1") table.addElement(header_row) self.row_count = 5 @@ -616,7 +625,7 @@ class Ifc5DXlsxWriter(Ifc5Dwriter): title = re.sub(r"[\[\]:*?/\\]", "_", self.sheet_data[sheet_id]["Name"])[:31] worksheet = self.workbook.create_sheet(title) headers = self.get_visible_headers(sheet_id) - worksheet.append(headers) + worksheet.append([self.get_display_label(h) for h in headers]) first_data_row = 2 # Row 1 is the header. for i, cost_item_data in enumerate(self.sheet_data[sheet_id]["cost_items"]): diff --git a/src/ifc5d/test/test_csv2ifc.py b/src/ifc5d/test/test_csv2ifc.py index 01b4a85ee6..7f6a8ebfd3 100644 --- a/src/ifc5d/test/test_csv2ifc.py +++ b/src/ifc5d/test/test_csv2ifc.py @@ -120,6 +120,34 @@ class TestCsv2Ifc: assert len(list(Path(temp_csv_dir).glob("*.ods"))) == 1 assert len(list(Path(temp_csv_dir).glob("*.xlsx"))) == 1 + def test_xlsx_columns_match_cost_panel(self): + """ODS/XLSX are presentation formats: they must show exactly what the + Bonsai cost panel shows (ID, Name, Quantity, Value, Total Cost), no + internal bookkeeping columns, no Description/Unit, no per-category + cost breakdown. See #6251.""" + import openpyxl + + ifc_file = self.setup_ifc_file() + csv_filepath = Path(__file__).parent.parent / "sample_cost_schedule_house_FR.csv" + ifc5d.csv2ifc.Csv2Ifc(str(csv_filepath), ifc_file).execute() + + with tempfile.TemporaryDirectory("w") as temp_dir: + writer = ifc5d.ifc5Dspreadsheet.Ifc5DXlsxWriter(ifc_file, temp_dir) + writer.write() + workbook = openpyxl.load_workbook(next(Path(temp_dir).glob("*.xlsx"))) + worksheet = workbook.active + + headers = [cell.value for cell in next(worksheet.iter_rows())] + assert headers == ["ID", "Name", "Quantity", "Value", "Total Cost"] + + # A leaf item (has quantity and value) gets Quantity * Value. + leaf_row = next(row for row in worksheet.iter_rows(min_row=2) if row[0].value == "DB.1.1") + assert leaf_row[4].value == "=C{}*D{}".format(leaf_row[0].row, leaf_row[0].row) + + # A parent/sum item gets the sum of its direct children's Total Cost. + parent_row = next(row for row in worksheet.iter_rows(min_row=2) if row[0].value == "DB.1") + assert parent_row[4].value.startswith("=SUM(") + class TestSerialiseCostQuantities: def test_quantity_name_with_special_characters_round_trips_as_json(self):