Compare commits

...

4 Commits

Author SHA1 Message Date
Laurent Trinques 467a14df71 Merge pull request #849 from ispyisail/fix/bom-include-slave-terminal
Fix terminal and contact blocks missing from the parts list
2026-09-13 09:34:30 +02:00
Laurent Trinques 1b6768fcec Merge pull request #851 from ispyisail/feature/autonum-import-from-project
New feature: reuse automatic numbering rules from another project
2026-09-13 07:19:41 +02:00
ispyisail a6fd42ac5f Import automatic numbering rules from another project
Forum #3186 / issue #850: a user who has built up conductor and element
numbering rules in one project has no way to reuse them in the next one.
The only answer today is to open both .qet files in a text editor and copy
the XML across by hand.

Adds an "Import from another project..." button to the auto-numbering page
of the project properties dialog. It offers every numbering found in the
chosen file, per category, with names that already exist here unticked by
default and a "replace same-named numberings" option for when that is what
the user wants.

The source file is parsed as plain XML rather than opened as a QETProject.
Opening it would run the whole load path, including the modal dialog raised
for a file written by a different version of QElectroTech -- a dialog the
user has no reason to see, since nothing but the <newdiagrams> block is
being read.

Two supporting changes:

  - readValuesFromProject() clears the three combo boxes before filling
    them. It only ran once before; it now runs again after an import, and
    without the clear every name appeared twice.

  - FolioAutonumberingW::setContext() likewise replaces its list instead
    of appending to it. It has a single caller, the line above.

This deliberately does not attempt the project-template feature also raised
on the forum thread. That needs decisions about where templates live and
what else they carry, and is better settled in a discussion first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:56:25 +12:00
ispyisail b034d3c5b3 Include slave and terminal elements in the bill of materials
A slave and a terminal are both routinely separately orderable hardware. A
circuit breaker can carry ten or twenty auxiliary blocks, each with its own
order code, and a terminal block is a purchased part in its own right.
Neither was reaching the bill of materials.

Decided in discussion #847: @IBSYSLevi -- "I would not expect that a defined
piece of hardware is excluded from BOM when not specifically defined as so" --
with use cases from @jozi332 covering Siemens breakers with ten to twenty
auxiliary blocks and PLC cards carrying per-channel data.

Two filters had to change, which is easy to miss: BomExport::defaultQuery()
and, upstream of it, the WHERE clause of element_nomenclature_view itself.
Changing only the query does nothing for slaves, because the view had already
removed them. Terminals were already in the view, so they appeared as soon as
the query allowed them -- which made a half-finished change look like it had
worked.

Measured on examples/industrial.qet, which holds 96 terminals and 41 slaves:
258 rows before, 354 with terminals, 395 with both. A slave given a
manufacturer and part number now appears in the export; previously it could
not, at any setting.

Nothing that should stay out of a bill of materials is newly included. The
folio report arrows and the conductor definition are still excluded because
they are not hardware, and anything else -- a relay's own auxiliary contact,
which is not orderable separately -- is kept out with exclude_from_bom, which
the view already honours and which #721 and #765 made settable on the symbol
itself.

