diff --git a/docs/smart-device-bom.md b/docs/smart-device-bom.md index d62823919..4252332c5 100644 --- a/docs/smart-device-bom.md +++ b/docs/smart-device-bom.md @@ -42,10 +42,18 @@ unit conventions and decimal quantities are not changed implicitly. ## BOM Policy -The default BOM query exports one row per Simple or Master element. Slave -contacts, terminal-strip elements, reports, thumbnails, and conductor -definitions are excluded. A Master device is counted once and PLC I/O entries -are not expanded. The existing `exclude_from_bom` property is honored by the +The default BOM query exports one row per Simple, Master, Slave or Terminal +element: an auxiliary contact block and a terminal block are parts of their +own (discussion #847). Reports, thumbnails and conductor definitions are +excluded. A Master device is counted once and PLC I/O entries are not +expanded. + +Two options leave rows out (#1178). Contact blocks: uncheck **Contacts +esclaves** in the dialog, or pass `--no-slaves`. Junctions, the +terminal-type symbols drawn where wires branch (`114_connections`), which +have no label, designation, manufacturer or manufacturer reference: check +**Laisser de côté les jonctions**, or pass `--no-junctions`. A terminal block +with a label or a part number is kept. The existing `exclude_from_bom` property is honored by the SQL view, including common true representations (`true`, `1`, `yes`, `on`). The default columns are: diff --git a/lang/qet_en.ts b/lang/qet_en.ts index 15f7578a4..b46bec982 100644 --- a/lang/qet_en.ts +++ b/lang/qet_en.ts @@ -1026,7 +1026,17 @@ Note: these options DO NOT allow or block auto numberings, only their update pol Format as a part list - + + Bornes sans repère, désignation, fabricant ni référence fabricant + Terminals with no label, designation, manufacturer or manufacturer reference + + + + Laisser de côté les jonctions + Leave out junctions + + + Aperçu Preview @@ -4214,47 +4224,52 @@ Continue? PLCs (MAE/SPS) - + + Contacts esclaves + Slave contacts + + + Configuration Configuration - + Ouvrir la configuration sélectionné Open the selected configuration - + Sauvegarder la configuration actuelle Save the current configuration - + Importer des rapports depuis un fichier Import reports from a file - + Importer... Import... - + Exporter tous les rapports enregistrés vers un fichier Export all saved reports to a file - + Exporter... Export... - + Requête SQL personnalisée Custom SQL Query - + Requête SQL : SQL request : diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index c21ebeaa5..46a69dbe3 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -703,6 +703,12 @@ Python, plus the hang guard on `addConductor` and the database refresh in `source_date_epoch` if given, else the server's own `SOURCE_DATE_EPOCH`, else 0 (1 January 1970). The result's `"reproducible"` is false, with a hint, when the QElectroTech build is too old to honour it. +- **`"no_slaves"` and `"no_junctions"` shorten a `bom` export.** The first + leaves out the contact blocks, which otherwise get a row of their own; the + second leaves out terminal-type elements with no label, designation, + manufacturer or manufacturer reference, such as the junction dots of + `114_connections`. They pass `--no-slaves` and `--no-junctions`; an older + QElectroTech ignores both and exports every row, so check the row count. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 1ceaad4b9..d0aba37ea 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1061,10 +1061,13 @@ def _source_date_epoch(value) -> int: def tool_export(binary: str, project: str, format: str, output: str, timeout: int = 180, reproducible: bool = False, - source_date_epoch: int | None = None) -> dict: + source_date_epoch: int | None = None, + no_slaves: bool = False, no_junctions: bool = False) -> dict: if format not in EXPORT_FORMATS: raise ValueError(f"unknown format {format!r}; " f"expected one of {', '.join(sorted(EXPORT_FORMATS))}") + if (no_slaves or no_junctions) and format != "bom": + raise ValueError("no_slaves and no_junctions only apply to format \"bom\"") proj = Path(project).expanduser() if not proj.is_file(): raise ValueError(f"no such project: {proj}") @@ -1082,8 +1085,12 @@ def tool_export(binary: str, project: str, format: str, output: str, if reproducible or source_date_epoch is not None: epoch = _source_date_epoch(source_date_epoch) extra_env = {"SOURCE_DATE_EPOCH": str(epoch)} - result = _run_qet(binary, [flag, str(proj), output], timeout, - extra_env=extra_env) + args = [flag, str(proj), output] + if no_slaves: + args.append("--no-slaves") + if no_junctions: + args.append("--no-junctions") + result = _run_qet(binary, args, timeout, extra_env=extra_env) out = Path(output).expanduser() result["output"] = str(out) result["output_exists"] = out.exists() @@ -4822,13 +4829,24 @@ TOOLS = [ "carries, in seconds since 1970 UTC. " "Default: this server's own " "SOURCE_DATE_EPOCH, else 0"}, + "no_slaves": {"type": "boolean", "default": False, + "description": "bom: leave out the contact blocks (slave " + "elements), which otherwise get a row of " + "their own with their master's label"}, + "no_junctions": {"type": "boolean", "default": False, + "description": "bom: leave out the junctions: " + "terminal-type elements with no label, " + "designation, manufacturer or " + "manufacturer reference"}, }, "required": ["project", "format", "output"], }, "handler": lambda a: tool_export(a["binary"], a["project"], a["format"], a["output"], a.get("timeout", 180), a.get("reproducible", False), - a.get("source_date_epoch")), + a.get("source_date_epoch"), + a.get("no_slaves", False), + a.get("no_junctions", False)), }, { "name": "qet_edit", diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index df566d38a..173cb034a 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -4034,6 +4034,36 @@ class ProjectNewValidation(unittest.TestCase): self.assertFalse(Path(new).exists()) +class BomExportOptions(unittest.TestCase): + """qet_export's no_slaves and no_junctions become --export-bom flags (#1178).""" + + def run_export(self, format="bom", **kw): + seen = [] + def fake(binary, args, timeout=180, **rest): + seen.extend(args) + return {"ok": True} + with tempfile.TemporaryDirectory() as tmp: + proj = Path(tmp) / "a.qet" + proj.write_text("") + with unittest.mock.patch.object(m, "_run_qet", fake): + m.tool_export("qet", str(proj), format, str(Path(tmp) / "o.csv"), **kw) + return seen + + def test_off_by_default(self): + args = self.run_export() + self.assertNotIn("--no-slaves", args) + self.assertNotIn("--no-junctions", args) + + def test_flags_after_the_output(self): + args = self.run_export(no_slaves=True, no_junctions=True) + self.assertEqual(args[0], "--export-bom") + self.assertEqual(args[3:], ["--no-slaves", "--no-junctions"]) + + def test_refused_for_other_formats(self): + with self.assertRaises(ValueError): + self.run_export(format="wiring", no_junctions=True) + + class ReproducibleExport(unittest.TestCase): """qet_export's "reproducible" sets SOURCE_DATE_EPOCH for the run.""" diff --git a/sources/bomexport.cpp b/sources/bomexport.cpp index a84bb51b2..97ecdff61 100644 --- a/sources/bomexport.cpp +++ b/sources/bomexport.cpp @@ -57,7 +57,32 @@ QStringList BomExport::defaultColumns() }; } -QString BomExport::defaultQuery() +/** + @brief BomExport::junctionFilter + @return a condition true for every row that is not a junction: a + terminal-type element with no label and no part data. Some users draw + the dots and bends where wires branch as their own terminal-type + symbols, and each one became an empty row of the parts list (#1178). + A terminal block with a label or a part number is kept. +*/ +QString BomExport::junctionFilter() +{ + return QStringLiteral("NOT (element_type = 'terminal' " + "AND IFNULL(label, '') = '' " + "AND IFNULL(designation, '') = '' " + "AND IFNULL(manufacturer, '') = '' " + "AND IFNULL(manufacturer_reference, '') = '')"); +} + +/** + @brief BomExport::defaultQuery + @param include_slaves : false leaves out the contact blocks (slave + elements), which otherwise appear as rows of their own + @param include_junctions : false leaves out the junctions, see + junctionFilter() + @return the query of the parts list +*/ +QString BomExport::defaultQuery(bool include_slaves, bool include_junctions) { //Slaves and terminals are included because both are routinely //separately orderable hardware. A circuit breaker can carry ten or @@ -77,11 +102,16 @@ QString BomExport::defaultQuery() //and the conductor definition stay out because they are not hardware. // //See discussion #847. + const QString types = include_slaves + ? QStringLiteral("'simple', 'master', 'slave', 'terminal'") + : QStringLiteral("'simple', 'master', 'terminal'"); + const QString junctions = include_junctions + ? QString() + : QStringLiteral(" AND ") + junctionFilter(); return QStringLiteral("SELECT %1 FROM element_nomenclature_view " - "WHERE element_type IN " - "('simple', 'master', 'slave', 'terminal') " + "WHERE element_type IN (%2)%3 " "ORDER BY diagram_position, position, label") - .arg(defaultColumns().join(QStringLiteral(", "))); + .arg(defaultColumns().join(QStringLiteral(", ")), types, junctions); } QByteArray BomExport::toCsv(QSqlQuery &query, const QStringList &headers, diff --git a/sources/bomexport.h b/sources/bomexport.h index 0241165a0..5305d378e 100644 --- a/sources/bomexport.h +++ b/sources/bomexport.h @@ -27,7 +27,9 @@ class QSqlQuery; namespace BomExport { QStringList defaultColumns(); - QString defaultQuery(); + QString defaultQuery(bool include_slaves = true, + bool include_junctions = true); + QString junctionFilter(); QByteArray toCsv(QSqlQuery &query, const QStringList &headers, bool include_headers = true, int *row_count = nullptr); bool writeCsv(const QString &file_path, const QByteArray &csv, diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index b6e20557a..6dc295196 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -408,10 +408,12 @@ int exportCsv(QETProject &project, const QString &format, const QString &output) /// Bill of materials from the same project database and default query as the /// GUI nomenclature export. -int exportBom(QETProject &project, const QString &output) +int exportBom(QETProject &project, const QString &output, + bool includeSlaves, bool includeJunctions) { project.dataBase()->updateDB(); - QSqlQuery query = project.dataBase()->newQuery(BomExport::defaultQuery()); + QSqlQuery query = project.dataBase()->newQuery( + BomExport::defaultQuery(includeSlaves, includeJunctions)); if (!query.exec()) { err << "BOM query failed: " << query.lastError().text() << "\n"; return 1; @@ -980,6 +982,9 @@ int run(const QStringList &args) // collected below. QStringList filtered = args; const bool showTerminals = filtered.removeAll("--show-terminals") > 0; + // --no-slaves and --no-junctions leave rows out of --export-bom. + const bool includeSlaves = filtered.removeAll("--no-slaves") == 0; + const bool includeJunctions = filtered.removeAll("--no-junctions") == 0; QString flag; QStringList rest; @@ -1037,7 +1042,7 @@ int run(const QStringList &args) if (format == "cables" || format == "wires") return exportCsv(project, format, output); if (format == "bom") - return exportBom(project, output); + return exportBom(project, output, includeSlaves, includeJunctions); if (format == "wiring") return exportWiring(project, output); if (format == "nets") diff --git a/sources/cli_export.h b/sources/cli_export.h index 8ad1b1d12..e9323a035 100644 --- a/sources/cli_export.h +++ b/sources/cli_export.h @@ -48,7 +48,7 @@ namespace CLIExport { qelectrotech --export-dxf [--show-terminals] qelectrotech --export-cables qelectrotech --export-wires - qelectrotech --export-bom + qelectrotech --export-bom [--no-slaves] [--no-junctions] qelectrotech --export-wiring qelectrotech --export-nets qelectrotech --export-links @@ -69,6 +69,11 @@ namespace CLIExport { cables: wiring list (one row per conductor) as CSV. wires: list of distinct wire numbers as CSV. bom: bill of materials (one row per element) as CSV. + --no-slaves: leave the contact blocks (slave elements) out of the + bill of materials. + --no-junctions: leave the junctions out of the bill of materials: + terminal-type elements with no label, designation, + manufacturer or manufacturer reference. wiring: from-to wiring list (one row per conductor) as CSV, read from the project database. Same logical list as `cables`, which reads the document XML instead; the two are meant to agree, diff --git a/sources/dataBase/ui/elementquerywidget.cpp b/sources/dataBase/ui/elementquerywidget.cpp index 6331efb63..9afb47a51 100644 --- a/sources/dataBase/ui/elementquerywidget.cpp +++ b/sources/dataBase/ui/elementquerywidget.cpp @@ -56,6 +56,7 @@ ElementQueryWidget::ElementQueryWidget(QWidget *parent) : m_button_group.addButton(ui->m_protection_cb, 5); m_button_group.addButton(ui->m_thumbnail_cb, 6); m_button_group.addButton(ui->m_plc_cb, 7); + m_button_group.addButton(ui->m_slave_cb, 8); connect(&m_button_group, static_cast(&QButtonGroup::idClicked), [this](int id) { @@ -78,27 +79,12 @@ ElementQueryWidget::ElementQueryWidget(QWidget *parent) : } else { - int checked = 0; - for (int i=1 ; i<8 ; ++i) { - if (m_button_group.button(i)->isChecked()) {++checked;} - } - - switch (checked) - { - case 0 : - check_box->setCheckState(Qt::Unchecked); - break; - case 7: - check_box->setCheckState(Qt::Checked); - break; - default: - check_box->setCheckState(Qt::PartiallyChecked); - break; - } + updateAllCheckState(); } updateQueryLine(); }); + updateAllCheckState(); setUpItems(); fillSavedQuery(); @@ -203,8 +189,12 @@ void ElementQueryWidget::setQuery(const QString &query) if (ui->m_plc_cb->isChecked()) { ++c; } + ui->m_slave_cb->setChecked (str_type.contains(ElementData::typeToString(ElementData::Slave))); + if (ui->m_slave_cb->isChecked()) { + ++c; + } - if (c == 7) { + if (c == 8) { ui->m_all_cb->setCheckState(Qt::Checked); } else if (c > 0) { ui->m_all_cb->setCheckState(Qt::PartiallyChecked); @@ -395,6 +385,11 @@ QString ElementQueryWidget::queryStr() const where += QStringLiteral(" element_sub_type = '") += ElementData::masterTypeToString(ElementData::PLC) += "'"; b = true; } + if (ui->m_slave_cb->isChecked()) { + if (b) where +=" OR"; + where += QStringLiteral(" element_type = '") += ElementData::typeToString(ElementData::Slave) += "'"; + b = true; + } where.append(")"); if (where == " WHERE ()") { @@ -406,6 +401,10 @@ QString ElementQueryWidget::queryStr() const // widget's query reads FROM that view, so a flagged element never // reaches this point in the first place. + if (!m_extra_filter.isEmpty()) { + filter_ += QStringLiteral(" AND ") + m_extra_filter; + } + if (where.isEmpty() && !filter_.isEmpty()) { filter_.remove(0, 4); //Remove the first " AND" of filter. filter_.prepend( " WHERE"); @@ -456,6 +455,64 @@ void ElementQueryWidget::setCount(QString text, bool set) updateQueryLine(); } +/** + @brief ElementQueryWidget::setSlavesIncluded + Check or uncheck the contact blocks (slave elements) in the element + type filter. The box is unchecked by default, which keeps the tables + made before it existed as they were. + @param included +*/ +void ElementQueryWidget::setSlavesIncluded(bool included) +{ + ui->m_slave_cb->setChecked(included); + updateAllCheckState(); + updateQueryLine(); +} + +/** + @brief ElementQueryWidget::updateAllCheckState + Set the "all" check box from the element type check boxes. +*/ +void ElementQueryWidget::updateAllCheckState() +{ + int checked = 0; + for (int i=1 ; i<9 ; ++i) { + if (m_button_group.button(i)->isChecked()) {++checked;} + } + + switch (checked) + { + case 0 : + ui->m_all_cb->setCheckState(Qt::Unchecked); + break; + case 8: + ui->m_all_cb->setCheckState(Qt::Checked); + break; + default: + ui->m_all_cb->setCheckState(Qt::PartiallyChecked); + break; + } +} + +/** + @brief ElementQueryWidget::setExtraFilter + Add a condition every row of the query must meet, on top of the filters + chosen in this widget. + @param text : the condition, ex : "label != ''" + @param set : + true by default -> the condition is used. + false -> the condition is removed. +*/ +void ElementQueryWidget::setExtraFilter(const QString &text, bool set) +{ + if (set) { + m_extra_filter = text; + } else { + m_extra_filter.clear(); + } + updateQueryLine(); +} + /** @brief ElementQueryWidget::updateQueryLine */ diff --git a/sources/dataBase/ui/elementquerywidget.h b/sources/dataBase/ui/elementquerywidget.h index f89883793..99cdf034d 100644 --- a/sources/dataBase/ui/elementquerywidget.h +++ b/sources/dataBase/ui/elementquerywidget.h @@ -45,6 +45,8 @@ class ElementQueryWidget : public QWidget QString queryStr() const; void setGroupBy(QString text, bool set = true); void setCount(QString text, bool set = true); + void setSlavesIncluded(bool included); + void setExtraFilter(const QString &text, bool set = true); static QString modelIdentifier() {return "nomenclature";} @@ -67,6 +69,7 @@ class ElementQueryWidget : public QWidget void on_m_choosen_list_itemDoubleClicked(QListWidgetItem *item); void updateQueryLine(); + void updateAllCheckState(); QStringList selectedKeys() const; void setUpItems(); QPair FilterFor(const QString &key) const; @@ -82,7 +85,8 @@ class ElementQueryWidget : public QWidget QList m_items_list; QString m_custom_query, m_group_by, - m_count; + m_count, + m_extra_filter; QHash > m_filter; }; diff --git a/sources/dataBase/ui/elementquerywidget.ui b/sources/dataBase/ui/elementquerywidget.ui index 2464c6c81..5e01e2c5b 100644 --- a/sources/dataBase/ui/elementquerywidget.ui +++ b/sources/dataBase/ui/elementquerywidget.ui @@ -318,6 +318,16 @@ + + + + Contacts esclaves + + + false + + + diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 85955ea27..ae16daba6 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -325,9 +325,13 @@ bool QetScriptApi::exportWires(const QString &output) return runFlag(QStringLiteral("--export-wires"), {output}); } -bool QetScriptApi::exportBom(const QString &output) +bool QetScriptApi::exportBom(const QString &output, bool noSlaves, + bool noJunctions) { - return runFlag(QStringLiteral("--export-bom"), {output}); + QStringList args{output}; + if (noSlaves) args << QStringLiteral("--no-slaves"); + if (noJunctions) args << QStringLiteral("--no-junctions"); + return runFlag(QStringLiteral("--export-bom"), args); } bool QetScriptApi::exportWiring(const QString &output) diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index f591f9f93..b548e74ec 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -384,7 +384,8 @@ class QetScriptApi : public QObject Q_INVOKABLE bool exportDxf(const QString &outDir, bool showTerminals = false); Q_INVOKABLE bool exportCables(const QString &output); Q_INVOKABLE bool exportWires(const QString &output); - Q_INVOKABLE bool exportBom(const QString &output); + Q_INVOKABLE bool exportBom(const QString &output, bool noSlaves = false, + bool noJunctions = false); Q_INVOKABLE bool exportWiring(const QString &output); Q_INVOKABLE bool exportNets(const QString &output); Q_INVOKABLE bool exportLinks(const QString &output); diff --git a/sources/ui/bomexportdialog.cpp b/sources/ui/bomexportdialog.cpp index 703b9e3c6..af884cfc8 100644 --- a/sources/ui/bomexportdialog.cpp +++ b/sources/ui/bomexportdialog.cpp @@ -46,6 +46,9 @@ BOMExportDialog::BOMExportDialog(QETProject *project, QWidget *parent) : m_query_widget = new ElementQueryWidget(this); ui->m_main_layout->insertWidget(0, m_query_widget); m_query_widget->setQuery(BomExport::defaultQuery()); + //The contact blocks are parts of their own, as in the command line + //export; unchecking "Contacts esclaves" leaves them out. + m_query_widget->setSlavesIncluded(true); on_m_format_as_bom_clicked(false); m_preview_model = new QSqlQueryModel(this); @@ -145,6 +148,15 @@ void BOMExportDialog::on_m_format_as_bom_clicked(bool checked) { m_query_widget->setCount("COUNT(*) AS designation_qty", checked); } +/** + @brief BOMExportDialog::on_m_no_junctions_clicked + @param checked : true leaves out the junctions, see + BomExport::junctionFilter() +*/ +void BOMExportDialog::on_m_no_junctions_clicked(bool checked) { + m_query_widget->setExtraFilter(BomExport::junctionFilter(), checked); +} + /** @brief BOMExportDialog::on_m_preview_pb_clicked Run the current query and show its result live, without going through diff --git a/sources/ui/bomexportdialog.h b/sources/ui/bomexportdialog.h index 0c5a0743e..924b3fc8a 100644 --- a/sources/ui/bomexportdialog.h +++ b/sources/ui/bomexportdialog.h @@ -45,6 +45,7 @@ class BOMExportDialog : public QDialog private slots: void on_m_format_as_bom_clicked(bool checked); + void on_m_no_junctions_clicked(bool checked); void on_m_preview_pb_clicked(); private: diff --git a/sources/ui/bomexportdialog.ui b/sources/ui/bomexportdialog.ui index 04b673f46..4a3ffa8f4 100644 --- a/sources/ui/bomexportdialog.ui +++ b/sources/ui/bomexportdialog.ui @@ -40,6 +40,19 @@ + + + + Bornes sans repère, désignation, fabricant ni référence fabricant + + + Laisser de côté les jonctions + + + false + + + diff --git a/tests/qttest/tst_smart_device.cpp b/tests/qttest/tst_smart_device.cpp index ced7ecbdc..e36571026 100644 --- a/tests/qttest/tst_smart_device.cpp +++ b/tests/qttest/tst_smart_device.cpp @@ -120,6 +120,34 @@ void SmartDeviceTest::defaultQueryAndCsv() QVERIFY(csv.contains("Terminal block")); QVERIFY(!csv.contains("Must not be exported")); QVERIFY(csv.indexOf("-K1") < csv.indexOf("-K2")); + + //#1178: a junction -- a terminal-type symbol drawn where wires + //branch -- has no label and no part data. It can be left out, and + //so can the contact blocks; a terminal block with only a part + //number stays. + QSqlQuery raw(db); + QVERIFY(raw.exec(QStringLiteral( + "INSERT INTO element_nomenclature_view " + "(label, element_type, diagram_position, position) " + "VALUES (NULL, 'terminal', 1, 6)"))); + QVERIFY(raw.exec(QStringLiteral( + "INSERT INTO element_nomenclature_view " + "(label, designation, manufacturer, manufacturer_reference, " + "element_type, diagram_position, position) " + "VALUES ('', '', '', 'PT 2,5', 'terminal', 1, 7)"))); + auto count = [&db](const QString &sql) { + QSqlQuery q(db); + if (!q.exec(sql)) + return -1; + int n = 0; + while (q.next()) + ++n; + return n; + }; + QCOMPARE(count(BomExport::defaultQuery()), 6); + QCOMPARE(count(BomExport::defaultQuery(true, false)), 5); + QCOMPARE(count(BomExport::defaultQuery(false, true)), 5); + QCOMPARE(count(BomExport::defaultQuery(false, false)), 4); } QSqlDatabase::removeDatabase(connection); }