mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
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:
committed by
Massimo Fabbro
parent
98c28a1f30
commit
1df738d968
@@ -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";
|
||||
|
||||
@@ -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"]):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user