tst_smart_device is updated rather than weakened. @enesgursoy6110 wrote it in
#830 to prove the filter works, inserting rows designated "Must not be
exported"; the slave and terminal rows now carry real designations and are
asserted present, and a folio report arrow takes over as the negative case,
so the test still proves filtering happens -- at the boundary we now want.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:00:50 +12:00
6 changed files with 263 additions and 5 deletions
@@ -54,6 +54,10 @@ FolioAutonumberingW::~FolioAutonumberingW()
*/
void FolioAutonumberingW::setContext(QList <QString> autonums)
{
// Replace the list rather than append to it: this is called again
// whenever the project's numberings change (import, for instance),
// and appending gave a second copy of every name.
ui->m_autonums_cb->clear();
foreach (QString str, autonums) { ui->m_autonums_cb->addItem(str);}
}
+20 -1
View File
@@ -59,8 +59,27 @@ QStringList BomExport::defaultColumns()
QString BomExport::defaultQuery()
{
//Slaves and terminals are included because both are routinely
//separately orderable hardware. A circuit breaker can carry ten or
//twenty auxiliary blocks, each with its own order code, and a
//terminal block is a purchased part in its own right. Neither shares
//a line with its master: the query is ungrouped, one row per element,
//so each appears as the distinct item it is.
//
//Anything that should not be ordered is kept out by setting
//exclude_from_bom on the element, which the view already honours --
//a relay's own auxiliary contact, say.
//
//Thumbnails are deliberately left out for now even though ten of
//them in the shipped examples carry manufacturer and reference data
//(the assembly-plan mounting-plate symbols), because that has not
//been asked for and is a separate question. The folio report arrows
//and the conductor definition stay out because they are not hardware.
//
//See discussion #847.
return QStringLiteral("SELECT %1 FROM element_nomenclature_view "
"WHERE ( element_type = 'simple' OR element_type = 'master') "
"WHERE element_type IN "
"('simple', 'master', 'slave', 'terminal') "
"ORDER BY diagram_position, position, label")
.arg(defaultColumns().join(QStringLiteral(", ")));
}
+8 -1
View File
@@ -711,7 +711,14 @@ void projectDataBase::createElementNomenclatureView()
//the table's. Kept identical to the mask populateElementTable()
//used to apply, so what this view returns does not change --
//a slave element (a relay contact) is still not a line item.
" AND e.type IN ('simple', 'terminal', 'master', 'thumbnail')");
//Slave is here because an auxiliary contact block is
//separately orderable hardware with its own part
//number, even though it shares its master's BMK.
//Anything that should not be ordered -- a relay's
//own auxiliary contact, say -- is kept out by
//exclude_from_bom above, not by its base type.
//See discussion #847.
" AND e.type IN ('simple', 'terminal', 'master', 'slave', 'thumbnail')");
QSqlQuery query(m_data_base);
if (!query.exec(create_view)) {
+217 -1
View File
@@ -23,6 +23,7 @@
#include "../autoNum/ui/formulaautonumberingw.h"
#include "../autoNum/ui/selectautonumw.h"
#include "../project/projectpropertieshandler.h"
#include "../qet.h"
#include "../qeticons.h"
#include "../qetproject.h"
#include "../borderpropertieswidget.h"
@@ -354,8 +355,19 @@ void ProjectAutoNumConfigPage::initWidgets()
m_faw = new FolioAutonumberingW(project());
tab_widget->addTab(m_faw, tr("Numérotation auto des folios"));
QHBoxLayout *main_layout = new QHBoxLayout();
m_import_pb = new QPushButton(
tr("Importer depuis un autre projet..."), this);
m_import_pb->setToolTip(
tr("Reprendre les numérotations automatiques "
"enregistrées dans un autre projet"));
QHBoxLayout *button_layout = new QHBoxLayout();
button_layout->addStretch();
button_layout->addWidget(m_import_pb);
QVBoxLayout *main_layout = new QVBoxLayout();
main_layout->addWidget(tab_widget);
main_layout->addLayout(button_layout);
setLayout(main_layout);
buildConnections();
@@ -367,6 +379,12 @@ void ProjectAutoNumConfigPage::initWidgets()
*/
void ProjectAutoNumConfigPage::readValuesFromProject()
{
// This is called again after an import, so start from an empty
// combo box instead of appending a second copy of every name.
m_saw_conductor->contextComboBox()->clear();
m_saw_element->contextComboBox()->clear();
m_saw_folio->contextComboBox()->clear();
//Conductor Tab
const QStringList strlc(m_project->conductorAutoNum().keys());
m_saw_conductor->contextComboBox()->addItems(strlc);
@@ -389,6 +407,9 @@ void ProjectAutoNumConfigPage::readValuesFromProject()
*/
void ProjectAutoNumConfigPage::adjustReadOnly()
{
if (m_import_pb && m_project) {
m_import_pb->setDisabled(m_project->isReadOnly());
}
}
/**
@@ -417,6 +438,9 @@ void ProjectAutoNumConfigPage::buildConnections()
// Auto Folio Numbering
connect(m_faw, &FolioAutonumberingW::applyPressed, this, &ProjectAutoNumConfigPage::applyAutoNum);
//Import from another project
connect(m_import_pb, &QPushButton::clicked, this, &ProjectAutoNumConfigPage::importFromProject);
}
/**
@@ -487,6 +511,198 @@ void ProjectAutoNumConfigPage::saveContextElement()
}
}
/**
@brief ProjectAutoNumConfigPage::importFromProject
Read the automatic numbering rules stored in another .qet project and
copy the ones the user selects into this project.
The source file is parsed as plain XML rather than opened as a
QETProject: opening it would run the whole load path, including the
modal dialog raised when the file was written by a different version
of QElectroTech.
*/
void ProjectAutoNumConfigPage::importFromProject()
{
if (!m_project || m_project->isReadOnly()) {
return;
}
const QString path = QFileDialog::getOpenFileName(
this,
tr("Importer les numérotations d'un projet"),
m_project->currentDir(),
tr("Projet QElectroTech (*.qet)"));
if (path.isEmpty()) {
return;
}
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
QMessageBox::warning(this, tr("Import impossible"),
tr("Impossible d'ouvrir %1").arg(path));
return;
}
QDomDocument doc;
if (!doc.setContent(&file)) {
QMessageBox::warning(this, tr("Import impossible"),
tr("%1 n'est pas un projet QElectroTech valide.")
.arg(QFileInfo(path).fileName()));
return;
}
file.close();
const QDomNodeList newdiagrams =
doc.elementsByTagName(QStringLiteral("newdiagrams"));
if (newdiagrams.isEmpty()) {
QMessageBox::information(this, tr("Aucune numérotation"),
tr("Ce projet ne contient aucune numérotation automatique."));
return;
}
const QDomElement root = newdiagrams.at(0).toElement();
// tag of the group, tag of one entry, label shown to the user
struct Category {
QString group_tag;
QString item_tag;
QString label;
};
const QList<Category> categories {
{QStringLiteral("conductors_autonums"),
QStringLiteral("conductor_autonum"), tr("Conducteurs")},
{QStringLiteral("element_autonums"),
QStringLiteral("element_autonum"), tr("Eléments")},
{QStringLiteral("folio_autonums"),
QStringLiteral("folio_autonum"), tr("Folios")}
};
QDialog dialog(this);
dialog.setWindowTitle(tr("Numérotations à importer"));
QVBoxLayout *layout = new QVBoxLayout(&dialog);
layout->addWidget(new QLabel(
tr("Numérotations trouvées dans %1 :")
.arg(QFileInfo(path).fileName()), &dialog));
QListWidget *list = new QListWidget(&dialog);
layout->addWidget(list);
// NumerotationContext is not a QVariant type, so the list item
// carries an index into this instead of the context itself.
QList<NumerotationContext> contexts;
for (int i = 0 ; i < categories.count() ; ++i)
{
const Category &category = categories.at(i);
QDomElement group;
for (QDomNode n = root.firstChild() ; !n.isNull() ; n = n.nextSibling()) {
if (n.toElement().tagName() == category.group_tag) {
group = n.toElement();
break;
}
}
if (group.isNull()) {
continue;
}
for (QDomElement entry : QET::findInDomElement(group, category.item_tag))
{
const QString title = entry.attribute(QStringLiteral("title"));
if (title.isEmpty()) {
continue;
}
bool exists = false;
switch (i) {
case 0: exists = m_project->conductorAutoNum().contains(title); break;
case 1: exists = m_project->elementAutoNum().contains(title); break;
default: exists = m_project->folioAutoNum().contains(title); break;
}
QListWidgetItem *item = new QListWidgetItem(
exists ? tr("%1 : %2 (existe déjà)")
.arg(category.label, title)
: QStringLiteral("%1 : %2")
.arg(category.label, title),
list);
item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
item->setCheckState(exists ? Qt::Unchecked : Qt::Checked);
item->setData(Qt::UserRole, i);
item->setData(Qt::UserRole + 1, title);
NumerotationContext nc;
nc.fromXml(entry);
item->setData(Qt::UserRole + 2, contexts.count());
contexts << nc;
}
}
if (contexts.isEmpty()) {
QMessageBox::information(this, tr("Aucune numérotation"),
tr("Ce projet ne contient aucune numérotation automatique."));
return;
}
QCheckBox *overwrite_cb = new QCheckBox(
tr("Remplacer les numérotations de même nom"), &dialog);
layout->addWidget(overwrite_cb);
QDialogButtonBox *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog);
layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject);
if (dialog.exec() != QDialog::Accepted) {
return;
}
int imported = 0, skipped = 0, conductors = 0;
for (int row = 0 ; row < list->count() ; ++row)
{
QListWidgetItem *item = list->item(row);
if (item->checkState() != Qt::Checked) {
continue;
}
const int category = item->data(Qt::UserRole).toInt();
const QString title = item->data(Qt::UserRole + 1).toString();
bool exists = false;
switch (category) {
case 0: exists = m_project->conductorAutoNum().contains(title); break;
case 1: exists = m_project->elementAutoNum().contains(title); break;
default: exists = m_project->folioAutoNum().contains(title); break;
}
if (exists && !overwrite_cb->isChecked()) {
++skipped;
continue;
}
const NumerotationContext &nc =
contexts.at(item->data(Qt::UserRole + 2).toInt());
switch (category) {
case 0:
m_project->addConductorAutoNum(title, nc);
++conductors;
break;
case 1: m_project->addElementAutoNum(title, nc); break;
default: m_project->addFolioAutoNum(title, nc); break;
}
++imported;
}
readValuesFromProject();
if (conductors) {
m_project->conductorAutoNumAdded();
}
QMessageBox::information(
this, tr("Import terminé"),
skipped ? tr("%1 numérotation(s) importée(s), "
"%2 conservée(s) telles quelles.")
.arg(imported).arg(skipped)
: tr("%1 numérotation(s) importée(s).").arg(imported));
}
/**
@brief ProjectAutoNumConfigPage::removeContextElement
Remove from project the current element numerotation context
@@ -156,6 +156,7 @@ class ProjectAutoNumConfigPage : public ProjectConfigPage {
void updateContextElement(const QString&);//element
void saveContextElement();
void removeContextElement();
void importFromProject();
void applyAutoNum();
void applyManagement();
@@ -173,6 +174,7 @@ class ProjectAutoNumConfigPage : public ProjectConfigPage {
SelectAutonumW *m_saw_element;
FolioAutonumberingW *m_faw;
AutoNumberingManagementW *m_amw;
QPushButton *m_import_pb = nullptr;
};
+12 -2
View File
@@ -92,9 +92,17 @@ void SmartDeviceTest::defaultQueryAndCsv()
QStringLiteral("日本電機")));
QVERIFY(add(QStringLiteral("-K1"), QStringLiteral("master"), 1, 2,
QStringLiteral("Müller")));
//A slave and a terminal are both separately orderable hardware -- an
//auxiliary contact block has its own order code, and so does a
//terminal block -- so both belong in the bill of materials. See
//discussion #847. Anything that should not be ordered is kept out by
//exclude_from_bom rather than by its base type.
QVERIFY(add(QStringLiteral("-K1.1"), QStringLiteral("slave"), 1, 3,
QStringLiteral("Must not be exported")));
QStringLiteral("Aux contact block")));
QVERIFY(add(QStringLiteral("X1"), QStringLiteral("terminal"), 1, 4,
QStringLiteral("Terminal block")));
//Still filtered out: a folio report arrow is not hardware.
QVERIFY(add(QStringLiteral(">1"), QStringLiteral("next_report"), 1, 5,
QStringLiteral("Must not be exported")));
QSqlQuery query(db);
@@ -103,11 +111,13 @@ void SmartDeviceTest::defaultQueryAndCsv()
int rows = 0;
const auto csv = BomExport::toCsv(
query, BomExport::defaultColumns(), true, &rows);
QCOMPARE(rows, 2);
QCOMPARE(rows, 4);
QVERIFY(csv.startsWith("\xEF\xBB\xBF\"label\";\"designation\";"));
QVERIFY(csv.contains(QStringLiteral("Müller").toUtf8()));
QVERIFY(csv.contains(QStringLiteral("日本電機").toUtf8()));
QVERIFY(csv.contains("\"Quoted \"\"note\"\"\nnext line\""));
QVERIFY(csv.contains("Aux contact block"));
QVERIFY(csv.contains("Terminal block"));
QVERIFY(!csv.contains("Must not be exported"));
QVERIFY(csv.indexOf("-K1") < csv.indexOf("-K2"));
}