ifc5d: match cost schedule export columns to the Bonsai cost panel (#6251)

Stefano's final ask on #6251 was specific: the ODS/XLSX export should
show exactly what the cost panel shows, ID (Identification), Name,
Quantity, Value, Total Cost, no more, no less. The previous fix in
this PR removed the internal bookkeeping columns but still exported
Description, Unit and a per-category cost breakdown (Labor Cost,
Material Cost, etc), none of which appear in the panel.

Presentation formats (.ods/.xlsx) now use an explicit allow-list of
columns instead of a block-list of internal ones, and relabel headers
to match the panel's own wording (ID / Value / Total Cost). The .csv
format is unchanged: csv2ifc still reads back the extra bookkeeping
columns for the import round trip, which is why it keeps them.

Also add a "Download CSV" button to the browser costing view
(Generate spreadsheet browser), which previously only offered a
clipboard-based Copy Selected. It reuses the already-rendered table
(respecting the user's column visibility settings) and triggers a
real file download, dropping only the UI-only Actions column.

AI-generated with Claude Code; reviewed and tested by Petru Conduraru.
This commit is contained in:
Petru Conduraru
2026-07-23 09:24:36 +03:00
committed by Massimo Fabbro
parent 98c28a1f30
commit 1df738d968
3 changed files with 139 additions and 19 deletions
@@ -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";
+28 -19
View File
@@ -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"]):
+28
View File
@@ -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):