diff --git a/CMakeLists.txt b/CMakeLists.txt index b7fee4c..8a35580 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ set(SOURCES model/specificationpcbtablemodel.cpp model/specificationtablemodel.cpp model/vedomosttablemodel.cpp + model/simplelisttablemodel.cpp model/pcbmaterialmodel.cpp view/adddesignatordialog.cpp view/basegosttable.cpp @@ -45,6 +46,7 @@ set(SOURCES controller/specificationpcbcontroller.cpp controller/specificationcontroller.cpp controller/vedomostcontroller.cpp + controller/simplelisttablecontroller.cpp controller/csvexporter.cpp view/editpdfwidget.cpp view/importeddataview.cpp @@ -76,6 +78,7 @@ set(HEADERS model/specificationpcbtablemodel.h model/specificationtablemodel.h model/vedomosttablemodel.h + model/simplelisttablemodel.h model/pcbmaterialmodel.h view/adddesignatordialog.h view/basegosttable.h @@ -89,6 +92,7 @@ set(HEADERS controller/specificationpcbcontroller.h controller/specificationcontroller.h controller/vedomostcontroller.h + controller/simplelisttablecontroller.h controller/csvexporter.h view/editpdfwidget.h view/importeddataview.h diff --git a/app_icon.rc b/app_icon.rc index ce41c89..ce12cb7 100644 --- a/app_icon.rc +++ b/app_icon.rc @@ -13,3 +13,7 @@ IDI_ICON1 ICON DISCARDABLE "assets/icon2.ico" + + + + diff --git a/controller/csvexporter.cpp b/controller/csvexporter.cpp index 6e80c56..b6261a2 100644 --- a/controller/csvexporter.cpp +++ b/controller/csvexporter.cpp @@ -3,6 +3,7 @@ #include "../model/specificationtablemodel.h" #include "../model/perechentablemodel.h" #include "../model/vedomosttablemodel.h" +#include "../model/simplelisttablemodel.h" #include #include #include @@ -54,6 +55,16 @@ bool CSVExporter::exportVedomost(const QString &filePath, VedomostTableModel *mo return exportTableModel(filePath, model, true); } +bool CSVExporter::exportSimpleList(const QString &filePath, SimpleListTableModel *model) +{ + if (!model) { + m_lastError = "Модель таблицы не указана"; + return false; + } + + return exportTableModel(filePath, model, true); +} + bool CSVExporter::exportTableModel(const QString &filePath, QAbstractTableModel *model, bool includeHeaders) { if (!model) { diff --git a/controller/csvexporter.h b/controller/csvexporter.h index 79713e1..469bceb 100644 --- a/controller/csvexporter.h +++ b/controller/csvexporter.h @@ -9,6 +9,7 @@ class SpecificationPCBTableModel; class SpecificationTableModel; class PerechenTableModel; class VedomostTableModel; +class SimpleListTableModel; /** * @brief Класс для экспорта таблиц в формат CSV @@ -58,6 +59,14 @@ public: */ bool exportVedomost(const QString &filePath, VedomostTableModel *model); + /** + * @brief Экспортирует таблицу бланка заказа в CSV + * @param filePath Путь к файлу для сохранения + * @param model Модель таблицы бланка заказа + * @return true если экспорт успешен, false в противном случае + */ + bool exportSimpleList(const QString &filePath, SimpleListTableModel *model); + /** * @brief Экспортирует любую модель таблицы в CSV * @param filePath Путь к файлу для сохранения diff --git a/controller/databasecontroller.cpp b/controller/databasecontroller.cpp index 50c3b72..8884308 100644 --- a/controller/databasecontroller.cpp +++ b/controller/databasecontroller.cpp @@ -215,6 +215,20 @@ bool DataBaseController::createTables() " created_at DATETIME DEFAULT CURRENT_TIMESTAMP," " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP" ")", + "CREATE TABLE IF NOT EXISTS simple_list_rows (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " project_name TEXT NOT NULL," + " row_order INTEGER NOT NULL," + " designator_value TEXT," + " name_value TEXT," + " quantity_value TEXT," + " is_empty BOOLEAN DEFAULT 0," + " designator_is_auto BOOLEAN DEFAULT 0," + " name_is_auto BOOLEAN DEFAULT 0," + " quantity_is_auto BOOLEAN DEFAULT 0," + " created_at DATETIME DEFAULT CURRENT_TIMESTAMP," + " updated_at DATETIME DEFAULT CURRENT_TIMESTAMP" + ")", "CREATE TABLE IF NOT EXISTS pcb_data (" " id INTEGER PRIMARY KEY AUTOINCREMENT," @@ -2789,6 +2803,146 @@ bool DataBaseController::saveVedomostTable(const QByteArray &tableData, const QS return true; } +// Работа с бланком заказа +bool DataBaseController::saveSimpleListTable(const QByteArray &tableData, const QString &projectName) +{ + if (!m_database.isOpen()) { + setLastError("База данных не открыта"); + return false; + } + + qDebug() << "DataBaseController::saveSimpleListTable: Сохраняем бланк заказа для проекта:" << projectName; + + // Начинаем транзакцию + if (!beginTransaction()) { + return false; + } + + // Удаляем существующие данные для этого проекта + QSqlQuery deleteQuery = prepareQuery("DELETE FROM simple_list_rows WHERE project_name = ?"); + deleteQuery.addBindValue(projectName); + + if (!deleteQuery.exec()) { + qDebug() << "DataBaseController::saveSimpleListTable: Ошибка удаления:" << deleteQuery.lastError().text(); + setLastError("Ошибка удаления существующих данных бланка заказа: " + deleteQuery.lastError().text()); + rollbackTransaction(); + return false; + } + + // Парсим JSON данные и сохраняем каждую строку отдельно + QJsonDocument doc = QJsonDocument::fromJson(tableData); + if (!doc.isObject()) { + setLastError("Ошибка парсинга JSON данных бланка заказа"); + rollbackTransaction(); + return false; + } + + QJsonObject root = doc.object(); + QJsonArray rowsArray = root["rows"].toArray(); + + int rowOrder = 0; + for (const QJsonValue &value : rowsArray) { + QJsonObject rowObj = value.toObject(); + + // Получаем данные ячеек + QJsonObject designatorObj = rowObj["designator"].toObject(); + QJsonObject nameObj = rowObj["name"].toObject(); + QJsonObject quantityObj = rowObj["quantity"].toObject(); + + // Добавляем строку в БД + QSqlQuery insertQuery = prepareQuery( + "INSERT INTO simple_list_rows (project_name, row_order, designator_value, name_value, quantity_value, " + "is_empty, designator_is_auto, name_is_auto, quantity_is_auto) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + insertQuery.addBindValue(projectName); + insertQuery.addBindValue(rowOrder++); + insertQuery.addBindValue(designatorObj["value"].toString()); + insertQuery.addBindValue(nameObj["value"].toString()); + insertQuery.addBindValue(quantityObj["value"].toString()); + insertQuery.addBindValue(rowObj["isEmpty"].toBool()); + insertQuery.addBindValue(designatorObj["isAutoGenerated"].toBool()); + insertQuery.addBindValue(nameObj["isAutoGenerated"].toBool()); + insertQuery.addBindValue(quantityObj["isAutoGenerated"].toBool()); + + if (!insertQuery.exec()) { + qDebug() << "DataBaseController::saveSimpleListTable: Ошибка сохранения строки" << rowOrder-1 << ":" << insertQuery.lastError().text(); + setLastError("Ошибка сохранения строки бланка заказа: " + insertQuery.lastError().text()); + rollbackTransaction(); + return false; + } + } + + // Подтверждаем транзакцию + if (!commitTransaction()) { + return false; + } + + qDebug() << "DataBaseController::saveSimpleListTable: Бланк заказа успешно сохранен," << rowsArray.size() << "строк"; + emit dataChanged(); + return true; +} + +QByteArray DataBaseController::loadSimpleListTable(const QString &projectName) +{ + if (!m_database.isOpen()) { + setLastError("База данных не открыта"); + return QByteArray(); + } + + qDebug() << "DataBaseController::loadSimpleListTable: Загружаем бланк заказа для проекта:" << projectName; + + QSqlQuery query = prepareQuery( + "SELECT designator_value, name_value, quantity_value, is_empty, designator_is_auto, name_is_auto, quantity_is_auto " + "FROM simple_list_rows WHERE project_name = ? ORDER BY row_order" + ); + query.addBindValue(projectName); + + if (!query.exec()) { + setLastError("Ошибка загрузки бланка заказа: " + query.lastError().text()); + return QByteArray(); + } + + QJsonObject root; + QJsonArray rowsArray; + + while (query.next()) { + QJsonObject rowObj; + + // Получаем данные ячеек + QJsonObject designatorObj; + designatorObj["value"] = query.value(0).toString(); + designatorObj["isAutoGenerated"] = query.value(4).toBool(); + designatorObj["isEmpty"] = false; + rowObj["designator"] = designatorObj; + + QJsonObject nameObj; + nameObj["value"] = query.value(1).toString(); + nameObj["isAutoGenerated"] = query.value(5).toBool(); + nameObj["isEmpty"] = false; + rowObj["name"] = nameObj; + + QJsonObject quantityObj; + quantityObj["value"] = query.value(2).toString(); + quantityObj["isAutoGenerated"] = query.value(6).toBool(); + quantityObj["isEmpty"] = false; + rowObj["quantity"] = quantityObj; + + rowObj["isEmpty"] = query.value(3).toBool(); + + rowsArray.append(rowObj); + } + + root["rows"] = rowsArray; + + QJsonDocument doc(root); + QByteArray result = doc.toJson(); + + qDebug() << "DataBaseController::loadSimpleListTable: Загружено" << rowsArray.size() << "строк"; + return result; +} + QByteArray DataBaseController::loadVedomostTable(const QString &projectName) { if (!m_database.isOpen()) { diff --git a/controller/databasecontroller.h b/controller/databasecontroller.h index b4f9797..da87fda 100644 --- a/controller/databasecontroller.h +++ b/controller/databasecontroller.h @@ -170,6 +170,10 @@ public: // Метод для получения информации о структуре ведомости покупных изделий в БД QString getVedomostTableInfo(const QString &projectName = QString()); + // Работа с бланком заказа + bool saveSimpleListTable(const QByteArray &tableData, const QString &projectName = QString()); + QByteArray loadSimpleListTable(const QString &projectName = QString()); + // Получение данных для отображения QList getComponentsForVariant(int variantId); QList getComponentsForVariant(const QString &variantName); diff --git a/controller/maincontroller.cpp b/controller/maincontroller.cpp index fbb980a..a67f60c 100644 --- a/controller/maincontroller.cpp +++ b/controller/maincontroller.cpp @@ -7,9 +7,11 @@ #include "../model/specificationpcbtablemodel.h" #include "../model/specificationtablemodel.h" #include "../model/vedomosttablemodel.h" +#include "../model/simplelisttablemodel.h" #include "../model/titleinscriptionsmodel.h" #include "../model/pcbmaterialmodel.h" #include "perechentablecontroller.h" +#include "simplelisttablecontroller.h" #include "specificationpcbcontroller.h" #include "specificationcontroller.h" #include "vedomostcontroller.h" @@ -37,6 +39,8 @@ MainController::MainController(QObject *parent) , m_specificationController(new SpecificationController(this)) , m_vedomostTableModel(new VedomostTableModel(this)) , m_vedomostController(new VedomostController(this)) + , m_simpleListTableModel(new SimpleListTableModel(this)) + , m_simpleListTableController(new SimpleListTableController(this)) , m_titleInscriptionsModel(new TitleInscriptionsModel(this)) , m_pcbMaterialModel(new PCBMaterialModel(this)) , m_databaseController(new DataBaseController(this)) @@ -94,6 +98,9 @@ MainController::MainController(QObject *parent) // Устанавливаем модель ведомости в контроллер m_vedomostController->setModel(m_vedomostTableModel); + // Устанавливаем модель SimpleList в контроллер + m_simpleListTableController->setModel(m_simpleListTableModel); + connect(m_databaseController, &DataBaseController::databaseError, this, &MainController::databaseError); @@ -165,6 +172,14 @@ VedomostController* MainController::vedomostController() const { return m_vedomostController; } +SimpleListTableModel* MainController::simpleListTableModel() const { + return m_simpleListTableModel; +} + +SimpleListTableController* MainController::simpleListTableController() const { + return m_simpleListTableController; +} + TitleInscriptionsModel* MainController::titleInscriptionsModel() const { return m_titleInscriptionsModel; } @@ -439,6 +454,16 @@ bool MainController::loadProjectSettingsFromDatabase() { m_projectSettingsModel->setFontStretch(docType, stretch); } + // Загружаем настройки бланка заказа + if (m_simpleListTableController) { + QString nameField = m_databaseController->getProjectSetting("SimpleList_nameField", "Name"); + int techReservePercent = m_databaseController->getProjectSetting("SimpleList_techReservePercent", "10").toInt(); + int boardsCount = m_databaseController->getProjectSetting("SimpleList_boardsCount", "1").toInt(); + m_simpleListTableController->setNameField(nameField); + m_simpleListTableController->setTechReservePercent(techReservePercent); + m_simpleListTableController->setBoardsCount(boardsCount); + } + m_projectSettingsModel->setModified(false); // Устанавливаем текущий вариант в контроллере @@ -489,6 +514,13 @@ bool MainController::saveProjectSettingsToDatabase() { success &= m_databaseController->setProjectSetting(stretchKey, QString::number(stretch)); } + // Сохраняем настройки бланка заказа + if (m_simpleListTableController) { + success &= m_databaseController->setProjectSetting("SimpleList_nameField", m_simpleListTableController->getNameField()); + success &= m_databaseController->setProjectSetting("SimpleList_techReservePercent", QString::number(m_simpleListTableController->getTechReservePercent())); + success &= m_databaseController->setProjectSetting("SimpleList_boardsCount", QString::number(m_simpleListTableController->getBoardsCount())); + } + // Сохраняем маппинг дезигнаторов в глобальную БД if (m_designatorMappingModel->isModified()) { m_designatorMappingModel->saveToDatabase(); @@ -943,6 +975,51 @@ bool MainController::exportVedomostToCsv(const QString &filePath) return m_csvExporter->exportVedomost(filePath, m_vedomostTableModel); } +bool MainController::exportSimpleListToCsv(const QString &filePath) +{ + if (!m_csvExporter || !m_simpleListTableModel) { + return false; + } + + return m_csvExporter->exportSimpleList(filePath, m_simpleListTableModel); +} + +bool MainController::saveSimpleListTableToDatabase(const QByteArray &tableData, const QString &projectName) +{ + // Проверяем, открыта ли база данных проекта + if (!m_databaseController->isDatabaseOpen()) { + return false; + } + + // Если имя проекта не указано, получаем его из пути к файлу БД + QString actualProjectName = projectName; + if (actualProjectName.isEmpty()) { + actualProjectName = QFileInfo(m_databaseController->getDatabasePath()).baseName(); + } + + bool success = m_databaseController->saveSimpleListTable(tableData, actualProjectName); + + return success; +} + +QByteArray MainController::loadSimpleListTableFromDatabase(const QString &projectName) +{ + // Проверяем, открыта ли база данных проекта + if (!m_databaseController->isDatabaseOpen()) { + return QByteArray(); + } + + // Если имя проекта не указано, получаем его из пути к файлу БД + QString actualProjectName = projectName; + if (actualProjectName.isEmpty()) { + actualProjectName = QFileInfo(m_databaseController->getDatabasePath()).baseName(); + } + + QByteArray tableData = m_databaseController->loadSimpleListTable(actualProjectName); + + return tableData; +} + PDFController* MainController::pdfController() const { return m_pdfController; diff --git a/controller/maincontroller.h b/controller/maincontroller.h index c9e797c..a45e903 100644 --- a/controller/maincontroller.h +++ b/controller/maincontroller.h @@ -21,6 +21,8 @@ class SpecificationTableModel; class SpecificationController; class VedomostTableModel; class VedomostController; +class SimpleListTableModel; +class SimpleListTableController; class TitleInscriptionsModel; class PCBMaterialModel; class AltiumParser; @@ -45,6 +47,8 @@ public: SpecificationController* specificationController() const; VedomostTableModel* vedomostTableModel() const; VedomostController* vedomostController() const; + SimpleListTableModel* simpleListTableModel() const; + SimpleListTableController* simpleListTableController() const; TitleInscriptionsModel* titleInscriptionsModel() const; PCBMaterialModel* pcbMaterialModel() const; @@ -98,6 +102,10 @@ public: bool saveVedomostTableToDatabase(const QByteArray &tableData, const QString &projectName = QString()); QByteArray loadVedomostTableFromDatabase(const QString &projectName = QString()); QString getVedomostTableInfo(const QString &projectName = QString()); + + // Работа с бланком заказа + bool saveSimpleListTableToDatabase(const QByteArray &tableData, const QString &projectName = QString()); + QByteArray loadSimpleListTableFromDatabase(const QString &projectName = QString()); // Парсинг файлов bool parseFile(const QString &filePath); @@ -124,6 +132,7 @@ public: bool exportSpecificationPcbToCsv(const QString &filePath); bool exportSpecificationToCsv(const QString &filePath); bool exportVedomostToCsv(const QString &filePath); + bool exportSimpleListToCsv(const QString &filePath); // Метод для получения PDFController PDFController* pdfController() const; @@ -148,6 +157,8 @@ private: SpecificationController* m_specificationController; VedomostTableModel* m_vedomostTableModel; VedomostController* m_vedomostController; + SimpleListTableModel* m_simpleListTableModel; + SimpleListTableController* m_simpleListTableController; TitleInscriptionsModel* m_titleInscriptionsModel; PCBMaterialModel* m_pcbMaterialModel; DataBaseController* m_databaseController; diff --git a/controller/pdfcontroller.cpp b/controller/pdfcontroller.cpp index eee483e..df42981 100644 --- a/controller/pdfcontroller.cpp +++ b/controller/pdfcontroller.cpp @@ -272,7 +272,7 @@ bool PDFController::exportSpecificationPcbToPdf(const QString &filePath, MainCon pageNumber++; } writer.newPage(); - addGostFrameA4(painter, pageRect, titleModel, projectParamModel, pageNumber, totalPages,1); + addGostFrameA4(painter, pageRect, titleModel, projectParamModel, pageNumber, totalPages,2); QRectF tableRect = pageRect.adjusted(20*mmToPixels, 5*mmToPixels, -5*mmToPixels, -20*mmToPixels); addListRegistrationTable(painter, tableRect); painter.end(); @@ -386,7 +386,7 @@ bool PDFController::exportSpecificationToPdf(const QString &filePath, MainContro pageNumber++; } writer.newPage(); - addGostFrameA4(painter, pageRect, titleModel, projectParamModel, pageNumber, totalPages,1); + addGostFrameA4(painter, pageRect, titleModel, projectParamModel, pageNumber, totalPages,3); QRectF tableRect = pageRect.adjusted(20*mmToPixels, 5*mmToPixels, -5*mmToPixels, -20*mmToPixels); addListRegistrationTable(painter, tableRect); painter.end(); @@ -509,7 +509,7 @@ bool PDFController::exportVedomostToPdf(const QString &filePath, MainController // Обновляем pageRect для A4 формата после изменения размера и ориентации страницы QRectF pageRectA4 = writer.pageLayout().paintRectPixels(writer.resolution()); - addGostFrameA4(painter, pageRectA4, titleModel, projectParamModel, pageNumber, totalPages,1); + addGostFrameA4(painter, pageRectA4, titleModel, projectParamModel, pageNumber, totalPages,4); QRectF tableRect = pageRectA4.adjusted(20*mmToPixels, 5*mmToPixels, -5*mmToPixels, -20*mmToPixels); addListRegistrationTable(painter, tableRect); painter.end(); @@ -896,8 +896,10 @@ bool PDFController::addGostFrameA4FirstPage(QPainter &painter, const QRectF &pag } else{ - painter.setFont(createGostFont(18, false, true)); - painter.drawText(mainTableRect.left()+65*mmToPixels, mainTableRect.top()+15*mmToPixels,70*mmToPixels, 20*mmToPixels,Qt::AlignCenter, getInscriptionValueWithProjectParams(model, projectParamModel, 1)); + QFont nameFont = createGostFont(18, false, true); + QString nameText = getInscriptionValueWithProjectParams(model, projectParamModel, 1); + QRectF nameRect(mainTableRect.left()+65*mmToPixels, mainTableRect.top()+15*mmToPixels, 70*mmToPixels, 20*mmToPixels); + drawTextMultiline(painter, nameRect, nameText, nameFont, 3); painter.setFont(createGostFont(12, false, true)); painter.drawText(mainTableRect.left()+65*mmToPixels, mainTableRect.top()+35*mmToPixels,70*mmToPixels, 5*mmToPixels,Qt::AlignCenter, getInscriptionValueWithProjectParams(model, projectParamModel, 301)); painter.drawText(mainTableRect.left()+65*mmToPixels, mainTableRect.bottom()-20*mmToPixels,70*mmToPixels, 20*mmToPixels,Qt::AlignHCenter|Qt::AlignBottom, "Ведомость покупных изделий"); diff --git a/controller/simplelisttablecontroller.cpp b/controller/simplelisttablecontroller.cpp new file mode 100644 index 0000000..0470519 --- /dev/null +++ b/controller/simplelisttablecontroller.cpp @@ -0,0 +1,648 @@ +#include "simplelisttablecontroller.h" +#include "../model/componentmodel.h" +#include "../model/specificationpcbtablemodel.h" +#include +#include +#include + +SimpleListTableController::SimpleListTableController(QObject *parent) + : QObject(parent) + , m_tableModel(new SimpleListTableModel(this)) + , m_nameField("Name") + , m_techReservePercent(10) + , m_boardsCount(1) +{ + qDebug() << "SimpleListTableController::SimpleListTableController: Создан контроллер"; +} + +SimpleListTableController::~SimpleListTableController() +{ + // m_tableModel удалится автоматически, так как он является дочерним объектом +} + +void SimpleListTableController::setModel(SimpleListTableModel *model) +{ + if (m_tableModel != model) { + qDebug() << "SimpleListTableController::setModel: Заменяем модель таблицы"; + m_tableModel = model; + // Синхронизируем процент тех запаса с моделью + if (m_tableModel) { + m_tableModel->setTechReservePercent(m_techReservePercent); + } + } +} + +void SimpleListTableController::setComponents(const QList &components) +{ + qDebug() << "SimpleListTableController::setComponents: Устанавливаем" << components.size() << "компонентов"; + m_components = components; + + // Собираем доступные свойства из компонентов + m_propertyNames = collectPropertyNames(); + qDebug() << "SimpleListTableController::setComponents: Собрано свойств:" << m_propertyNames.size(); +} + +void SimpleListTableController::generateTableFromComponents() +{ + if (!m_tableModel) { + return; + } + + qDebug() << "SimpleListTableController::generateTableFromComponents: Начинаем генерацию таблицы"; + qDebug() << "SimpleListTableController::generateTableFromComponents: Количество компонентов:" << m_components.size(); + + // Очищаем таблицу + m_tableModel->clear(); + + if (m_components.isEmpty()) { + return; + } + + try { + // Сортируем компоненты по десигнатору + std::sort(m_components.begin(), m_components.end(), + [this](ComponentModel* a, ComponentModel* b) { + if (!a || !b) return false; + + QString designatorA = a->designator(); + QString designatorB = b->designator(); + + // Разделяем десигнаторы на буквенную и числовую части + QPair partsA = splitDesignator(designatorA); + QPair partsB = splitDesignator(designatorB); + + QString letterA = partsA.first; + QString letterB = partsB.first; + QString numberA = partsA.second; + QString numberB = partsB.second; + + // Сначала сравниваем буквенные части + if (letterA != letterB) { + return letterA < letterB; + } + + // Если буквенные части одинаковые, сравниваем числовые части + bool okA, okB; + int numA = numberA.toInt(&okA); + int numB = numberB.toInt(&okB); + + if (okA && okB) { + return numA < numB; + } + + // Fallback: если не удалось преобразовать в числа, используем строковое сравнение + return numberA < numberB; + }); + + // Группируем компоненты по наименованию (Name) + QString prevName = ""; + QString groupStartDesignator = ""; + QString groupLastDesignator = ""; + int groupCount = 0; + + for (int i = 0; i < m_components.size(); ++i) { + ComponentModel *component = m_components[i]; + if (!component) { + qDebug() << "SimpleListTableController::generateTableFromComponents: Пропускаем nullptr компонент на позиции" << i; + continue; + } + + QString currentName = getComponentValue(component, m_nameField); + + if (i == 0 || currentName != prevName) { + // Добавляем предыдущую группу, если она есть + if (i > 0 && groupCount > 0) { + SimpleListRowData row; + + // Формируем строку десигнаторов + QString designatorText; + if (groupCount == 1) { + designatorText = groupStartDesignator; + } else if (groupCount == 2) { + designatorText = groupStartDesignator + ", " + groupLastDesignator; + } else { + designatorText = groupStartDesignator + "-" + groupLastDesignator; + } + + row.designator = SimpleListCellData(designatorText, false, false); + row.name = SimpleListCellData(prevName, false, false); + // (кол-во элементов * кол-во плат) + техзапас + int baseQty = groupCount * m_boardsCount; + int reserve = qMax(1, qRound(baseQty * m_techReservePercent / 100.0)); + int quantityWithReserve = baseQty + reserve; + row.quantity = SimpleListCellData(QString::number(quantityWithReserve), false, false); + + if (m_tableModel) { + m_tableModel->addRow(row); + } + } + + // Начинаем новую группу + groupStartDesignator = component->designator(); + groupLastDesignator = component->designator(); + groupCount = 1; + prevName = currentName; + } else { + // Продолжаем текущую группу - обновляем последний десигнатор + groupLastDesignator = component->designator(); + groupCount++; + } + } + + // Добавляем последнюю группу + if (groupCount > 0 && m_tableModel) { + SimpleListRowData row; + + QString designatorText; + if (groupCount == 1) { + designatorText = groupStartDesignator; + } else if (groupCount == 2) { + designatorText = groupStartDesignator + ", " + groupLastDesignator; + } else { + designatorText = groupStartDesignator + "-" + groupLastDesignator; + } + + row.designator = SimpleListCellData(designatorText, false, false); + row.name = SimpleListCellData(prevName, false, false); + // (кол-во элементов * кол-во плат) + техзапас + int baseQty = groupCount * m_boardsCount; + int reserve = qMax(1, qRound(baseQty * m_techReservePercent / 100.0)); + int quantityWithReserve = baseQty + reserve; + row.quantity = SimpleListCellData(QString::number(quantityWithReserve), false, false); + + m_tableModel->addRow(row); + } + } catch (const std::exception &e) { + qDebug() << "SimpleListTableController::generateTableFromComponents: Ошибка:" << e.what(); + } + + qDebug() << "SimpleListTableController::generateTableFromComponents: Генерация завершена"; +} + +void SimpleListTableController::generateTableFromSpecificationPCB(SpecificationPCBTableModel *specPCBModel) +{ + if (!m_tableModel || !specPCBModel) { + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Модель недоступна"; + return; + } + + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Начинаем генерацию из SpecificationPCB"; + + // Очищаем таблицу + m_tableModel->clear(); + + // Получаем все строки из SpecificationPCB + QList allRows = specPCBModel->getRows(); + + if (allRows.isEmpty()) { + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Нет данных в SpecificationPCB"; + return; + } + + // Находим индекс строки с "Прочие изделия" + int startIndex = -1; + for (int i = 0; i < allRows.size(); ++i) { + if (allRows[i].name.value == "Прочие изделия" && allRows[i].isHeader) { + startIndex = i; + break; + } + } + + if (startIndex == -1) { + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Не найдена строка 'Прочие изделия'"; + return; + } + + // Находим индекс строки с "Примечание" + int endIndex = -1; + for (int i = startIndex + 1; i < allRows.size(); ++i) { + if (allRows[i].name.value == "Примечание" && !allRows[i].isHeader) { + endIndex = i; + break; + } + } + + if (endIndex == -1) { + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Не найдена строка 'Примечание'"; + // Если не найдено "Примечание", берем все строки до конца + endIndex = allRows.size(); + } + + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Найдены строки от" << startIndex << "до" << endIndex; + + // Извлекаем строки между "Прочие изделия" и "Примечание" + // Пропускаем саму строку "Прочие изделия" и пустые строки после нее + QList relevantRows; + for (int i = startIndex + 1; i < endIndex; ++i) { + // Пропускаем пустые строки + if (allRows[i].isEmpty) { + continue; + } + // Пропускаем заголовки + if (allRows[i].isHeader) { + continue; + } + relevantRows.append(allRows[i]); + } + + if (relevantRows.isEmpty()) { + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Нет релевантных строк"; + return; + } + + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Найдено релевантных строк:" << relevantRows.size(); + + // Группируем строки по позиции (position) + // Строки с одинаковой позицией должны быть склеены вместе + // Используем QMap для группировки, но обрабатываем в порядке появления + QMap> groupedByPosition; + QStringList positionOrder; // Для сохранения порядка позиций + + QString currentPosition; + for (const SpecificationPCBRowData &row : relevantRows) { + QString position = row.position.value.trimmed(); + // Если позиция пуста, это продолжение предыдущей позиции + if (position.isEmpty()) { + // Добавляем к текущей позиции + if (!currentPosition.isEmpty()) { + groupedByPosition[currentPosition].append(row); + } + } else { + // Новая позиция + currentPosition = position; + if (!groupedByPosition.contains(position)) { + groupedByPosition[position] = QList(); + positionOrder.append(position); // Сохраняем порядок + } + groupedByPosition[position].append(row); + } + } + + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Сгруппировано позиций:" << groupedByPosition.size(); + + // Обрабатываем каждую группу позиций в порядке появления + for (const QString &position : positionOrder) { + if (!groupedByPosition.contains(position)) { + continue; + } + QList rowsForPosition = groupedByPosition[position]; + + if (rowsForPosition.isEmpty()) { + continue; + } + + // Склеиваем наименование (name) и примечание (note) для всех строк этой позиции + QStringList nameParts; + QStringList noteParts; + + for (const SpecificationPCBRowData &row : rowsForPosition) { + QString nameValue = row.name.value.trimmed(); + QString noteValue = row.note.value.trimmed(); + + if (!nameValue.isEmpty()) { + nameParts.append(nameValue); + } + // Примечание (десигнаторы): разбиваем по переносам строк внутри ячейки (R1, R3\nR5 -> R1, R3, R5) + if (!noteValue.isEmpty()) { + QString normalized = noteValue.replace("\r\n", "\n").replace('\r', '\n'); + for (const QString &line : normalized.split('\n', Qt::SkipEmptyParts)) { + QString trimmed = line.trimmed(); + if (!trimmed.isEmpty()) { + noteParts.append(trimmed); + } + } + } + } + + // Объединяем части наименования и примечания обратно в одну строку + QString fullName = nameParts.join(" "); // Склеиваем многострочные наименования + QString fullNote = noteParts.join(", "); // Десигнаторы (R1, R3, R5) — между частями после переноса ставим запятую + + // Получаем количество из первой строки позиции (остальные строки обычно пустые по количеству) + int quantity = 0; + for (const SpecificationPCBRowData &row : rowsForPosition) { + QString qtyStr = row.quantity.value.trimmed(); + if (!qtyStr.isEmpty()) { + bool ok; + int qty = qtyStr.toInt(&ok); + if (ok && qty > 0) { + quantity = qty; + break; + } + } + } + + // Если количество не найдено, считаем количество строк с номерами позиций + if (quantity == 0) { + quantity = 1; // По умолчанию 1 + } + + // (кол-во элементов * кол-во плат) + техзапас + int baseQty = quantity * m_boardsCount; + int reserve = qMax(1, qRound(baseQty * m_techReservePercent / 100.0)); + int quantityWithReserve = baseQty + reserve; + + // Создаем строку для SimpleList + SimpleListRowData row; + row.designator = SimpleListCellData(fullNote, false, false); // Поз.обозначение из примечания (десигнаторы) + row.name = SimpleListCellData(fullName, false, false); // Наименование (склеенное из всех строк позиции) + row.quantity = SimpleListCellData(QString::number(quantityWithReserve), false, false); + + m_tableModel->addRow(row); + + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Добавлена строка - Позиция:" << position + << "Поз.обозначение:" << fullNote << "Наименование:" << fullName << "Кол-во:" << quantityWithReserve; + } + + qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Генерация завершена, строк:" << m_tableModel->rowCount(); +} + +void SimpleListTableController::updateTableFromComponents() +{ + generateTableFromComponents(); +} + +void SimpleListTableController::addEmptyRow() +{ + if (!m_tableModel) { + return; + } + m_tableModel->addEmptyRow(); +} + +void SimpleListTableController::addEmptyRowAt(int position) +{ + if (!m_tableModel) { + return; + } + m_tableModel->addEmptyRowAt(position); +} + +void SimpleListTableController::insertEmptyRowAt(int position) +{ + if (!m_tableModel) { + return; + } + m_tableModel->addEmptyRowAt(position); +} + +void SimpleListTableController::removeRow(int row) +{ + if (!m_tableModel) { + return; + } + m_tableModel->removeRow(row); +} + +void SimpleListTableController::clearTable() +{ + if (!m_tableModel) { + return; + } + m_tableModel->clear(); +} + +QString SimpleListTableController::getComponentValue(ComponentModel *component, const QString &propertyName) +{ + if (!component) { + return ""; + } + + // Получаем значение свойства компонента + QString value; + if (propertyName == "Designator") { + value = component->designator(); + } else { + QMap props = component->properties(); + value = props.value(propertyName, ""); + } + + // Проверяем, является ли значение свойства выражением + if (value.startsWith("=")) { + qDebug() << "SimpleListTableController::getComponentValue: Значение свойства является выражением:" << value; + return parseExpression(component, value); + } + + return value; +} + +QString SimpleListTableController::parseExpression(ComponentModel *component, const QString &expression) +{ + if (!component) { + return ""; + } + + qDebug() << "SimpleListTableController::parseExpression: Обрабатываем выражение:" << expression; + + QString result; + QString expr; + + // Определяем, как обрабатывать выражение + if (expression.startsWith("=")) { + expr = expression.mid(1); // Убираем начальный "=" + } else if (expression.startsWith("\"") && expression.endsWith("\"")) { + // Если это просто значение в кавычках, возвращаем его без кавычек + return expression.mid(1, expression.length() - 2); + } else { + // Если это не выражение, возвращаем как есть + return expression; + } + + // Разбиваем выражение по операторам "+" с учетом кавычек + QStringList parts; + QString currentPart; + bool inDoubleQuotes = false; + bool inSingleQuotes = false; + + for (int i = 0; i < expr.length(); ++i) { + QChar ch = expr[i]; + + if (ch == '"' && !inSingleQuotes) { + inDoubleQuotes = !inDoubleQuotes; + currentPart += ch; + } else if (ch == '\'' && !inDoubleQuotes) { + inSingleQuotes = !inSingleQuotes; + currentPart += ch; + } else if (ch == '+' && !inDoubleQuotes && !inSingleQuotes) { + // Это разделитель "+" вне кавычек + if (!currentPart.trimmed().isEmpty()) { + parts.append(currentPart.trimmed()); + } + currentPart.clear(); + } else { + currentPart += ch; + } + } + + // Добавляем последнюю часть + if (!currentPart.trimmed().isEmpty()) { + parts.append(currentPart.trimmed()); + } + + qDebug() << "SimpleListTableController::parseExpression: Части выражения:" << parts; + + for (const QString &part : parts) { + QString trimmedPart = part.trimmed(); + qDebug() << "SimpleListTableController::parseExpression: Обрабатываем часть:" << trimmedPart; + + // Если это не пустая строка, добавляем к результату + if (!trimmedPart.isEmpty()) { + QString value; + + // Проверяем, является ли это литералом в одинарных кавычках + if (trimmedPart.startsWith("'") && trimmedPart.endsWith("'")) { + // Это литерал - убираем кавычки и используем как есть + value = trimmedPart.mid(1, trimmedPart.length() - 2); + qDebug() << "SimpleListTableController::parseExpression: Найден литерал в одинарных кавычках:" << value; + } + // Проверяем, является ли это именем свойства компонента в двойных кавычках + else if (trimmedPart.startsWith("\"") && trimmedPart.endsWith("\"")) { + // Это свойство компонента - убираем кавычки и ищем в свойствах + QString propertyName = trimmedPart.mid(1, trimmedPart.length() - 2); + qDebug() << "SimpleListTableController::parseExpression: Ищем свойство компонента:" << propertyName; + + if (propertyName == "Designator") { + value = component->designator(); + qDebug() << "SimpleListTableController::parseExpression: Найдено свойство Designator =" << value; + } else { + // Ищем в свойствах компонента + QMap props = component->properties(); + value = props.value(propertyName, ""); + qDebug() << "SimpleListTableController::parseExpression: Ищем свойство" << propertyName << "в компоненте, результат:" << value; + // Рекурсивно обрабатываем вложенные выражения + if (value.startsWith("=")) { + qDebug() << "SimpleListTableController::parseExpression: Значение свойства является вложенным выражением, рекурсивно обрабатываем"; + value = parseExpression(component, value); + } + } + } + // Проверяем, является ли это именем свойства компонента без кавычек + else if (trimmedPart == "Designator") { + value = component->designator(); + qDebug() << "SimpleListTableController::parseExpression: Найдено свойство Designator =" << value; + } else { + // Ищем в свойствах компонента + QMap props = component->properties(); + value = props.value(trimmedPart, ""); + qDebug() << "SimpleListTableController::parseExpression: Ищем свойство" << trimmedPart << "в компоненте, результат:" << value; + // Рекурсивно обрабатываем вложенные выражения + if (value.startsWith("=")) { + qDebug() << "SimpleListTableController::parseExpression: Значение свойства является вложенным выражением, рекурсивно обрабатываем"; + value = parseExpression(component, value); + } + } + + // Добавляем значение к результату + if (!value.isEmpty()) { + if (!result.isEmpty()) { + result += value; + } else { + result = value; + } + qDebug() << "SimpleListTableController::parseExpression: Добавили к результату, текущий результат:" << result; + } + } + } + + qDebug() << "SimpleListTableController::parseExpression: Финальный результат:" << result; + return result; +} + +QStringList SimpleListTableController::collectPropertyNames() +{ + QStringList propertyNames; + + // Добавляем основные свойства компонента + propertyNames << "Designator"; + + // Собираем дополнительные свойства из компонентов + for (ComponentModel *component : m_components) { + QMap props = component->properties(); + for (auto it = props.begin(); it != props.end(); ++it) { + if (!propertyNames.contains(it.key())) { + propertyNames.append(it.key()); + } + } + } + + // Если дополнительные свойства не найдены в компонентах, добавляем стандартные + if (propertyNames.size() == 1) { // Только Designator + propertyNames << "Name" << "Type" << "Value" << "Footprint" << "Description"; + } + + return propertyNames; +} + +QPair SimpleListTableController::splitDesignator(const QString &designator) +{ + if (designator.isEmpty()) { + return QPair("", ""); + } + + // Используем регулярное выражение для разделения буквенной и цифровой части + QRegExp regex("([A-Za-z]+)(\\d*)"); + if (regex.indexIn(designator) != -1) { + QString letterPart = regex.cap(1); + QString numberPart = regex.cap(2); + return QPair(letterPart, numberPart); + } + + // Fallback: если регулярное выражение не сработало, + // ищем первую цифру и разделяем по ней + for (int i = 0; i < designator.length(); ++i) { + if (designator[i].isDigit()) { + QString letterPart = designator.left(i); + QString numberPart = designator.mid(i); + return QPair(letterPart, numberPart); + } + } + + // Если цифр нет, возвращаем весь десигнатор как буквенную часть + return QPair(designator, ""); +} + +void SimpleListTableController::setNameField(const QString &fieldName) +{ + qDebug() << "SimpleListTableController::setNameField: Устанавливаем поле наименования:" << fieldName; + m_nameField = fieldName; +} + +QString SimpleListTableController::getNameField() const +{ + return m_nameField; +} + +void SimpleListTableController::setTechReservePercent(int percent) +{ + qDebug() << "SimpleListTableController::setTechReservePercent: Устанавливаем процент тех запаса:" << percent; + m_techReservePercent = percent; + + // Синхронизируем с моделью для обновления заголовка + if (m_tableModel) { + m_tableModel->setTechReservePercent(percent); + } +} + +int SimpleListTableController::getTechReservePercent() const +{ + return m_techReservePercent; +} + +void SimpleListTableController::setBoardsCount(int count) +{ + if (count < 1) { + count = 1; + } + m_boardsCount = count; +} + +int SimpleListTableController::getBoardsCount() const +{ + return m_boardsCount; +} + +QStringList SimpleListTableController::getAvailableProperties() const +{ + return m_propertyNames; +} + diff --git a/controller/simplelisttablecontroller.h b/controller/simplelisttablecontroller.h new file mode 100644 index 0000000..79e6cbc --- /dev/null +++ b/controller/simplelisttablecontroller.h @@ -0,0 +1,65 @@ +#ifndef SIMPLELISTTABLECONTROLLER_H +#define SIMPLELISTTABLECONTROLLER_H + +#include +#include +#include +#include "../model/simplelisttablemodel.h" + +class ComponentModel; + +class SimpleListTableController : public QObject +{ + Q_OBJECT + +public: + explicit SimpleListTableController(QObject *parent = nullptr); + ~SimpleListTableController(); + + // Получение модели для отображения в UI + SimpleListTableModel* getTableModel() const { return m_tableModel; } + SimpleListTableModel* getModel() const { return m_tableModel; } // Алиас для совместимости + + // Методы для работы с компонентами + void setComponents(const QList &components); + void generateTableFromComponents(); + void generateTableFromSpecificationPCB(class SpecificationPCBTableModel *specPCBModel); // Генерация из SpecificationPCB + void updateTableFromComponents(); // Метод для обновления таблицы по кнопке + + // Новые методы для управления строками + void addEmptyRow(); + void addEmptyRowAt(int position); + void insertEmptyRowAt(int position); // Вставка строки в определенную позицию + void removeRow(int row); + void clearTable(); + + // Метод для установки модели таблицы (для интеграции) + void setModel(SimpleListTableModel *model); + + // Методы для работы с настройками + void setNameField(const QString &fieldName); + QString getNameField() const; + void setTechReservePercent(int percent); + int getTechReservePercent() const; + void setBoardsCount(int count); + int getBoardsCount() const; + QStringList getAvailableProperties() const; + +private: + QString getComponentValue(ComponentModel *component, const QString &propertyName); + QString parseExpression(ComponentModel *component, const QString &expression); + QStringList collectPropertyNames(); + + // Метод для разделения десигнатора на буквенную и цифровую части + static QPair splitDesignator(const QString &designator); + + SimpleListTableModel *m_tableModel; + QList m_components; + QStringList m_propertyNames; + QString m_nameField; // Поле для наименования компонента (по умолчанию "Name") + int m_techReservePercent; // Процент тех запаса (по умолчанию 10) + int m_boardsCount; // Кол-во плат (по умолчанию 1) +}; + +#endif // SIMPLELISTTABLECONTROLLER_H + diff --git a/model/simplelisttablemodel.cpp b/model/simplelisttablemodel.cpp new file mode 100644 index 0000000..3b2a51d --- /dev/null +++ b/model/simplelisttablemodel.cpp @@ -0,0 +1,378 @@ +#include "simplelisttablemodel.h" +#include "componentmodel.h" +#include +#include +#include +#include + +// Регистрируем структуры для работы с QVariant +Q_DECLARE_METATYPE(SimpleListCellData) +Q_DECLARE_METATYPE(SimpleListRowData) + +// Статические константы +const QStringList SimpleListTableModel::DEFAULT_HEADERS = QStringList() << "Поз.обозначение" << "Наименование" << "Кол."; + +SimpleListTableModel::SimpleListTableModel(QObject *parent) + : QAbstractTableModel(parent) + , m_techReservePercent(10) +{ + qDebug() << "SimpleListTableModel::SimpleListTableModel: Создана модель"; +} + +int SimpleListTableModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return m_rows.size(); +} + +int SimpleListTableModel::columnCount(const QModelIndex &parent) const +{ + if (parent.isValid()) + return 0; + return COLUMN_COUNT; +} + +QVariant SimpleListTableModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= m_rows.size()) + return QVariant(); + + const SimpleListRowData &row = m_rows[index.row()]; + + if (role == Qt::DisplayRole || role == Qt::EditRole) { + switch (index.column()) { + case 0: return row.designator.value; + case 1: return row.name.value; + case 2: return row.quantity.value; + default: return QVariant(); + } + } else if (role == Qt::UserRole) { + // Возвращаем структуру ячейки для дополнительной информации + switch (index.column()) { + case 0: return QVariant::fromValue(row.designator); + case 1: return QVariant::fromValue(row.name); + case 2: return QVariant::fromValue(row.quantity); + default: return QVariant(); + } + } + + return QVariant(); +} + +QVariant SimpleListTableModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole) + return QVariant(); + + if (orientation == Qt::Horizontal) { + if (section < DEFAULT_HEADERS.size()) { + QString header = DEFAULT_HEADERS[section]; + // Для столбца "Кол." (индекс 2) добавляем процент тех запаса + if (section == 2) { + header = QString("Кол-во (+%1%)").arg(m_techReservePercent); + } + return header; + } + } else { + return section + 1; + } + + return QVariant(); +} + +bool SimpleListTableModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (!index.isValid() || index.row() >= m_rows.size() || role != Qt::EditRole) + return false; + + if (!canEditRow(index.row())) { + qDebug() << "SimpleListTableModel::setData: Строка" << index.row() << "не может быть отредактирована"; + return false; + } + + SimpleListRowData &row = m_rows[index.row()]; + QString newValue = value.toString(); + + switch (index.column()) { + case 0: + row.designator.value = newValue; + row.designator.isAutoGenerated = false; + row.designator.isEmpty = newValue.isEmpty(); + break; + case 1: + row.name.value = newValue; + row.name.isAutoGenerated = false; + row.name.isEmpty = newValue.isEmpty(); + break; + case 2: + row.quantity.value = newValue; + row.quantity.isAutoGenerated = false; + row.quantity.isEmpty = newValue.isEmpty(); + break; + default: + return false; + } + + // Обновляем флаг isEmpty для строки + updateRowEmptyStatus(index.row()); + + emit dataChanged(index, index, QVector() << role); + return true; +} + +Qt::ItemFlags SimpleListTableModel::flags(const QModelIndex &index) const +{ + if (!index.isValid()) + return Qt::NoItemFlags; + + Qt::ItemFlags flags = Qt::ItemIsEnabled | Qt::ItemIsSelectable; + + // Разрешаем редактирование только для редактируемых строк + if (canEditRow(index.row())) { + flags |= Qt::ItemIsEditable; + } + + return flags; +} + +void SimpleListTableModel::addRow(const SimpleListRowData &rowData) +{ + beginInsertRows(QModelIndex(), m_rows.size(), m_rows.size()); + m_rows.append(rowData); + endInsertRows(); + emit modelDataChanged(); +} + +void SimpleListTableModel::insertRow(int position, const SimpleListRowData &rowData) +{ + if (position < 0 || position > m_rows.size()) + return; + + beginInsertRows(QModelIndex(), position, position); + m_rows.insert(position, rowData); + endInsertRows(); + emit modelDataChanged(); +} + +void SimpleListTableModel::removeRow(int row) +{ + if (row < 0 || row >= m_rows.size()) + return; + + beginRemoveRows(QModelIndex(), row, row); + m_rows.removeAt(row); + endRemoveRows(); + emit modelDataChanged(); +} + +void SimpleListTableModel::clear() +{ + qDebug() << "SimpleListTableModel::clear: Очищаем таблицу, строк:" << m_rows.size(); + + if (m_rows.isEmpty()) { + return; + } + + beginResetModel(); + m_rows.clear(); + endResetModel(); + + emit modelDataChanged(); +} + +void SimpleListTableModel::setComponents(const QList &components) +{ + qDebug() << "SimpleListTableModel::setComponents: Получено" << components.size() << "компонентов"; + + // Проверяем компоненты на null + QList validComponents; + for (ComponentModel *component : components) { + if (component) { + validComponents.append(component); + } + } + + m_components = validComponents; + qDebug() << "SimpleListTableModel::setComponents: Сохранено" << m_components.size() << "валидных компонентов"; +} + +QByteArray SimpleListTableModel::saveToDatabase() const +{ + qDebug() << "SimpleListTableModel::saveToDatabase: Начинаем сохранение в БД"; + qDebug() << "SimpleListTableModel::saveToDatabase: Количество строк:" << m_rows.size(); + + QJsonObject root; + QJsonArray rowsArray; + + for (const SimpleListRowData &row : m_rows) { + QJsonObject rowObj; + + // Сохраняем данные ячеек + QJsonObject designatorObj; + designatorObj["value"] = row.designator.value; + designatorObj["isAutoGenerated"] = row.designator.isAutoGenerated; + designatorObj["isEmpty"] = row.designator.isEmpty; + rowObj["designator"] = designatorObj; + + QJsonObject nameObj; + nameObj["value"] = row.name.value; + nameObj["isAutoGenerated"] = row.name.isAutoGenerated; + nameObj["isEmpty"] = row.name.isEmpty; + rowObj["name"] = nameObj; + + QJsonObject quantityObj; + quantityObj["value"] = row.quantity.value; + quantityObj["isAutoGenerated"] = row.quantity.isAutoGenerated; + quantityObj["isEmpty"] = row.quantity.isEmpty; + rowObj["quantity"] = quantityObj; + + rowObj["isEmpty"] = row.isEmpty; + + rowsArray.append(rowObj); + } + + root["rows"] = rowsArray; + + QJsonDocument doc(root); + QByteArray result = doc.toJson(); + + qDebug() << "SimpleListTableModel::saveToDatabase: Сохранение завершено, размер данных:" << result.size() << "байт"; + return result; +} + +void SimpleListTableModel::loadFromDatabase(const QByteArray &data) +{ + qDebug() << "SimpleListTableModel::loadFromDatabase: Начинаем загрузку из БД, размер данных:" << data.size(); + + QJsonDocument doc = QJsonDocument::fromJson(data); + if (!doc.isObject()) { + qDebug() << "SimpleListTableModel::loadFromDatabase: Ошибка парсинга JSON"; + return; + } + + QJsonObject root = doc.object(); + + // Загружаем строки + clear(); + if (root.contains("rows")) { + QJsonArray rowsArray = root["rows"].toArray(); + for (const QJsonValue &value : rowsArray) { + QJsonObject rowObj = value.toObject(); + SimpleListRowData row; + + if (rowObj.contains("designator")) { + QJsonObject desObj = rowObj["designator"].toObject(); + row.designator = SimpleListCellData( + desObj["value"].toString(), + desObj["isAutoGenerated"].toBool(), + desObj["isEmpty"].toBool() + ); + } + + if (rowObj.contains("name")) { + QJsonObject nameObj = rowObj["name"].toObject(); + row.name = SimpleListCellData( + nameObj["value"].toString(), + nameObj["isAutoGenerated"].toBool(), + nameObj["isEmpty"].toBool() + ); + } + + if (rowObj.contains("quantity")) { + QJsonObject qtyObj = rowObj["quantity"].toObject(); + row.quantity = SimpleListCellData( + qtyObj["value"].toString(), + qtyObj["isAutoGenerated"].toBool(), + qtyObj["isEmpty"].toBool() + ); + } + + row.isEmpty = rowObj["isEmpty"].toBool(); + + addRow(row); + } + } + + qDebug() << "SimpleListTableModel::loadFromDatabase: Загружено строк:" << m_rows.size(); +} + +QString SimpleListTableModel::getDocumentTitle() const +{ + return "Список элементов"; +} + +QString SimpleListTableModel::getDocumentType() const +{ + return "SimpleList"; +} + +void SimpleListTableModel::addEmptyRow() +{ + SimpleListRowData emptyRow = createEmptyRow(); + addRow(emptyRow); +} + +void SimpleListTableModel::addEmptyRowAt(int position) +{ + SimpleListRowData emptyRow = createEmptyRow(); + insertRow(position, emptyRow); +} + +SimpleListRowData SimpleListTableModel::createEmptyRow() const +{ + SimpleListRowData row; + row.isEmpty = false; + + row.designator = SimpleListCellData("", false, true); + row.name = SimpleListCellData("", false, true); + row.quantity = SimpleListCellData("", false, true); + + return row; +} + +bool SimpleListTableModel::canEditRow(int row) const +{ + if (row < 0 || row >= m_rows.size()) + return false; + + return true; +} + +void SimpleListTableModel::updateRowEmptyStatus(int row) +{ + if (row < 0 || row >= m_rows.size()) + return; + + SimpleListRowData &rowData = m_rows[row]; + + // Проверяем, пустые ли все ячейки + bool allEmpty = rowData.designator.value.isEmpty() && + rowData.name.value.isEmpty() && + rowData.quantity.value.isEmpty(); + + // Обновляем флаг isEmpty для строки + if (rowData.isEmpty != allEmpty) { + rowData.isEmpty = allEmpty; + } +} + +SimpleListCellData SimpleListTableModel::createCellData(const QString &value, bool isAuto, bool isEmpty) +{ + return SimpleListCellData(value, isAuto, isEmpty); +} + +void SimpleListTableModel::setTechReservePercent(int percent) +{ + if (m_techReservePercent != percent) { + m_techReservePercent = percent; + // Обновляем заголовок столбца количества + emit headerDataChanged(Qt::Horizontal, 2, 2); + } +} + +int SimpleListTableModel::getTechReservePercent() const +{ + return m_techReservePercent; +} + diff --git a/model/simplelisttablemodel.h b/model/simplelisttablemodel.h new file mode 100644 index 0000000..407cbcd --- /dev/null +++ b/model/simplelisttablemodel.h @@ -0,0 +1,98 @@ +#ifndef SIMPLELISTTABLEMODEL_H +#define SIMPLELISTTABLEMODEL_H + +#include +#include +#include +#include +#include +#include +#include + +// Forward declaration +class ComponentModel; + +// Структура для хранения данных ячейки +struct SimpleListCellData +{ + QString value; // Значение ячейки + bool isAutoGenerated; // Автоматически сгенерированное значение + bool isEmpty; // Пустая ячейка + + SimpleListCellData() : isAutoGenerated(false), isEmpty(true) {} + + SimpleListCellData(const QString &val, bool autoGen = false, bool empty = false) + : value(val), isAutoGenerated(autoGen), isEmpty(empty) {} +}; + +// Структура для хранения строки таблицы +struct SimpleListRowData +{ + SimpleListCellData designator; // Десигнатор (поз. обозначение) + SimpleListCellData name; // Наименование + SimpleListCellData quantity; // Количество + bool isEmpty; // Пустая строка + + SimpleListRowData() : isEmpty(false) {} +}; + +class SimpleListTableModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + explicit SimpleListTableModel(QObject *parent = nullptr); + + // Методы QAbstractTableModel + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex &index) const override; + + // Методы для работы со строками + void addRow(const SimpleListRowData &rowData); + void insertRow(int position, const SimpleListRowData &rowData); + void removeRow(int row); + void clear(); + + // Новые методы для управления строками + void addEmptyRow(); + void addEmptyRowAt(int position); + SimpleListRowData createEmptyRow() const; + bool canEditRow(int row) const; + + // Методы для работы с компонентами + void setComponents(const QList &components); + + // Методы для сохранения/загрузки + QByteArray saveToDatabase() const; + void loadFromDatabase(const QByteArray &data); + + // Методы для экспорта + QString getDocumentTitle() const; + QString getDocumentType() const; + + // Методы для работы с тех. запасом + void setTechReservePercent(int percent); + int getTechReservePercent() const; + +signals: + void modelDataChanged(); + void headerDataChanged(Qt::Orientation orientation, int first, int last); + +private: + void updateRowEmptyStatus(int row); + SimpleListCellData createCellData(const QString &value, bool isAuto = false, bool isEmpty = false); + + QList m_rows; + QList m_components; // Компоненты для генерации + int m_techReservePercent; // Процент тех запаса + + static const int COLUMN_COUNT = 3; + static const QStringList DEFAULT_HEADERS; +}; + +#endif // SIMPLELISTTABLEMODEL_H + diff --git a/model/specificationpcbtablemodel.cpp b/model/specificationpcbtablemodel.cpp index 617a53c..80e3ea9 100644 --- a/model/specificationpcbtablemodel.cpp +++ b/model/specificationpcbtablemodel.cpp @@ -1003,6 +1003,11 @@ QString SpecificationPCBTableModel::getDocumentType() const return "SpecificationPCB"; } +QList SpecificationPCBTableModel::getRows() const +{ + return m_rows; +} + bool SpecificationPCBTableModel::isRowHeader(int row) const { if (row < 0 || row >= m_rows.size()) { diff --git a/model/specificationpcbtablemodel.h b/model/specificationpcbtablemodel.h index 088601c..c3498c9 100644 --- a/model/specificationpcbtablemodel.h +++ b/model/specificationpcbtablemodel.h @@ -111,6 +111,9 @@ public: QString getDocumentTitle() const; QString getDocumentType() const; + // Метод для получения строк (для использования в SimpleList) + QList getRows() const; + // Методы для проверки статуса строки bool isRowHeader(int row) const; diff --git a/view/documentprocessor.cpp b/view/documentprocessor.cpp index 9190b2e..b91fa1c 100644 --- a/view/documentprocessor.cpp +++ b/view/documentprocessor.cpp @@ -618,6 +618,109 @@ void VedomostProcessor::setModel(VedomostTableModel *model) } } +// SimpleListProcessor +SimpleListProcessor::SimpleListProcessor(QObject *parent) + : DocumentProcessor(parent) + , m_controller(new SimpleListTableController(this)) +{ +} + +SimpleListProcessor::~SimpleListProcessor() +{ + // m_controller удалится автоматически, так как он является дочерним объектом +} + +void SimpleListProcessor::processComponents(const QList &components) +{ + qDebug() << "SimpleListProcessor::processComponents: Получено" << components.size() << "компонентов"; + // Передаем компоненты в контроллер + if (m_controller) { + m_controller->setComponents(components); + qDebug() << "SimpleListProcessor::processComponents: Компоненты переданы в контроллер"; + } +} + +void SimpleListProcessor::processProjectParams(const QMap ¶ms) +{ + qDebug() << "SimpleListProcessor::processProjectParams: Получено" << params.size() << "параметров"; + // Пока не используем параметры проекта +} + +QTableView* SimpleListProcessor::createTableView() +{ + QTableView *tableView = new QTableView(); + if (m_controller) { + tableView->setModel(m_controller->getTableModel()); + + // Настройка растягивания столбцов + QHeaderView *horizontalHeader = tableView->horizontalHeader(); + horizontalHeader->setStretchLastSection(false); + + // Устанавливаем режимы для каждого столбца + horizontalHeader->setSectionResizeMode(0, QHeaderView::Interactive); // Поз.обозначение + horizontalHeader->setSectionResizeMode(1, QHeaderView::Interactive); // Наименование + horizontalHeader->setSectionResizeMode(2, QHeaderView::Interactive); // Количество + + // Устанавливаем начальные размеры для столбцов + horizontalHeader->resizeSection(0, 150); // Поз.обозначение + horizontalHeader->resizeSection(1, 300); // Наименование + horizontalHeader->resizeSection(2, 80); // Количество + + // Настройка вертикального заголовка + QHeaderView *verticalHeader = tableView->verticalHeader(); + verticalHeader->setDefaultSectionSize(25); + verticalHeader->setSectionResizeMode(QHeaderView::Fixed); + + qDebug() << "SimpleListProcessor::createTableView: Настроено растягивание столбцов"; + } else { + // Если контроллер не установлен, создаем временную модель + QStandardItemModel *tempModel = new QStandardItemModel(tableView); + QStringList headers; + headers << "Поз.обозначение" << "Наименование" << "Кол."; + tempModel->setHorizontalHeaderLabels(headers); + tableView->setModel(tempModel); + + // Настройка столбцов + QHeaderView *horizontalHeader = tableView->horizontalHeader(); + horizontalHeader->setStretchLastSection(false); + horizontalHeader->setSectionResizeMode(0, QHeaderView::Interactive); + horizontalHeader->setSectionResizeMode(1, QHeaderView::Interactive); + horizontalHeader->setSectionResizeMode(2, QHeaderView::Interactive); + horizontalHeader->resizeSection(0, 150); + horizontalHeader->resizeSection(1, 300); + horizontalHeader->resizeSection(2, 80); + + QHeaderView *verticalHeader = tableView->verticalHeader(); + verticalHeader->setDefaultSectionSize(25); + verticalHeader->setSectionResizeMode(QHeaderView::Fixed); + + qDebug() << "SimpleListProcessor::createTableView: Создана временная модель"; + } + return tableView; +} + +void SimpleListProcessor::updateTable() +{ + qDebug() << "SimpleListProcessor::updateTable: Обновляем таблицу"; + if (m_controller) { + m_controller->updateTableFromComponents(); + } +} + +void SimpleListProcessor::setController(SimpleListTableController *controller) +{ + qDebug() << "SimpleListProcessor::setController: Устанавливаем контроллер"; + m_controller = controller; +} + +void SimpleListProcessor::setModel(SimpleListTableModel *model) +{ + qDebug() << "SimpleListProcessor::setModel: Модель установлена в контроллер"; + if (m_controller) { + m_controller->setModel(model); + } +} + // DocumentEditWidget DocumentEditWidget::DocumentEditWidget(DocumentProcessor *processor, QWidget *parent) : QWidget(parent) @@ -759,6 +862,8 @@ void DocumentEditWidget::onOpenColumnSettings() docType = DocumentType::Specification; } else if (qobject_cast(m_processor)) { docType = DocumentType::Vedomost; + } else if (qobject_cast(m_processor)) { + docType = DocumentType::SimpleList; } else { qDebug() << "DocumentEditWidget::onOpenColumnSettings: Процессор не поддерживает настройки"; return; @@ -774,7 +879,13 @@ void DocumentEditWidget::onOpenColumnSettings() qDebug() << "DocumentEditWidget::onOpenColumnSettings: Получены настройки таблицы:" << newTableMappings; qDebug() << "DocumentEditWidget::onOpenColumnSettings: Получены настройки надписей:" << newInscriptionValues; - // Настройки таблицы и надписей уже сохранены в диалоге + // Настройки таблицы и надписей уже сохранены в диалоге в контроллер + // Сохраняем настройки в БД + if (m_controller) { + m_controller->saveProjectSettingsToDatabase(); + qDebug() << "DocumentEditWidget::onOpenColumnSettings: Настройки сохранены в БД"; + } + qDebug() << "DocumentEditWidget::onOpenColumnSettings: Настройки обновлены, но таблица не генерируется автоматически"; } else { qDebug() << "DocumentEditWidget::onOpenColumnSettings: Диалог настроек отменен"; @@ -887,7 +998,45 @@ void DocumentEditWidget::onGenerateTable() qDebug() << "DocumentEditWidget::onGenerateTable: VedomostController недоступен"; } } else { - qDebug() << "DocumentEditWidget::onGenerateTable: Процессор не является PerechenProcessor, SpecificationPCBProcessor, SpecificationProcessor или VedomostProcessor"; + // Проверяем, является ли процессор SimpleListProcessor + SimpleListProcessor *simpleListProcessor = qobject_cast(m_processor); + if (simpleListProcessor) { + if (m_controller && m_controller->simpleListTableController()) { + qDebug() << "DocumentEditWidget::onGenerateTable: Генерируем таблицу через SimpleListTableController из SpecificationPCB"; + + // Генерируем таблицу бланка заказа на основе SpecificationPCB + SpecificationPCBTableModel *specPCBModel = m_controller->specificationPCBTableModel(); + if (specPCBModel && specPCBModel->rowCount() > 0) { + // Если SpecificationPCB уже сгенерирована, используем её + m_controller->simpleListTableController()->generateTableFromSpecificationPCB(specPCBModel); + } else { + // Если SpecificationPCB не сгенерирована, сначала генерируем её + qDebug() << "DocumentEditWidget::onGenerateTable: SpecificationPCB не сгенерирована, генерируем её"; + if (m_controller->specificationPCBController() && m_componentModel) { + m_controller->specificationPCBController()->setComponents(m_componentModel->getComponents()); + m_controller->specificationPCBController()->generateTableFromComponents(); + + // Теперь используем сгенерированную SpecificationPCB + specPCBModel = m_controller->specificationPCBTableModel(); + if (specPCBModel) { + m_controller->simpleListTableController()->generateTableFromSpecificationPCB(specPCBModel); + } + } + } + + // Устанавливаем модель из контроллера + if (m_controller->simpleListTableModel()) { + m_tableView->setModel(m_controller->simpleListTableModel()); + applyColumnStretching(); + setupSelectionConnection(); + qDebug() << "DocumentEditWidget::onGenerateTable: Модель SimpleListTableModel установлена"; + } + } else { + qDebug() << "DocumentEditWidget::onGenerateTable: SimpleListTableController недоступен"; + } + } else { + qDebug() << "DocumentEditWidget::onGenerateTable: Процессор не является PerechenProcessor, SpecificationPCBProcessor, SpecificationProcessor, VedomostProcessor или SimpleListProcessor"; + } } } } @@ -970,7 +1119,20 @@ void DocumentEditWidget::onRecalculateTable() return; } - qDebug() << "DocumentEditWidget::onRecalculateTable: Процессор не является PerechenProcessor, SpecificationPCBProcessor, SpecificationProcessor или VedomostProcessor"; + // Проверяем, является ли процессор SimpleListProcessor + SimpleListProcessor *simpleListProcessor = qobject_cast(m_processor); + if (simpleListProcessor) { + if (m_controller && m_controller->simpleListTableController()) { + qDebug() << "DocumentEditWidget::onRecalculateTable: Пересчитываем таблицу SimpleListTableModel"; + m_controller->simpleListTableController()->updateTableFromComponents(); + qDebug() << "DocumentEditWidget::onRecalculateTable: Пересчет SimpleListTableModel завершен"; + } else { + qDebug() << "DocumentEditWidget::onRecalculateTable: SimpleListTableController недоступен"; + } + return; + } + + qDebug() << "DocumentEditWidget::onRecalculateTable: Процессор не является PerechenProcessor, SpecificationPCBProcessor, SpecificationProcessor, VedomostProcessor или SimpleListProcessor"; } void DocumentEditWidget::setData(ComponentTableModel *componentModel, ProjectParamTableModel *projectParamModel) diff --git a/view/documentprocessor.h b/view/documentprocessor.h index 8d09518..6fdd0c6 100644 --- a/view/documentprocessor.h +++ b/view/documentprocessor.h @@ -21,6 +21,8 @@ #include "../controller/specificationpcbcontroller.h" #include "../controller/specificationcontroller.h" #include "../controller/vedomostcontroller.h" +#include "../model/simplelisttablemodel.h" +#include "../controller/simplelisttablecontroller.h" #include "documentsettingsdialog.h" #include "../model/titleinscriptionsmodel.h" @@ -176,6 +178,33 @@ private: VedomostController *m_controller; }; +class SimpleListProcessor : public DocumentProcessor +{ + Q_OBJECT +public: + explicit SimpleListProcessor(QObject *parent = nullptr); + ~SimpleListProcessor(); + + void processComponents(const QList &components) override; + void processProjectParams(const QMap ¶ms) override; + QTableView* createTableView() override; + + // Метод для обновления таблицы по кнопке + void updateTable(); + + // Метод для получения контроллера + SimpleListTableController* getController() const { return m_controller; } + + // Метод для установки контроллера (для интеграции с MainController) + void setController(SimpleListTableController *controller); + + // Метод для установки модели (для интеграции с MainController) + void setModel(SimpleListTableModel *model); + +private: + SimpleListTableController *m_controller; +}; + // Универсальный виджет для работы с документами class DocumentEditWidget : public QWidget { diff --git a/view/documentsettingsdialog.cpp b/view/documentsettingsdialog.cpp index 7cb2697..31b71fd 100644 --- a/view/documentsettingsdialog.cpp +++ b/view/documentsettingsdialog.cpp @@ -4,6 +4,7 @@ #include "../controller/specificationpcbcontroller.h" #include "../controller/specificationcontroller.h" #include "../controller/vedomostcontroller.h" +#include "../controller/simplelisttablecontroller.h" #include "../model/componenttablemodel.h" #include #include @@ -83,6 +84,9 @@ DocumentSettingsDialog::DocumentSettingsDialog(DocumentType docType, MainControl , m_titleInscriptionsModel(nullptr) , m_primaryApplicationComboBox(nullptr) , m_primaryApplicationFieldNumber(0) + , m_simpleListNameFieldComboBox(nullptr) + , m_simpleListTechReserveSpinBox(nullptr) + , m_simpleListBoardsCountSpinBox(nullptr) { qDebug() << "DocumentSettingsDialog: Конструктор для типа документа:" << static_cast(docType); @@ -127,6 +131,11 @@ void DocumentSettingsDialog::setUp() // Загружаем настройки "Первичное применение" для всех типов документов loadPrimaryApplicationSettings(); + // Загружаем настройки SimpleList + if (m_documentType == DocumentType::SimpleList) { + loadSimpleListSettings(); + } + qDebug() << "DocumentSettingsDialog::setUp: Настройка диалога завершена"; } @@ -183,6 +192,11 @@ void DocumentSettingsDialog::createTableSettingsTab() createWhereUsedField(); } + // Создаем поля настроек для SimpleList + if (m_documentType == DocumentType::SimpleList) { + createSimpleListSettingsFields(); + } + // Создаем поля для размера шрифта и поджима createFontSettingsFields(); @@ -991,6 +1005,10 @@ void DocumentSettingsDialog::loadTableSettings() qDebug() << "DocumentSettingsDialog::loadTableSettings: VedomostController недоступен"; } break; + case DocumentType::SimpleList: + // Для SimpleList настройки колонок не используются + qDebug() << "DocumentSettingsDialog::loadTableSettings: SimpleList - настройки колонок не требуются"; + break; } } else { qDebug() << "DocumentSettingsDialog::loadTableSettings: Контроллер недоступен"; @@ -1100,6 +1118,9 @@ void DocumentSettingsDialog::loadFontSettings() fontStretch = m_controller->vedomostController()->getFontStretch(); } break; + case DocumentType::SimpleList: + // Для SimpleList настройки шрифта не используются + break; } m_fontSizeSpinBox->setValue(fontSize); @@ -1146,6 +1167,9 @@ void DocumentSettingsDialog::saveFontSettings() m_controller->vedomostController()->setFontStretch(fontStretch); } break; + case DocumentType::SimpleList: + // Для SimpleList настройки шрифта не используются + break; } // Сохраняем в БД @@ -1235,6 +1259,10 @@ void DocumentSettingsDialog::saveTableSettings() qDebug() << "DocumentSettingsDialog::saveTableSettings: VedomostController недоступен"; } break; + case DocumentType::SimpleList: + // Для SimpleList настройки колонок не используются + qDebug() << "DocumentSettingsDialog::saveTableSettings: SimpleList - настройки колонок не требуются"; + break; } } else { qDebug() << "DocumentSettingsDialog::saveTableSettings: Контроллер недоступен"; @@ -1371,6 +1399,11 @@ void DocumentSettingsDialog::onAccept() // Сохраняем настройки "Первичное применение" для всех типов документов savePrimaryApplicationSettings(); + // Сохраняем настройки SimpleList + if (m_documentType == DocumentType::SimpleList) { + saveSimpleListSettings(); + } + accept(); } @@ -1896,3 +1929,141 @@ void DocumentSettingsDialog::setDecimalNumberValues(const QMap loadDecimalNumberSettings(); } } + +void DocumentSettingsDialog::createSimpleListSettingsFields() +{ + qDebug() << "DocumentSettingsDialog::createSimpleListSettingsFields: Создаем поля настроек SimpleList"; + + // Создаем группу для настроек SimpleList + QGroupBox *simpleListGroup = new QGroupBox("Настройки списка", m_tableScrollContent); + QGridLayout *simpleListLayout = new QGridLayout(simpleListGroup); + + // Поле "Наименование" + QLabel *nameFieldLabel = new QLabel("Поле для наименования:", simpleListGroup); + simpleListLayout->addWidget(nameFieldLabel, 0, 0); + + m_simpleListNameFieldComboBox = new QComboBox(simpleListGroup); + m_simpleListNameFieldComboBox->addItem("-- Не выбрано --", ""); + + // Добавляем доступные свойства из контроллера + if (m_controller && m_controller->simpleListTableController()) { + QStringList availableProperties = m_controller->simpleListTableController()->getAvailableProperties(); + for (const QString &property : availableProperties) { + m_simpleListNameFieldComboBox->addItem(property, property); + } + } + + simpleListLayout->addWidget(m_simpleListNameFieldComboBox, 0, 1); + + // Поле "Технический запас" + QLabel *techReserveLabel = new QLabel("Технический запас (%):", simpleListGroup); + simpleListLayout->addWidget(techReserveLabel, 1, 0); + + m_simpleListTechReserveSpinBox = new QSpinBox(simpleListGroup); + m_simpleListTechReserveSpinBox->setRange(0, 100); + m_simpleListTechReserveSpinBox->setValue(10); + m_simpleListTechReserveSpinBox->setSuffix("%"); + simpleListLayout->addWidget(m_simpleListTechReserveSpinBox, 1, 1); + + // Поле "Кол-во плат" + QLabel *boardsCountLabel = new QLabel("Кол-во плат:", simpleListGroup); + simpleListLayout->addWidget(boardsCountLabel, 2, 0); + m_simpleListBoardsCountSpinBox = new QSpinBox(simpleListGroup); + m_simpleListBoardsCountSpinBox->setRange(1, 9999); + m_simpleListBoardsCountSpinBox->setValue(1); + simpleListLayout->addWidget(m_simpleListBoardsCountSpinBox, 2, 1); + + // Добавляем группу в основную компоновку таблицы + QGridLayout *mainGridLayout = qobject_cast(m_tableScrollContent->layout()); + if (mainGridLayout) { + int row = mainGridLayout->rowCount(); + mainGridLayout->addWidget(simpleListGroup, row, 0, 1, 2); + } else { + // Если основная компоновка не является QGridLayout, создаем новую + QVBoxLayout *mainLayout = new QVBoxLayout(m_tableScrollContent); + mainLayout->addWidget(simpleListGroup); + } + + qDebug() << "DocumentSettingsDialog::createSimpleListSettingsFields: Поля настроек SimpleList созданы"; +} + +void DocumentSettingsDialog::loadSimpleListSettings() +{ + qDebug() << "DocumentSettingsDialog::loadSimpleListSettings: Загружаем настройки SimpleList"; + + if (!m_controller || !m_controller->simpleListTableController()) { + qDebug() << "DocumentSettingsDialog::loadSimpleListSettings: Контроллер недоступен"; + return; + } + + SimpleListTableController *controller = m_controller->simpleListTableController(); + + // Загружаем поле наименования + QString nameField = controller->getNameField(); + if (m_simpleListNameFieldComboBox) { + int index = m_simpleListNameFieldComboBox->findData(nameField); + if (index >= 0) { + m_simpleListNameFieldComboBox->setCurrentIndex(index); + } else { + // Если значение не найдено, устанавливаем по умолчанию "Name" + index = m_simpleListNameFieldComboBox->findData("Name"); + if (index >= 0) { + m_simpleListNameFieldComboBox->setCurrentIndex(index); + } + } + } + + // Загружаем процент тех запаса + int techReservePercent = controller->getTechReservePercent(); + if (m_simpleListTechReserveSpinBox) { + m_simpleListTechReserveSpinBox->setValue(techReservePercent); + } + + // Загружаем кол-во плат + if (m_simpleListBoardsCountSpinBox) { + m_simpleListBoardsCountSpinBox->setValue(controller->getBoardsCount()); + } + + qDebug() << "DocumentSettingsDialog::loadSimpleListSettings: Настройки загружены - поле:" << nameField << "тех.запас:" << techReservePercent << "кол-во плат:" << controller->getBoardsCount(); +} + +void DocumentSettingsDialog::saveSimpleListSettings() +{ + qDebug() << "DocumentSettingsDialog::saveSimpleListSettings: Сохраняем настройки SimpleList"; + + if (!m_controller || !m_controller->simpleListTableController()) { + qDebug() << "DocumentSettingsDialog::saveSimpleListSettings: Контроллер недоступен"; + return; + } + + SimpleListTableController *controller = m_controller->simpleListTableController(); + + // Сохраняем поле наименования + if (m_simpleListNameFieldComboBox) { + QString nameField = m_simpleListNameFieldComboBox->currentData().toString(); + if (nameField.isEmpty()) { + nameField = m_simpleListNameFieldComboBox->currentText(); + if (nameField == "-- Не выбрано --") { + nameField = "Name"; // Значение по умолчанию + } + } + controller->setNameField(nameField); + qDebug() << "DocumentSettingsDialog::saveSimpleListSettings: Сохранено поле наименования:" << nameField; + } + + // Сохраняем процент тех запаса + if (m_simpleListTechReserveSpinBox) { + int techReservePercent = m_simpleListTechReserveSpinBox->value(); + controller->setTechReservePercent(techReservePercent); + qDebug() << "DocumentSettingsDialog::saveSimpleListSettings: Сохранен процент тех запаса:" << techReservePercent; + } + + // Сохраняем кол-во плат + if (m_simpleListBoardsCountSpinBox) { + int boardsCount = m_simpleListBoardsCountSpinBox->value(); + controller->setBoardsCount(boardsCount); + qDebug() << "DocumentSettingsDialog::saveSimpleListSettings: Сохранено кол-во плат:" << boardsCount; + } + + qDebug() << "DocumentSettingsDialog::saveSimpleListSettings: Настройки SimpleList сохранены"; +} diff --git a/view/documentsettingsdialog.h b/view/documentsettingsdialog.h index 8ed353c..c7c7fb8 100644 --- a/view/documentsettingsdialog.h +++ b/view/documentsettingsdialog.h @@ -30,7 +30,8 @@ enum class DocumentType { Perechen, // Перечень элементов SpecificationPCB, // Спецификация ПП Specification, // Спецификация материалов - Vedomost // Ведомость покупных изделий + Vedomost, // Ведомость покупных изделий + SimpleList // Список элементов для экспорта }; class DocumentSettingsDialog : public QDialog @@ -160,6 +161,16 @@ private: void createFontSettingsFields(); void loadFontSettings(); void saveFontSettings(); + + // Методы для работы с настройками SimpleList + void createSimpleListSettingsFields(); + void loadSimpleListSettings(); + void saveSimpleListSettings(); + + // Виджеты для настроек SimpleList + QComboBox *m_simpleListNameFieldComboBox; + QSpinBox *m_simpleListTechReserveSpinBox; + QSpinBox *m_simpleListBoardsCountSpinBox; }; #endif // DOCUMENTSETTINGSDIALOG_H diff --git a/view/editpdfwidget.cpp b/view/editpdfwidget.cpp index e68b767..d3e2cc3 100644 --- a/view/editpdfwidget.cpp +++ b/view/editpdfwidget.cpp @@ -36,6 +36,7 @@ void EditPDFWidget::setWidgets(){ m_specificationProcessor = new SpecificationProcessor(this); m_specificationPCBProcessor = new SpecificationPCBProcessor(this); m_vedomostProcessor = new VedomostProcessor(this); + m_simpleListProcessor = new SimpleListProcessor(this); // Устанавливаем PerechenTableController из MainController в PerechenProcessor if (m_controller && m_perechenProcessor) { @@ -106,6 +107,21 @@ void EditPDFWidget::setWidgets(){ } } + // Устанавливаем SimpleListTableController из MainController в SimpleListProcessor + if (m_controller && m_simpleListProcessor) { + SimpleListTableController *simpleListController = m_controller->simpleListTableController(); + if (simpleListController) { + qDebug() << "EditPDFWidget::setWidgets: Используем SimpleListTableController из MainController"; + m_simpleListProcessor->setController(simpleListController); + + SimpleListTableModel *simpleListModel = m_controller->simpleListTableModel(); + if (simpleListModel) { + qDebug() << "EditPDFWidget::setWidgets: Устанавливаем SimpleListTableModel из MainController в SimpleListProcessor"; + m_simpleListProcessor->setModel(simpleListModel); + } + } + } + // Создаем виджеты документов с соответствующими процессорами if (m_controller) { // Используем новый конструктор с контроллером для сохранения настроек @@ -113,18 +129,21 @@ void EditPDFWidget::setWidgets(){ m_specificationEditWidget = new DocumentEditWidget(m_specificationProcessor, m_controller, this); m_specificationPCBEditWidget = new DocumentEditWidget(m_specificationPCBProcessor, m_controller, this); m_vedomostEditWidget = new DocumentEditWidget(m_vedomostProcessor, m_controller, this); + m_simpleListEditWidget = new DocumentEditWidget(m_simpleListProcessor, m_controller, this); } else { // Используем старый конструктор для обратной совместимости m_perechenEditWidget = new DocumentEditWidget(m_perechenProcessor, this); m_specificationEditWidget = new DocumentEditWidget(m_specificationProcessor, this); m_specificationPCBEditWidget = new DocumentEditWidget(m_specificationPCBProcessor, this); m_vedomostEditWidget = new DocumentEditWidget(m_vedomostProcessor, this); + m_simpleListEditWidget = new DocumentEditWidget(m_simpleListProcessor, this); } m_tabWidget->addTab(m_specificationEditWidget, "Спецификация"); m_tabWidget->addTab(m_specificationPCBEditWidget, "Спецификация ПП"); m_tabWidget->addTab(m_vedomostEditWidget, "Ведомость"); m_tabWidget->addTab(m_perechenEditWidget, "Перечень"); + m_tabWidget->addTab(m_simpleListEditWidget, "Бланк заказа"); } void EditPDFWidget::setUpLayout(){ @@ -172,6 +191,7 @@ void EditPDFWidget::setData(ComponentTableModel *componentModel, ProjectParamTab m_specificationEditWidget->setData(componentModel, projectParamModel); m_specificationPCBEditWidget->setData(componentModel, projectParamModel); m_vedomostEditWidget->setData(componentModel, projectParamModel); + m_simpleListEditWidget->setData(componentModel, projectParamModel); } void EditPDFWidget::updateAllViews() diff --git a/view/editpdfwidget.h b/view/editpdfwidget.h index 478c707..76f1408 100644 --- a/view/editpdfwidget.h +++ b/view/editpdfwidget.h @@ -45,12 +45,14 @@ private: SpecificationProcessor* m_specificationProcessor; SpecificationPCBProcessor* m_specificationPCBProcessor; VedomostProcessor* m_vedomostProcessor; + SimpleListProcessor* m_simpleListProcessor; // Виджеты документов DocumentEditWidget* m_perechenEditWidget; DocumentEditWidget* m_specificationEditWidget; DocumentEditWidget* m_specificationPCBEditWidget; DocumentEditWidget* m_vedomostEditWidget; + DocumentEditWidget* m_simpleListEditWidget; // Модели данных ComponentTableModel* m_componentModel; diff --git a/view/mainwindow.cpp b/view/mainwindow.cpp index 290a6c9..3af9016 100644 --- a/view/mainwindow.cpp +++ b/view/mainwindow.cpp @@ -4,6 +4,7 @@ #include "../model/specificationpcbtablemodel.h" #include "../model/specificationtablemodel.h" #include "../model/vedomosttablemodel.h" +#include "../model/simplelisttablemodel.h" #include "../model/titleinscriptionsmodel.h" #include "../model/projectparamtablemodel.h" #include "../controller/altiumparser.h" @@ -129,6 +130,7 @@ void MainWindow::setUpLayout(){ csvMenu->addAction("&Перечень элементов (CSV)", this, &MainWindow::onExportPerechenToCsv); csvMenu->addAction("&Ведомость покупных изделий (CSV)", this, &MainWindow::onExportPurchaseListToCsv); csvMenu->addAction("Спецификация &PCB (CSV)", this, &MainWindow::onExportSpecificationPcbToCsv); + csvMenu->addAction("&Бланк заказа (CSV)", this, &MainWindow::onExportSimpleListToCsv); m_viewMenu = m_menuBar->addMenu(tr("&Вид")); QAction* dataPanelAction = m_viewMenu->addAction(tr("&Панель данных"), this, &MainWindow::onViewDataPanel); dataPanelAction->setCheckable(true); @@ -173,6 +175,7 @@ void MainWindow::setUpLayout(){ m_toolBar2->addAction(tr("Перечень (CSV)"), this, &MainWindow::onExportPerechenToCsv); m_toolBar2->addAction(tr("Ведомость (CSV)"), this, &MainWindow::onExportPurchaseListToCsv); m_toolBar2->addAction(tr("Спецификация PCB (CSV)"), this, &MainWindow::onExportSpecificationPcbToCsv); + m_toolBar2->addAction(tr("Бланк заказа (CSV)"), this, &MainWindow::onExportSimpleListToCsv); m_toolBar2->addSeparator(); QAction* undoToolAction = m_toolBar2->addAction(tr("&Отменить"), this, &MainWindow::onUndo); QAction* redoToolAction = m_toolBar2->addAction(tr("&Повторить"), this, &MainWindow::onRedo); @@ -421,6 +424,20 @@ void MainWindow::openProjectFile(const QString &fileName) { qDebug() << "MainWindow::onFileOpen: Данные таблицы ведомости покупных изделий не найдены в БД"; } + // Загружаем бланк заказа напрямую в модель MainController + QByteArray simpleListTableData = m_controller->loadSimpleListTableFromDatabase(); + if (!simpleListTableData.isEmpty()) { + SimpleListTableModel *simpleListModel = m_controller->simpleListTableModel(); + if (simpleListModel) { + simpleListModel->loadFromDatabase(simpleListTableData); + qDebug() << "MainWindow::onFileOpen: Таблица бланка заказа загружена из БД в MainController"; + } else { + qDebug() << "MainWindow::onFileOpen: SimpleListTableModel недоступен в MainController"; + } + } else { + qDebug() << "MainWindow::onFileOpen: Данные таблицы бланка заказа не найдены в БД"; + } + // Загружаем надписи титульного листа в MainController QMap inscriptions; if (m_controller->loadTitleInscriptionsFromDatabase(inscriptions)) { @@ -548,6 +565,20 @@ bool MainWindow::onFileSave(){ qDebug() << "MainWindow::onFileSave: VedomostTableModel недоступен в MainController"; } + // Сохраняем бланк заказа из модели MainController + SimpleListTableModel *simpleListModel = m_controller->simpleListTableModel(); + if (simpleListModel) { + QByteArray tableData = simpleListModel->saveToDatabase(); + if (!tableData.isEmpty()) { + success &= m_controller->saveSimpleListTableToDatabase(tableData); + qDebug() << "MainWindow::onFileSave: Таблица бланка заказа сохранена из MainController"; + } else { + qDebug() << "MainWindow::onFileSave: Данные таблицы бланка заказа пусты"; + } + } else { + qDebug() << "MainWindow::onFileSave: SimpleListTableModel недоступен в MainController"; + } + if (success) { m_statusBar->showMessage(tr("Проект сохранен: %1").arg(m_currentProjectPath)); return true; @@ -632,21 +663,35 @@ void MainWindow::onFileSaveAs(){ qDebug() << "MainWindow::onFileSaveAs: SpecificationTableModel недоступен в MainController"; } - // Сохраняем ведомость покупных изделий из модели MainController - VedomostTableModel *vedomostModel = m_controller->vedomostTableModel(); - if (vedomostModel) { - QByteArray tableData = vedomostModel->saveToDatabase(); - if (!tableData.isEmpty()) { - success &= m_controller->saveVedomostTableToDatabase(tableData); - qDebug() << "MainWindow::onFileSaveAs: Таблица ведомости покупных изделий сохранена из MainController"; + // Сохраняем ведомость покупных изделий из модели MainController + VedomostTableModel *vedomostModel = m_controller->vedomostTableModel(); + if (vedomostModel) { + QByteArray tableData = vedomostModel->saveToDatabase(); + if (!tableData.isEmpty()) { + success &= m_controller->saveVedomostTableToDatabase(tableData); + qDebug() << "MainWindow::onFileSaveAs: Таблица ведомости покупных изделий сохранена из MainController"; + } else { + qDebug() << "MainWindow::onFileSaveAs: Данные таблицы ведомости покупных изделий пусты"; + } } else { - qDebug() << "MainWindow::onFileSaveAs: Данные таблицы ведомости покупных изделий пусты"; + qDebug() << "MainWindow::onFileSaveAs: VedomostTableModel недоступен в MainController"; } - } else { - qDebug() << "MainWindow::onFileSaveAs: VedomostTableModel недоступен в MainController"; - } - - if (success) { + + // Сохраняем бланк заказа из модели MainController + SimpleListTableModel *simpleListModel = m_controller->simpleListTableModel(); + if (simpleListModel) { + QByteArray tableData = simpleListModel->saveToDatabase(); + if (!tableData.isEmpty()) { + success &= m_controller->saveSimpleListTableToDatabase(tableData); + qDebug() << "MainWindow::onFileSaveAs: Таблица бланка заказа сохранена из MainController"; + } else { + qDebug() << "MainWindow::onFileSaveAs: Данные таблицы бланка заказа пусты"; + } + } else { + qDebug() << "MainWindow::onFileSaveAs: SimpleListTableModel недоступен в MainController"; + } + + if (success) { m_currentProjectPath = fileName; updateWindowTitle(); updateStatusBar(); @@ -1150,6 +1195,50 @@ void MainWindow::onExportPurchaseListToCsv() } } +void MainWindow::onExportSimpleListToCsv() +{ + qDebug() << "MainWindow::onExportSimpleListToCsv: Экспорт бланка заказа в CSV"; + + // Проверяем, открыт ли проект + if (m_currentProjectPath.isEmpty()) { + QMessageBox::warning(this, tr("Предупреждение"), + tr("Сначала создайте или откройте проект")); + return; + } + + // Проверяем, есть ли данные в модели бланка заказа + SimpleListTableModel *simpleListModel = m_controller->simpleListTableModel(); + if (!simpleListModel || simpleListModel->rowCount() == 0) { + QMessageBox::warning(this, tr("Предупреждение"), + tr("Бланк заказа пуст. Сначала сгенерируйте таблицу.")); + return; + } + + // Открываем диалог сохранения файла + QString defaultPath = QCoreApplication::applicationDirPath() + "/БланкЗаказа.csv"; + QString fileName = QFileDialog::getSaveFileName( + this, + tr("Экспорт бланка заказа в CSV"), + defaultPath, + tr("CSV файлы (*.csv);;Все файлы (*)") + ); + + if (!fileName.isEmpty()) { + m_statusBar->showMessage(tr("Экспорт бланка заказа в CSV: %1").arg(fileName)); + + // Экспортируем в CSV через MainController + if (m_controller->exportSimpleListToCsv(fileName)) { + QMessageBox::information(this, tr("Успех"), + tr("Бланк заказа успешно экспортирован в CSV:\n%1").arg(fileName)); + m_statusBar->showMessage(tr("Экспорт бланка заказа в CSV завершен")); + } else { + QMessageBox::critical(this, tr("Ошибка"), + tr("Не удалось экспортировать бланк заказа в CSV:\n%1").arg(m_controller->lastError())); + m_statusBar->showMessage(tr("Ошибка экспорта бланка заказа в CSV")); + } + } +} + void MainWindow::onUndo(){} void MainWindow::onRedo(){} diff --git a/view/mainwindow.h b/view/mainwindow.h index 81f8666..d1faea3 100644 --- a/view/mainwindow.h +++ b/view/mainwindow.h @@ -93,6 +93,7 @@ private slots: void onExportPerechenToCsv(); void onExportPurchaseListToCsv(); void onExportSpecificationPcbToCsv(); + void onExportSimpleListToCsv(); void onUndo(); void onRedo(); diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..5fcf259 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,22 @@ +# HTTP +HTTP_PORT=8080 + +# PostgreSQL +POSTGRES_USER=gost +POSTGRES_PASSWORD=gost +POSTGRES_DB=gost + +# API auth (send as Authorization: Bearer or X-API-Token) +API_TOKEN=changeme + +# OpenRouter (traffic goes through SOCKS5 if OPENROUTER_PROXY is set) +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +OPENROUTER_API_KEY= +OPENROUTER_MODEL=deepseek/deepseek-chat +# examples: +# OPENROUTER_PROXY=socks5://127.0.0.1:1080 +# OPENROUTER_PROXY=socks5://user:pass@host:1080 +OPENROUTER_PROXY= +LLM_MAX_CONTEXT_ROWS=80 + +CORS_ORIGINS=* diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..ecd4eb3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,7 @@ +data/ +**/__pycache__/ +**/.venv/ +**/node_modules/ +**/dist/ +.env +*.pyc diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..623805c --- /dev/null +++ b/web/README.md @@ -0,0 +1,84 @@ +# GostGenerator Web + +Веб-сервис для генерации ГОСТ-документов из архива Altium-проекта: парсинг в БД, редактирование таблиц и рамки, экспорт PDF/Excel, помощь LLM через OpenRouter (прокси). + +## Состав + +- `backend/` — FastAPI + PostgreSQL + парсер Altium + генераторы таблиц +- `frontend/` — React (Vite) SPA +- `docker-compose.yml` — api + db + web (nginx) + +## Быстрый старт на сервере + +```bash +cd web +cp .env.example .env +# отредактируйте .env: API_TOKEN, OPENROUTER_*, POSTGRES_PASSWORD +docker compose up -d --build +``` + +Откройте `http://:8080`. + +В шапке укажите **API token** (значение `API_TOKEN` из `.env`). + +### Типовой сценарий + +1. Создать проект +2. Загрузить ZIP с Altium-проектом (внутри должен быть `.PrjPcb` + `.SchDoc` / `.PcbDoc`) +3. Заполнить децимальный номер / название платы и поля рамки +4. Выбрать документ → **Сгенерировать** → править ячейки → **Сохранить** +5. Скачать **Excel** или **PDF** +6. При необходимости: чат LLM → просмотреть proposed edits → **Применить** +7. Обновление базы элементов: **Обновить ZIP** (таблицы по умолчанию сохраняются) + +## Переменные окружения + +| Переменная | Описание | +|---|---| +| `API_TOKEN` | Токен для API / UI | +| `OPENROUTER_BASE_URL` | URL OpenRouter (`…/api/v1`) | +| `OPENROUTER_API_KEY` | Ключ | +| `OPENROUTER_MODEL` | Например `deepseek/deepseek-chat` | +| `OPENROUTER_PROXY` | SOCKS5-прокси, напр. `socks5://user:pass@host:1080` | +| `HTTP_PORT` | Порт nginx (по умолчанию 8080) | +| `DATA_DIR` | В контейнере `/data` (zip и артефакты) | + +## Локальная разработка (без Docker UI) + +PostgreSQL + API: + +```bash +# поднять только БД +docker compose up -d db + +cd backend +python -m venv .venv +# Windows: .venv\Scripts\activate +pip install -r requirements.txt +set DATABASE_URL=postgresql+psycopg://gost:gost@localhost:5432/gost +set API_TOKEN=changeme +set DATA_DIR=../data +uvicorn app.main:app --reload --port 8000 +``` + +Frontend: + +```bash +cd frontend +npm install +npm run dev +``` + +Vite проксирует `/api` на `localhost:8000`. + +## API + +Документация: `http://:8080/docs` (через nginx) или `:8000/docs` напрямую к api. + +Основные эндпоинты: `/api/projects`, `…/upload`, `…/tables/{type}/generate`, `…/inscriptions`, `…/export`, `…/llm/chat`, `…/llm/apply`. + +Заголовок: `Authorization: Bearer ` или `X-API-Token`. + +## LLM и стоимость + +Вызовы только по явной команде в чате. Автозаполнения всех строк нет. В промпт уходит сжатый снимок таблицы (лимит `LLM_MAX_CONTEXT_ROWS`). Правки не применяются автоматически. diff --git a/web/backend/Dockerfile b/web/backend/Dockerfile new file mode 100644 index 0000000..2aceb50 --- /dev/null +++ b/web/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY fonts ./fonts + +ENV PYTHONUNBUFFERED=1 +ENV DATA_DIR=/data +ENV FONT_PATH=/app/fonts/GOST_A.TTF + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/web/backend/app/__init__.py b/web/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/backend/app/api/__init__.py b/web/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/backend/app/api/routes.py b/web/backend/app/api/routes.py new file mode 100644 index 0000000..509ee80 --- /dev/null +++ b/web/backend/app/api/routes.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.responses import Response +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.core.auth import require_token +from app.core.database import get_db +from app.models import Component, LlmMessage, Project, ProjectParam +from app.schemas import ( + ExportRequest, + GenerateTableRequest, + InscriptionsUpdate, + LlmApplyRequest, + LlmChatRequest, + LlmChatResponse, + LlmEdit, + ProjectCreate, + ProjectOut, + ProjectUpdate, + TableRowsPatch, + TableRowsResponse, +) +from app.services.excel_export import export_xlsx +from app.services.llm import chat_edit_table +from app.services.pdf_export import export_pdf +from app.services.project_service import get_project_full, ingest_zip, project_dir +from app.services.table_service import ( + TABLE_TYPES, + apply_llm_edits, + generate_table, + get_inscriptions, + get_rows, + patch_rows, + set_inscriptions, +) +from app.services import table_service + +router = APIRouter(prefix="/api") +secured = APIRouter(dependencies=[Depends(require_token)]) + + +def _project_out(db: Session, project: Project) -> ProjectOut: + variants = [v.name for v in project.variants] if project.variants else [] + component_count = db.scalar( + select(func.count()).select_from(Component).where(Component.project_id == project.id) + ) or 0 + layer_count = project.pcb_data.layer_count if project.pcb_data else 0 + return ProjectOut( + id=project.id, + name=project.name, + status=project.status, + current_variant=project.current_variant, + zip_path=project.zip_path, + pcb_doc_name=project.pcb_doc_name, + decimal_number=project.decimal_number, + board_name=project.board_name, + error_message=project.error_message, + created_at=project.created_at, + updated_at=project.updated_at, + variants=variants, + component_count=component_count, + layer_count=layer_count, + ) + + +def _get_project(db: Session, project_id: int) -> Project: + project = get_project_full(db, project_id) + if not project: + raise HTTPException(404, "Project not found") + return project + + +@router.get("/health") +def health(): + return {"status": "ok"} + + +@secured.get("/projects", response_model=list[ProjectOut]) +def list_projects(db: Session = Depends(get_db)): + projects = db.scalars(select(Project).order_by(Project.id.desc())).all() + result = [] + for p in projects: + full = get_project_full(db, p.id) or p + result.append(_project_out(db, full)) + return result + + +@secured.post("/projects", response_model=ProjectOut) +def create_project(body: ProjectCreate, db: Session = Depends(get_db)): + project = Project(name=body.name, status="created") + db.add(project) + db.commit() + db.refresh(project) + project_dir(project.id) + return _project_out(db, project) + + +@secured.get("/projects/{project_id}", response_model=ProjectOut) +def get_project(project_id: int, db: Session = Depends(get_db)): + return _project_out(db, _get_project(db, project_id)) + + +@secured.patch("/projects/{project_id}", response_model=ProjectOut) +def update_project(project_id: int, body: ProjectUpdate, db: Session = Depends(get_db)): + project = _get_project(db, project_id) + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(project, field, value) + db.commit() + return _project_out(db, _get_project(db, project_id)) + + +@secured.delete("/projects/{project_id}") +def delete_project(project_id: int, db: Session = Depends(get_db)): + project = db.get(Project, project_id) + if not project: + raise HTTPException(404, "Project not found") + db.delete(project) + db.commit() + return {"ok": True} + + +@secured.post("/projects/{project_id}/upload", response_model=ProjectOut) +async def upload_zip( + project_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db), +): + project = _get_project(db, project_id) + data = await file.read() + try: + ingest_zip(db, project, data, filename=file.filename or "project.zip", keep_tables=False) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return _project_out(db, _get_project(db, project_id)) + + +@secured.put("/projects/{project_id}/upload", response_model=ProjectOut) +async def reupload_zip( + project_id: int, + file: UploadFile = File(...), + keep_tables: bool = True, + db: Session = Depends(get_db), +): + project = _get_project(db, project_id) + data = await file.read() + try: + ingest_zip( + db, + project, + data, + filename=file.filename or "project.zip", + keep_tables=keep_tables, + ) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return _project_out(db, _get_project(db, project_id)) + + +@secured.get("/projects/{project_id}/components") +def list_components(project_id: int, db: Session = Depends(get_db)): + project = _get_project(db, project_id) + views = table_service.load_components_for_variant(db, project) + return [ + { + "designator": v.designator, + "properties": v.properties, + "is_fitted": v.is_fitted, + } + for v in views + ] + + +@secured.get("/projects/{project_id}/params") +def list_params(project_id: int, db: Session = Depends(get_db)): + _get_project(db, project_id) + rows = db.scalars(select(ProjectParam).where(ProjectParam.project_id == project_id)).all() + return [ + {"id": r.id, "name": r.name, "value": r.value, "variant_name": r.variant_name} + for r in rows + ] + + +@secured.get("/projects/{project_id}/pcb") +def get_pcb(project_id: int, db: Session = Depends(get_db)): + project = _get_project(db, project_id) + if not project.pcb_data: + return {"layer_count": 0, "materials": []} + return { + "layer_count": project.pcb_data.layer_count, + "materials": [ + { + "name": m.name, + "value": m.value, + "height": m.height, + "diel_type": m.diel_type, + "layer_number": m.layer_number, + } + for m in project.pcb_data.diel_materials + ], + } + + +@secured.get("/projects/{project_id}/inscriptions") +def read_inscriptions(project_id: int, db: Session = Depends(get_db)): + _get_project(db, project_id) + return get_inscriptions(db, project_id) + + +@secured.patch("/projects/{project_id}/inscriptions") +def update_inscriptions(project_id: int, body: InscriptionsUpdate, db: Session = Depends(get_db)): + _get_project(db, project_id) + return set_inscriptions(db, project_id, body.inscriptions) + + +@secured.get("/projects/{project_id}/tables/{table_type}", response_model=TableRowsResponse) +def read_table(project_id: int, table_type: str, db: Session = Depends(get_db)): + _get_project(db, project_id) + if table_type not in TABLE_TYPES: + raise HTTPException(400, f"Unknown table type. Use one of: {TABLE_TYPES}") + return TableRowsResponse(table_type=table_type, rows=get_rows(db, project_id, table_type)) + + +@secured.patch("/projects/{project_id}/tables/{table_type}", response_model=TableRowsResponse) +def update_table( + project_id: int, + table_type: str, + body: TableRowsPatch, + db: Session = Depends(get_db), +): + _get_project(db, project_id) + if table_type not in TABLE_TYPES: + raise HTTPException(400, f"Unknown table type") + rows = patch_rows(db, project_id, table_type, body.rows) + return TableRowsResponse(table_type=table_type, rows=rows) + + +@secured.post("/projects/{project_id}/tables/{table_type}/generate", response_model=TableRowsResponse) +def generate( + project_id: int, + table_type: str, + body: GenerateTableRequest | None = None, + db: Session = Depends(get_db), +): + project = _get_project(db, project_id) + if table_type not in TABLE_TYPES: + raise HTTPException(400, f"Unknown table type") + body = body or GenerateTableRequest() + try: + rows = generate_table( + db, + project, + table_type, + name_field=body.name_field, + tech_reserve_percent=body.tech_reserve_percent, + boards_count=body.boards_count, + ) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return TableRowsResponse(table_type=table_type, rows=rows) + + +@secured.post("/projects/{project_id}/export") +def export_document(project_id: int, body: ExportRequest, db: Session = Depends(get_db)): + project = _get_project(db, project_id) + if body.table_type not in TABLE_TYPES: + raise HTTPException(400, "Unknown table type") + rows = get_rows(db, project_id, body.table_type) + if not rows: + raise HTTPException(400, "Table is empty. Generate it first.") + inscriptions = get_inscriptions(db, project_id) + + if body.format == "xlsx": + data = export_xlsx(body.table_type, rows, title=project.name) + return Response( + content=data, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.xlsx"' + }, + ) + if body.format == "pdf": + try: + data = export_pdf(body.table_type, rows, inscriptions) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return Response( + content=data, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.pdf"' + }, + ) + raise HTTPException(400, "format must be pdf or xlsx") + + +@secured.post("/projects/{project_id}/llm/chat", response_model=LlmChatResponse) +async def llm_chat(project_id: int, body: LlmChatRequest, db: Session = Depends(get_db)): + _get_project(db, project_id) + if body.table_type not in TABLE_TYPES: + raise HTTPException(400, "Unknown table type") + rows = get_rows(db, project_id, body.table_type) + db.add( + LlmMessage( + project_id=project_id, + role="user", + content=body.message, + table_type=body.table_type, + ) + ) + db.commit() + try: + result = await chat_edit_table(body.message, body.table_type, rows) + except Exception as e: + raise HTTPException(502, f"LLM request failed: {e}") from e + + msg = LlmMessage( + project_id=project_id, + role="assistant", + content=result["reply"], + table_type=body.table_type, + proposed_edits=json.dumps(result["edits"], ensure_ascii=False), + ) + db.add(msg) + db.commit() + db.refresh(msg) + return LlmChatResponse( + reply=result["reply"], + edits=[LlmEdit(**e) for e in result["edits"]], + message_id=msg.id, + ) + + +@secured.post("/projects/{project_id}/llm/apply", response_model=TableRowsResponse) +def llm_apply(project_id: int, body: LlmApplyRequest, db: Session = Depends(get_db)): + _get_project(db, project_id) + if body.table_type not in TABLE_TYPES: + raise HTTPException(400, "Unknown table type") + edits = [e.model_dump() for e in body.edits] + rows = apply_llm_edits(db, project_id, body.table_type, edits) + return TableRowsResponse(table_type=body.table_type, rows=rows) + + +@secured.get("/projects/{project_id}/llm/history") +def llm_history(project_id: int, db: Session = Depends(get_db)): + _get_project(db, project_id) + msgs = db.scalars( + select(LlmMessage) + .where(LlmMessage.project_id == project_id) + .order_by(LlmMessage.id.asc()) + ).all() + return [ + { + "id": m.id, + "role": m.role, + "content": m.content, + "table_type": m.table_type, + "proposed_edits": json.loads(m.proposed_edits) if m.proposed_edits else None, + "created_at": m.created_at, + } + for m in msgs + ] + +router.include_router(secured) + diff --git a/web/backend/app/core/__init__.py b/web/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/backend/app/core/auth.py b/web/backend/app/core/auth.py new file mode 100644 index 0000000..8cb2144 --- /dev/null +++ b/web/backend/app/core/auth.py @@ -0,0 +1,30 @@ +from fastapi import Depends, HTTPException, Security, status +from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer + +from app.core.config import get_settings + +_bearer = HTTPBearer(auto_error=False) +_api_key = APIKeyHeader(name="X-API-Token", auto_error=False) + + +def require_token( + bearer: HTTPAuthorizationCredentials | None = Security(_bearer), + api_key: str | None = Security(_api_key), +) -> None: + settings = get_settings() + expected = settings.api_token + if not expected: + return + token = None + if bearer and bearer.credentials: + token = bearer.credentials + elif api_key: + token = api_key + if token != expected: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing API token", + ) + + +AuthDep = Depends(require_token) diff --git a/web/backend/app/core/config.py b/web/backend/app/core/config.py new file mode 100644 index 0000000..91fd249 --- /dev/null +++ b/web/backend/app/core/config.py @@ -0,0 +1,33 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "postgresql+psycopg://gost:gost@localhost:5432/gost" + api_token: str = "changeme" + data_dir: str = "./data" + font_path: str = str(Path(__file__).resolve().parents[2] / "fonts" / "GOST_A.TTF") + + openrouter_base_url: str = "https://openrouter.ai/api/v1" + openrouter_api_key: str = "" + openrouter_model: str = "deepseek/deepseek-chat" + # SOCKS5 proxy for OpenRouter, e.g. socks5://user:pass@host:1080 + openrouter_proxy: str = "" + llm_max_context_rows: int = 80 + + cors_origins: str = "*" + + @property + def cors_origin_list(self) -> list[str]: + if self.cors_origins.strip() == "*": + return ["*"] + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/web/backend/app/core/database.py b/web/backend/app/core/database.py new file mode 100644 index 0000000..c2aadd2 --- /dev/null +++ b/web/backend/app/core/database.py @@ -0,0 +1,23 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.core.config import get_settings + + +class Base(DeclarativeBase): + pass + + +settings = get_settings() +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/web/backend/app/main.py b/web/backend/app/main.py new file mode 100644 index 0000000..12b26c9 --- /dev/null +++ b/web/backend/app/main.py @@ -0,0 +1,35 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import router +from app.core.config import get_settings +from app.core.database import Base, engine +import app.models # noqa: F401 — register models + + +@asynccontextmanager +async def lifespan(_: FastAPI): + settings = get_settings() + Path(settings.data_dir).mkdir(parents=True, exist_ok=True) + Base.metadata.create_all(bind=engine) + yield + + +app = FastAPI(title="GostGenerator Web API", version="1.0.0", lifespan=lifespan) +settings = get_settings() +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.include_router(router) + + +@app.get("/") +def root(): + return {"service": "GostGenerator Web API", "docs": "/docs"} diff --git a/web/backend/app/models/__init__.py b/web/backend/app/models/__init__.py new file mode 100644 index 0000000..e4b5aa8 --- /dev/null +++ b/web/backend/app/models/__init__.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class Project(Base): + __tablename__ = "projects" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[str] = mapped_column(String(64), default="created") + current_variant: Mapped[str] = mapped_column(String(255), default="No Variations") + zip_path: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) + extract_path: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) + pcb_doc_name: Mapped[Optional[str]] = mapped_column(String(512), nullable=True) + decimal_number: Mapped[str] = mapped_column(String(255), default="") + board_name: Mapped[str] = mapped_column(String(255), default="") + error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + variants: Mapped[list[Variant]] = relationship(back_populates="project", cascade="all, delete-orphan") + components: Mapped[list[Component]] = relationship(back_populates="project", cascade="all, delete-orphan") + project_params: Mapped[list[ProjectParam]] = relationship( + back_populates="project", cascade="all, delete-orphan" + ) + pcb_data: Mapped[Optional[PcbData]] = relationship( + back_populates="project", uselist=False, cascade="all, delete-orphan" + ) + settings: Mapped[list[ProjectSetting]] = relationship( + back_populates="project", cascade="all, delete-orphan" + ) + inscriptions: Mapped[list[TitleInscription]] = relationship( + back_populates="project", cascade="all, delete-orphan" + ) + designator_mappings: Mapped[list[DesignatorMapping]] = relationship( + back_populates="project", cascade="all, delete-orphan" + ) + llm_messages: Mapped[list[LlmMessage]] = relationship( + back_populates="project", cascade="all, delete-orphan" + ) + + +class Variant(Base): + __tablename__ = "variants" + __table_args__ = (UniqueConstraint("project_id", "name", name="uq_variant_project_name"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + + project: Mapped[Project] = relationship(back_populates="variants") + + +class Component(Base): + __tablename__ = "components" + __table_args__ = (UniqueConstraint("project_id", "designator", name="uq_component_designator"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + designator: Mapped[str] = mapped_column(String(128), nullable=False) + + project: Mapped[Project] = relationship(back_populates="components") + properties: Mapped[list[ComponentProperty]] = relationship( + back_populates="component", cascade="all, delete-orphan" + ) + variant_links: Mapped[list[ComponentVariant]] = relationship( + back_populates="component", cascade="all, delete-orphan" + ) + variant_properties: Mapped[list[VariantProperty]] = relationship( + back_populates="component", cascade="all, delete-orphan" + ) + + +class ComponentProperty(Base): + __tablename__ = "component_properties" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + component_id: Mapped[int] = mapped_column(ForeignKey("components.id", ondelete="CASCADE"), index=True) + key: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[str] = mapped_column(Text, default="") + + component: Mapped[Component] = relationship(back_populates="properties") + + +class ComponentVariant(Base): + __tablename__ = "component_variants" + __table_args__ = ( + UniqueConstraint("component_id", "variant_id", name="uq_component_variant"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + component_id: Mapped[int] = mapped_column(ForeignKey("components.id", ondelete="CASCADE"), index=True) + variant_id: Mapped[int] = mapped_column(ForeignKey("variants.id", ondelete="CASCADE"), index=True) + is_fitted: Mapped[bool] = mapped_column(Boolean, default=True) + + component: Mapped[Component] = relationship(back_populates="variant_links") + variant: Mapped[Variant] = relationship() + + +class VariantProperty(Base): + __tablename__ = "variant_properties" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + component_id: Mapped[int] = mapped_column(ForeignKey("components.id", ondelete="CASCADE"), index=True) + variant_id: Mapped[int] = mapped_column(ForeignKey("variants.id", ondelete="CASCADE"), index=True) + key: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[str] = mapped_column(Text, default="") + + component: Mapped[Component] = relationship(back_populates="variant_properties") + variant: Mapped[Variant] = relationship() + + +class ProjectParam(Base): + __tablename__ = "project_params" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[str] = mapped_column(Text, default="") + variant_name: Mapped[str] = mapped_column(String(255), default="") + + project: Mapped[Project] = relationship(back_populates="project_params") + + +class ProjectSetting(Base): + __tablename__ = "project_settings" + __table_args__ = (UniqueConstraint("project_id", "key", name="uq_project_setting"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + key: Mapped[str] = mapped_column(String(255), nullable=False) + value: Mapped[str] = mapped_column(Text, default="") + + project: Mapped[Project] = relationship(back_populates="settings") + + +class DesignatorMapping(Base): + __tablename__ = "designator_mappings" + __table_args__ = (UniqueConstraint("project_id", "prefix", name="uq_designator_prefix"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + prefix: Mapped[str] = mapped_column(String(16), nullable=False) + singular_name: Mapped[str] = mapped_column(String(255), default="") + plural_name: Mapped[str] = mapped_column(String(255), default="") + + project: Mapped[Project] = relationship(back_populates="designator_mappings") + + +class TitleInscription(Base): + __tablename__ = "title_inscriptions" + __table_args__ = (UniqueConstraint("project_id", "field_number", name="uq_inscription_field"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + field_number: Mapped[int] = mapped_column(Integer, nullable=False) + field_value: Mapped[str] = mapped_column(Text, default="") + + project: Mapped[Project] = relationship(back_populates="inscriptions") + + +class PcbData(Base): + __tablename__ = "pcb_data" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column( + ForeignKey("projects.id", ondelete="CASCADE"), unique=True, index=True + ) + layer_count: Mapped[int] = mapped_column(Integer, default=0) + + project: Mapped[Project] = relationship(back_populates="pcb_data") + diel_materials: Mapped[list[DielMaterial]] = relationship( + back_populates="pcb_data", cascade="all, delete-orphan" + ) + + +class DielMaterial(Base): + __tablename__ = "diel_materials" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + pcb_data_id: Mapped[int] = mapped_column(ForeignKey("pcb_data.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(255), default="DielMaterial") + value: Mapped[str] = mapped_column(String(255), default="") + height: Mapped[float] = mapped_column(Float, default=0.0) + diel_type: Mapped[int] = mapped_column(Integer, default=1) + layer_number: Mapped[int] = mapped_column(Integer, default=0) + + pcb_data: Mapped[PcbData] = relationship(back_populates="diel_materials") + + +class PerechenRow(Base): + __tablename__ = "perechen_rows" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + row_index: Mapped[int] = mapped_column(Integer, default=0) + page_number: Mapped[int] = mapped_column(Integer, default=1) + position: Mapped[str] = mapped_column(Text, default="") + designation: Mapped[str] = mapped_column(Text, default="") + quantity: Mapped[str] = mapped_column(String(64), default="") + note: Mapped[str] = mapped_column(Text, default="") + is_header: Mapped[bool] = mapped_column(Boolean, default=False) + is_empty: Mapped[bool] = mapped_column(Boolean, default=False) + is_auto_generated: Mapped[bool] = mapped_column(Boolean, default=True) + stretch: Mapped[bool] = mapped_column(Boolean, default=False) + is_underline: Mapped[bool] = mapped_column(Boolean, default=False) + + +class SpecificationPcbRow(Base): + __tablename__ = "specification_pcb_rows" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + row_index: Mapped[int] = mapped_column(Integer, default=0) + page_number: Mapped[int] = mapped_column(Integer, default=1) + format: Mapped[str] = mapped_column(String(64), default="") + zone: Mapped[str] = mapped_column(String(64), default="") + position: Mapped[str] = mapped_column(String(64), default="") + designation: Mapped[str] = mapped_column(Text, default="") + name: Mapped[str] = mapped_column(Text, default="") + quantity: Mapped[str] = mapped_column(String(64), default="") + note: Mapped[str] = mapped_column(Text, default="") + is_header: Mapped[bool] = mapped_column(Boolean, default=False) + is_empty: Mapped[bool] = mapped_column(Boolean, default=False) + is_auto_generated: Mapped[bool] = mapped_column(Boolean, default=True) + stretch: Mapped[bool] = mapped_column(Boolean, default=False) + is_underline: Mapped[bool] = mapped_column(Boolean, default=False) + + +class SpecificationRow(Base): + __tablename__ = "specification_rows" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + row_index: Mapped[int] = mapped_column(Integer, default=0) + page_number: Mapped[int] = mapped_column(Integer, default=1) + format: Mapped[str] = mapped_column(String(64), default="") + zone: Mapped[str] = mapped_column(String(64), default="") + position: Mapped[str] = mapped_column(String(64), default="") + designation: Mapped[str] = mapped_column(Text, default="") + name: Mapped[str] = mapped_column(Text, default="") + quantity: Mapped[str] = mapped_column(String(64), default="") + note: Mapped[str] = mapped_column(Text, default="") + is_header: Mapped[bool] = mapped_column(Boolean, default=False) + is_empty: Mapped[bool] = mapped_column(Boolean, default=False) + is_auto_generated: Mapped[bool] = mapped_column(Boolean, default=True) + stretch: Mapped[bool] = mapped_column(Boolean, default=False) + is_underline: Mapped[bool] = mapped_column(Boolean, default=False) + + +class VedomostRow(Base): + __tablename__ = "vedomost_rows" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + row_index: Mapped[int] = mapped_column(Integer, default=0) + page_number: Mapped[int] = mapped_column(Integer, default=1) + name: Mapped[str] = mapped_column(Text, default="") + product_code: Mapped[str] = mapped_column(Text, default="") + document_code: Mapped[str] = mapped_column(Text, default="") + supplier: Mapped[str] = mapped_column(Text, default="") + where_used: Mapped[str] = mapped_column(Text, default="") + quantity_per_item: Mapped[str] = mapped_column(String(64), default="") + quantity_in_set: Mapped[str] = mapped_column(String(64), default="") + quantity_for_reg: Mapped[str] = mapped_column(String(64), default="") + total_quantity: Mapped[str] = mapped_column(String(64), default="") + note: Mapped[str] = mapped_column(Text, default="") + is_header: Mapped[bool] = mapped_column(Boolean, default=False) + is_empty: Mapped[bool] = mapped_column(Boolean, default=False) + is_auto_generated: Mapped[bool] = mapped_column(Boolean, default=True) + stretch: Mapped[bool] = mapped_column(Boolean, default=False) + is_underline: Mapped[bool] = mapped_column(Boolean, default=False) + + +class SimpleListRow(Base): + __tablename__ = "simple_list_rows" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + row_index: Mapped[int] = mapped_column(Integer, default=0) + designator: Mapped[str] = mapped_column(Text, default="") + name: Mapped[str] = mapped_column(Text, default="") + quantity: Mapped[str] = mapped_column(String(64), default="") + is_auto_generated: Mapped[bool] = mapped_column(Boolean, default=True) + is_empty: Mapped[bool] = mapped_column(Boolean, default=False) + + +class LlmMessage(Base): + __tablename__ = "llm_messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + project_id: Mapped[int] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), index=True) + role: Mapped[str] = mapped_column(String(32), nullable=False) + content: Mapped[str] = mapped_column(Text, default="") + table_type: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + proposed_edits: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + project: Mapped[Project] = relationship(back_populates="llm_messages") diff --git a/web/backend/app/schemas/__init__.py b/web/backend/app/schemas/__init__.py new file mode 100644 index 0000000..2bb574a --- /dev/null +++ b/web/backend/app/schemas/__init__.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class ProjectCreate(BaseModel): + name: str = Field(min_length=1, max_length=255) + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + current_variant: Optional[str] = None + decimal_number: Optional[str] = None + board_name: Optional[str] = None + + +class ProjectOut(BaseModel): + id: int + name: str + status: str + current_variant: str + zip_path: Optional[str] = None + pcb_doc_name: Optional[str] = None + decimal_number: str + board_name: str + error_message: Optional[str] = None + created_at: datetime + updated_at: datetime + variants: list[str] = [] + component_count: int = 0 + layer_count: int = 0 + + model_config = {"from_attributes": True} + + +class ComponentPropertyOut(BaseModel): + key: str + value: str + + +class ComponentOut(BaseModel): + id: int + designator: str + properties: list[ComponentPropertyOut] = [] + is_fitted: bool = True + + model_config = {"from_attributes": True} + + +class ProjectParamOut(BaseModel): + id: int + name: str + value: str + variant_name: str = "" + + model_config = {"from_attributes": True} + + +class DielMaterialOut(BaseModel): + name: str + value: str + height: float + diel_type: int + layer_number: int + + model_config = {"from_attributes": True} + + +class PcbInfoOut(BaseModel): + layer_count: int = 0 + materials: list[DielMaterialOut] = [] + + +class InscriptionOut(BaseModel): + field_number: int + field_value: str + + +class InscriptionsUpdate(BaseModel): + inscriptions: dict[int, str] + + +class TableRowBase(BaseModel): + id: Optional[int] = None + row_index: int = 0 + page_number: int = 1 + is_header: bool = False + is_empty: bool = False + is_auto_generated: bool = True + stretch: bool = False + is_underline: bool = False + + +class PerechenRowOut(TableRowBase): + position: str = "" + designation: str = "" + quantity: str = "" + note: str = "" + + +class SpecPcbRowOut(TableRowBase): + format: str = "" + zone: str = "" + position: str = "" + designation: str = "" + name: str = "" + quantity: str = "" + note: str = "" + + +class SpecRowOut(TableRowBase): + format: str = "" + zone: str = "" + position: str = "" + designation: str = "" + name: str = "" + quantity: str = "" + note: str = "" + + +class VedomostRowOut(TableRowBase): + name: str = "" + product_code: str = "" + document_code: str = "" + supplier: str = "" + where_used: str = "" + quantity_per_item: str = "" + quantity_in_set: str = "" + quantity_for_reg: str = "" + total_quantity: str = "" + note: str = "" + + +class SimpleListRowOut(BaseModel): + id: Optional[int] = None + row_index: int = 0 + designator: str = "" + name: str = "" + quantity: str = "" + is_auto_generated: bool = True + is_empty: bool = False + + +class TableRowsResponse(BaseModel): + table_type: str + rows: list[dict[str, Any]] + + +class TableRowsPatch(BaseModel): + rows: list[dict[str, Any]] + + +class GenerateTableRequest(BaseModel): + name_field: Optional[str] = None + tech_reserve_percent: Optional[float] = None + boards_count: Optional[int] = None + + +class LlmChatRequest(BaseModel): + message: str = Field(min_length=1) + table_type: str + + +class LlmEdit(BaseModel): + op: str + row_id: Optional[int] = None + row_index: Optional[int] = None + fields: dict[str, Any] = {} + + +class LlmChatResponse(BaseModel): + reply: str + edits: list[LlmEdit] = [] + message_id: Optional[int] = None + + +class LlmApplyRequest(BaseModel): + table_type: str + edits: list[LlmEdit] + + +class ExportRequest(BaseModel): + table_type: str + format: str = "pdf" # pdf | xlsx + + +class DesignatorMappingOut(BaseModel): + prefix: str + singular_name: str + plural_name: str diff --git a/web/backend/app/services/__init__.py b/web/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/backend/app/services/altium_parser.py b/web/backend/app/services/altium_parser.py new file mode 100644 index 0000000..8cd2219 --- /dev/null +++ b/web/backend/app/services/altium_parser.py @@ -0,0 +1,359 @@ +"""Altium .PrjPcb / .SchDoc / .PcbDoc parser (ported from desktop AltiumParser).""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class ComponentProperty: + name: str + text: str + + +@dataclass +class DielProperty: + name: str + value: str + height: float + diel_type: int + layer_number: int + + +@dataclass +class ProjectData: + variant_names: list[str] = field(default_factory=lambda: ["No Variations"]) + components_list: list[list[ComponentProperty]] = field(default_factory=list) + components_variant_list: list[list[list[ComponentProperty]]] = field(default_factory=list) + components_prop_variant_list: list[list[ComponentProperty]] = field(default_factory=list) + component_variant_prop_list: list[ComponentProperty] = field(default_factory=list) + dnf_designators_list: list[str] = field(default_factory=list) + dnf_variant_designators_list: list[list[str]] = field(default_factory=list) + fitted_designators_list: list[str] = field(default_factory=list) + fitted_variant_designators_list: list[list[str]] = field(default_factory=list) + prj_params_variant_list: list[list[list[str]]] = field(default_factory=list) + pcb_layer_count: int = 0 + pcb_diel_materials: list[DielProperty] = field(default_factory=list) + pcb_doc_file_name: str = "" + is_waiting_variant_description: bool = False + current_variant_number: int = 0 + + +def _read_cp1251_lines(path: Path) -> list[str]: + raw = path.read_bytes() + text = raw.decode("cp1251", errors="replace") + return [line.rstrip("\r\n") for line in text.splitlines()] + + +def _read_pipe_chunks(path: Path) -> list[str]: + """Read Altium binary-ish docs and extract pipe-separated ASCII chunks per line.""" + raw = path.read_bytes() + text = raw.decode("cp1251", errors="replace") + # Also try latin-1 overlay for pipe records that may have been mangled + return [line for line in text.splitlines() if line.strip()] + + +class AltiumParser: + def __init__(self) -> None: + self.last_error = "" + self.pcb_doc_file_name = "" + + def parse_prjpcb(self, filename: str | Path) -> ProjectData: + path = Path(filename) + if not path.exists(): + self.last_error = f"File not found: {path}" + raise FileNotFoundError(self.last_error) + + data = ProjectData() + data.prj_params_variant_list = [[]] # index 0 = No Variations + + lines = _read_cp1251_lines(path) + prev = "" + prev_prev = "" + for line in lines: + prj = line.strip() + self._parse_project_variant_section(prj, data) + self._parse_component_variations(prj, data) + self._parse_project_parameters(prev_prev, prev, prj, data) + self._parse_schdoc_files(prj, path, data) + self._parse_pcbdoc_files(prj, path, data) + prev_prev, prev = prev, prj + + # finalize last variant buffers if any + if data.components_prop_variant_list or data.dnf_designators_list or data.fitted_designators_list: + self._finalize_current_variant(data) + + self.pcb_doc_file_name = data.pcb_doc_file_name + return data + + def _finalize_current_variant(self, data: ProjectData) -> None: + if data.component_variant_prop_list: + data.components_prop_variant_list.append(list(data.component_variant_prop_list)) + data.component_variant_prop_list.clear() + if data.components_prop_variant_list: + data.components_variant_list.append(list(data.components_prop_variant_list)) + data.components_prop_variant_list.clear() + data.dnf_variant_designators_list.append(list(data.dnf_designators_list)) + data.fitted_variant_designators_list.append(list(data.fitted_designators_list)) + data.dnf_designators_list.clear() + data.fitted_designators_list.clear() + + def _parse_project_variant_section(self, prj: str, data: ProjectData) -> None: + upper = prj.upper() + if len(prj) >= 15 and upper.startswith("[PROJECTVARIANT"): + data.is_waiting_variant_description = True + # Extract number if present + m = re.search(r"(\d+)", prj) + data.current_variant_number = int(m.group(1)) if m else len(data.variant_names) + while len(data.prj_params_variant_list) <= data.current_variant_number: + data.prj_params_variant_list.append([]) + return + if data.is_waiting_variant_description and upper.startswith("DESCRIPTION="): + name = prj[12:].strip() + if name and name not in data.variant_names: + data.variant_names.append(name) + data.is_waiting_variant_description = False + return + if len(prj) >= 19 and upper.startswith("PARAMVARIATIONCOUNT"): + self._finalize_current_variant(data) + + def _parse_component_variations(self, prj: str, data: ProjectData) -> None: + upper = prj.upper() + parts = prj.split("|") + if not parts: + return + values = parts[0].split("=") + if len(values) <= 2: + return + + if "VARIATION" in values[0].upper() and "DESIGNATOR" in values[1].upper(): + designator = values[2] + prop_list = [ComponentProperty("Designator", designator)] + kind = "0" + if len(parts) > 2: + kind_part = parts[2] + if "=" in kind_part: + kind = kind_part.split("=", 1)[1] + prop_list.append(ComponentProperty("Kind", kind)) + if kind == "1": + data.dnf_designators_list.append(designator) + else: + data.fitted_designators_list.append(designator) + # flush previous component props into list + if data.component_variant_prop_list: + # keep designator props as start of new component + pass + data.components_prop_variant_list.append(prop_list) + return + + if "PARAMVARIATION" in values[0].upper() and "PARAMETERNAME" in values[1].upper(): + prop_name = values[2] + prop_text = "" + if len(parts) > 1 and "=" in parts[1]: + prop_text = parts[1].split("=", 1)[1] + data.component_variant_prop_list.append(ComponentProperty(prop_name, prop_text)) + # attach to last variation component if present + if data.components_prop_variant_list: + data.components_prop_variant_list[-1].append(ComponentProperty(prop_name, prop_text)) + + def _parse_project_parameters( + self, prev_prev: str, prev: str, prj: str, data: ProjectData + ) -> None: + if not prev_prev.upper().startswith("[PARAMETER"): + return + if not prev.upper().startswith("NAME="): + return + if not prj.upper().startswith("VALUE="): + return + name = prev[5:] + value = prj[6:] + idx = data.current_variant_number if "_" in prev_prev else 0 + while len(data.prj_params_variant_list) <= idx: + data.prj_params_variant_list.append([]) + data.prj_params_variant_list[idx].append([name, value]) + + def _parse_schdoc_files(self, prj: str, prj_path: Path, data: ProjectData) -> None: + if len(prj) < 19: + return + if prj[:13].upper() != "DOCUMENTPATH=": + return + if prj[-6:].upper() != "SCHDOC": + return + rel = prj[13:] + sch_path = (prj_path.parent / rel).resolve() + if sch_path.exists(): + self._parse_schdoc_file(sch_path, data.components_list) + + def _parse_pcbdoc_files(self, prj: str, prj_path: Path, data: ProjectData) -> None: + if len(prj) < 19: + return + if prj[:13].upper() != "DOCUMENTPATH=": + return + if prj[-6:].upper() != "PCBDOC": + return + rel = prj[13:] + pcb_path = (prj_path.parent / rel).resolve() + if pcb_path.exists(): + if self._parse_pcbdoc_file(pcb_path, data): + data.pcb_doc_file_name = str(pcb_path) + + def _parse_schdoc_file(self, path: Path, components_list: list[list[ComponentProperty]]) -> None: + lines = _read_pipe_chunks(path) + is_component = False + is_no_bom = False + is_first_part = True + component_props: list[ComponentProperty] = [] + + def flush(): + nonlocal component_props, is_no_bom, is_first_part, is_component + if not is_no_bom and component_props and is_first_part: + components_list.append(list(component_props)) + component_props = [] + is_no_bom = False + is_first_part = True + + for line in lines: + parts = line.split("|") + i = 0 + while i < len(parts): + part = parts[i] + up = part.upper() + + if len(part) == 8 and up == "RECORD=1": + if is_component: + flush() + is_component = True + i += 1 + continue + + if is_component: + if up.startswith("COMPONENTKINDVERSION2=5"): + is_no_bom = True + elif up.startswith("CURRENTPARTID="): + if up != "CURRENTPARTID=1" or len(part) > 15: + is_first_part = False + elif ( + i + 1 < len(parts) + and up.startswith("TEXT=") + and parts[i + 1].upper().startswith("NAME=") + ): + text = part[5:] + name = parts[i + 1][5:] + if not is_no_bom: + component_props.append(ComponentProperty(name, text)) + i += 2 + continue + elif up.startswith("HEADER="): + flush() + is_component = False + + i += 1 + + if is_component: + flush() + + def _parse_pcbdoc_file(self, path: Path, data: ProjectData) -> bool: + lines = _read_pipe_chunks(path) + signal_layerset: Optional[str] = None + layer_count = 0 + materials: list[DielProperty] = [] + + layerset_re = re.compile(r"LAYERSET(\d+)NAME=&Signal Layers", re.I) + v9_name_re = re.compile(r"V9_STACK_LAYER(\d+)_NAME=", re.I) + v9_diel_re = re.compile(r"V9_STACK_LAYER(\d+)_DIELTYPE=(\d+)", re.I) + v8_diel_re = re.compile(r"LAYER_V8_(\d+)_DIELTYPE=(\d+)", re.I) + + found_layerset = False + for line in lines: + m = layerset_re.search(line) + if m: + signal_layerset = m.group(1) + found_layerset = True + + for line in lines: + parts = line.split("|") + joined = line + if signal_layerset: + key = f"LAYERSET{signal_layerset}LAYERS=" + for part in parts: + if part.upper().startswith(key.upper()): + layers = part.split("=", 1)[1].split(",") + layer_count = sum(1 for x in layers if x.strip() and x.strip() != "MultiLayer") + + for m in v9_name_re.finditer(joined): + n = int(m.group(1)) + layer_count = max(layer_count, n + 1) + + for m in v9_diel_re.finditer(joined): + n = int(m.group(1)) + diel_type = int(m.group(2)) + height = 0.0 + value = "" + hkey = f"V9_STACK_LAYER{n}_DIELHEIGHT=" + mkey = f"V9_STACK_LAYER{n}_DIELMATERIAL=" + for part in parts: + pu = part.upper() + if pu.startswith(hkey.upper()): + hraw = part.split("=", 1)[1] + hraw = re.sub(r"mil", "", hraw, flags=re.I).strip() + try: + height = float(hraw) * 0.0254 + except ValueError: + height = 0.0 + if pu.startswith(mkey.upper()): + value = part.split("=", 1)[1] + if value and height > 0: + materials.append( + DielProperty("DielMaterial", value, height, diel_type, n) + ) + + for m in v8_diel_re.finditer(joined): + n = int(m.group(1)) + diel_type = int(m.group(2)) + height = 0.0 + value = "" + hkey = f"LAYER_V8_{n}_DIELHEIGHT=" + mkey = f"LAYER_V8_{n}_DIELMATERIAL=" + for part in parts: + pu = part.upper() + if pu.startswith(hkey.upper()): + hraw = part.split("=", 1)[1] + hraw = re.sub(r"mil", "", hraw, flags=re.I).strip() + try: + height = float(hraw) * 0.0254 + except ValueError: + height = 0.0 + if pu.startswith(mkey.upper()): + value = part.split("=", 1)[1] + if value and height > 0: + materials.append( + DielProperty("DielMaterial", value, height, diel_type, n) + ) + + if not found_layerset and layer_count > 0: + layer_count -= 1 + + data.pcb_layer_count = max(layer_count, 0) + data.pcb_diel_materials = materials + return True + + +def make_complex_string(text: str, props: list[ComponentProperty]) -> str: + if not text.startswith("="): + return text + expr = text[1:] + mapping = {p.name: p.text for p in props} + + def repl(m: re.Match) -> str: + key = m.group(1) + return mapping.get(key, "") + + return re.sub(r"['\"]([^'\"]+)['\"]", repl, expr) + + +def find_prjpcb(extract_dir: Path) -> Optional[Path]: + candidates = list(extract_dir.rglob("*.PrjPcb")) + list(extract_dir.rglob("*.prjpcb")) + return candidates[0] if candidates else None diff --git a/web/backend/app/services/designators.py b/web/backend/app/services/designators.py new file mode 100644 index 0000000..cca40ff --- /dev/null +++ b/web/backend/app/services/designators.py @@ -0,0 +1,178 @@ +"""Default designator type names (singular/plural) by letter prefix.""" + +from __future__ import annotations + +import re + +# Minimal useful defaults; projects can override via DB +DEFAULT_MAPPINGS: dict[str, tuple[str, str]] = { + "C": ("Конденсатор", "Конденсаторы"), + "R": ("Резистор", "Резисторы"), + "L": ("Катушка индуктивности", "Катушки индуктивности"), + "D": ("Диод", "Диоды"), + "VD": ("Диод", "Диоды"), + "VT": ("Транзистор", "Транзисторы"), + "Q": ("Транзистор", "Транзисторы"), + "DA": ("Микросхема", "Микросхемы"), + "DD": ("Микросхема", "Микросхемы"), + "U": ("Микросхема", "Микросхемы"), + "X": ("Соединитель", "Соединители"), + "XP": ("Соединитель", "Соединители"), + "XS": ("Соединитель", "Соединители"), + "FU": ("Предохранитель", "Предохранители"), + "F": ("Предохранитель", "Предохранители"), + "SA": ("Переключатель", "Переключатели"), + "SB": ("Кнопка", "Кнопки"), + "HL": ("Индикатор", "Индикаторы"), + "HG": ("Индикатор", "Индикаторы"), + "G": ("Генератор", "Генераторы"), + "T": ("Трансформатор", "Трансформаторы"), + "TV": ("Трансформатор", "Трансформаторы"), + "K": ("Реле", "Реле"), + "B": ("Пьезоэлемент", "Пьезоэлементы"), + "Z": ("Фильтр", "Фильтры"), +} + + +def split_designator(designator: str) -> tuple[str, str]: + m = re.match(r"^([A-Za-z]+)(\d*)$", designator.strip()) + if m: + return m.group(1), m.group(2) + for i, ch in enumerate(designator): + if ch.isdigit(): + return designator[:i], designator[i:] + return designator, "" + + +def designator_sort_key(designator: str) -> tuple[str, int, str]: + letter, num = split_designator(designator) + try: + n = int(num) if num else 0 + except ValueError: + n = 0 + return letter, n, num + return letter.upper(), n, num + + +def prefix_from_designator(designator: str) -> str: + letter, _ = split_designator(designator) + return letter.upper() + + +def get_type_names( + designator: str, mappings: dict[str, tuple[str, str]] | None = None +) -> tuple[str, str]: + maps = {**DEFAULT_MAPPINGS, **(mappings or {})} + prefix = prefix_from_designator(designator) + if prefix in maps: + return maps[prefix] + # try 2-letter then 1-letter + if len(prefix) >= 2 and prefix[:2] in maps: + return maps[prefix[:2]] + if prefix[:1] in maps: + return maps[prefix[:1]] + return (prefix, prefix) + + +def format_designator_range(designators: list[str]) -> str: + if not designators: + return "" + if len(designators) == 1: + return designators[0] + if len(designators) == 2: + return f"{designators[0]}, {designators[1]}" + return f"{designators[0]}-{designators[-1]}" + + +def format_consecutive_ranges(designators: list[str]) -> str: + """Group consecutive numbered designators into ranges.""" + if not designators: + return "" + parsed = [] + for d in designators: + letter, num = split_designator(d) + try: + n = int(num) if num else None + except ValueError: + n = None + parsed.append((d, letter, n)) + + segments: list[list[str]] = [] + current: list[str] = [] + last_letter = None + last_num = None + for d, letter, n in parsed: + if not current: + current = [d] + last_letter, last_num = letter, n + continue + if letter == last_letter and n is not None and last_num is not None and n == last_num + 1: + current.append(d) + last_num = n + else: + segments.append(current) + current = [d] + last_letter, last_num = letter, n + if current: + segments.append(current) + + parts = [] + for seg in segments: + if len(seg) == 1: + parts.append(seg[0]) + elif len(seg) == 2: + parts.append(f"{seg[0]}, {seg[1]}") + else: + parts.append(f"{seg[0]}-{seg[-1]}") + return ", ".join(parts) + + +def split_long_text(text: str, max_length: int) -> list[str]: + if not text or len(text) <= max_length: + return [text] if text is not None else [""] + parts: list[str] = [] + remaining = text + while len(remaining) > max_length: + chunk = remaining[:max_length] + split_at = chunk.rfind(",") + if split_at < max_length // 3: + split_at = chunk.rfind(" ") + if split_at < max_length // 3: + split_at = max_length + parts.append(remaining[:split_at].rstrip(", ").strip()) + remaining = remaining[split_at:].lstrip(", ").strip() + if remaining: + parts.append(remaining) + return parts or [""] + + +def resolve_expression(value: str, props: dict[str, str]) -> str: + if not value.startswith("="): + return value + expr = value[1:] + result = [] + i = 0 + while i < len(expr): + ch = expr[i] + if ch in "'\"": + quote = ch + i += 1 + start = i + while i < len(expr) and expr[i] != quote: + i += 1 + result.append(expr[start:i]) + i += 1 + elif ch == "+": + i += 1 + elif ch.isspace(): + i += 1 + else: + start = i + while i < len(expr) and expr[i] not in "+'\"": + i += 1 + key = expr[start:i].strip() + val = props.get(key, "") + if val.startswith("="): + val = resolve_expression(val, props) + result.append(val) + return "".join(result) diff --git a/web/backend/app/services/excel_export.py b/web/backend/app/services/excel_export.py new file mode 100644 index 0000000..4ccb90a --- /dev/null +++ b/web/backend/app/services/excel_export.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from io import BytesIO +from typing import Any + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font + +HEADERS = { + "perechen": ["Поз. обозначение", "Наименование", "Кол.", "Примечание"], + "specification_pcb": ["Формат", "Зона", "Поз.", "Обозначение", "Наименование", "Кол.", "Примечание"], + "specification": ["Формат", "Зона", "Поз.", "Обозначение", "Наименование", "Кол.", "Примечание"], + "vedomost": [ + "Наименование", + "Код продукции", + "Обозн. документа на поставку", + "Поставщик", + "Куда входит", + "Кол. на изделие", + "Кол. в комплекте", + "Кол. на регулир.", + "Всего", + "Примечание", + ], + "simple_list": ["Поз. обозначение", "Наименование", "Кол."], +} + +FIELDS = { + "perechen": ["position", "designation", "quantity", "note"], + "specification_pcb": ["format", "zone", "position", "designation", "name", "quantity", "note"], + "specification": ["format", "zone", "position", "designation", "name", "quantity", "note"], + "vedomost": [ + "name", + "product_code", + "document_code", + "supplier", + "where_used", + "quantity_per_item", + "quantity_in_set", + "quantity_for_reg", + "total_quantity", + "note", + ], + "simple_list": ["designator", "name", "quantity"], +} + + +def export_xlsx(table_type: str, rows: list[dict[str, Any]], title: str = "") -> bytes: + wb = Workbook() + ws = wb.active + ws.title = table_type[:31] + headers = HEADERS[table_type] + fields = FIELDS[table_type] + if title: + ws.append([title]) + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers)) + ws["A1"].font = Font(bold=True, size=14) + ws.append(headers) + for cell in ws[ws.max_row]: + cell.font = Font(bold=True) + cell.alignment = Alignment(wrap_text=True) + for row in rows: + if row.get("is_empty"): + ws.append([""] * len(fields)) + continue + values = [row.get(f, "") or "" for f in fields] + ws.append(values) + if row.get("is_header"): + for cell in ws[ws.max_row]: + cell.font = Font(bold=True, underline="single") + buf = BytesIO() + wb.save(buf) + return buf.getvalue() diff --git a/web/backend/app/services/llm.py b/web/backend/app/services/llm.py new file mode 100644 index 0000000..6cd75b5 --- /dev/null +++ b/web/backend/app/services/llm.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +import httpx + +from app.core.config import get_settings + + +SYSTEM_PROMPT = """Ты помощник инженера по редактированию ГОСТ-таблиц (перечень, спецификация, ведомость). +Пользователь даёт команды на русском. Ты отвечаешь ТОЛЬКО валидным JSON без markdown: +{ + "reply": "краткий ответ пользователю", + "edits": [ + {"op": "update_row", "row_id": 123, "fields": {"note": "..."}}, + {"op": "update_row", "row_index": 5, "fields": {"designation": "..."}}, + {"op": "add_row", "row_index": 10, "fields": {...}}, + {"op": "delete_row", "row_id": 123} + ] +} +Правила: +- Меняй только то, о чём просят. +- Используй row_id из снимка таблицы, если есть. +- Не выдумывай поля вне списка колонок. +- Если правок нет — edits: []. +""" + + +def _extract_json(text: str) -> dict[str, Any]: + text = text.strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + try: + return json.loads(text) + except json.JSONDecodeError: + m = re.search(r"\{.*\}", text, re.S) + if m: + return json.loads(m.group(0)) + return {"reply": text, "edits": []} + + +def compress_rows(rows: list[dict[str, Any]], max_rows: int) -> list[dict[str, Any]]: + slim = [] + for r in rows[:max_rows]: + item = {"row_id": r.get("id"), "row_index": r.get("row_index")} + for k, v in r.items(): + if k in ("id", "project_id", "is_auto_generated", "stretch"): + continue + if v not in ("", None, False, 0) or k in ("is_header", "is_empty"): + item[k] = v + slim.append(item) + return slim + + +async def chat_edit_table( + message: str, + table_type: str, + rows: list[dict[str, Any]], +) -> dict[str, Any]: + settings = get_settings() + if not settings.openrouter_api_key: + return { + "reply": "OpenRouter API key не настроен (OPENROUTER_API_KEY).", + "edits": [], + } + + snapshot = compress_rows(rows, settings.llm_max_context_rows) + user_content = ( + f"Тип таблицы: {table_type}\n" + f"Снимок строк (до {settings.llm_max_context_rows}):\n" + f"{json.dumps(snapshot, ensure_ascii=False)}\n\n" + f"Команда пользователя: {message}" + ) + + payload = { + "model": settings.openrouter_model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_content}, + ], + "temperature": 0.2, + "max_tokens": 2000, + } + headers = { + "Authorization": f"Bearer {settings.openrouter_api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://gostgenerator.local", + "X-Title": "GostGenerator Web", + } + url = settings.openrouter_base_url.rstrip("/") + "/chat/completions" + + proxy = (settings.openrouter_proxy or "").strip() or None + if proxy and "://" not in proxy: + proxy = f"socks5://{proxy}" + + client_kwargs: dict[str, Any] = {"timeout": 90.0} + if proxy: + client_kwargs["proxy"] = proxy + + async with httpx.AsyncClient(**client_kwargs) as client: + resp = await client.post(url, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + + content = data["choices"][0]["message"]["content"] + parsed = _extract_json(content) + edits = parsed.get("edits") or [] + # normalize + norm_edits = [] + for e in edits: + if not isinstance(e, dict): + continue + norm_edits.append( + { + "op": e.get("op", "update_row"), + "row_id": e.get("row_id"), + "row_index": e.get("row_index"), + "fields": e.get("fields") or {}, + } + ) + return {"reply": parsed.get("reply") or "", "edits": norm_edits} diff --git a/web/backend/app/services/pdf_export.py b/web/backend/app/services/pdf_export.py new file mode 100644 index 0000000..5b73ad9 --- /dev/null +++ b/web/backend/app/services/pdf_export.py @@ -0,0 +1,182 @@ +"""Simplified GOST A4 PDF export with frame and table.""" + +from __future__ import annotations + +from io import BytesIO +from pathlib import Path +from typing import Any + +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas + +from app.core.config import get_settings + +_font_registered = False +FONT_NAME = "GOST_A" + + +def _ensure_font() -> str: + global _font_registered + if not _font_registered: + path = Path(get_settings().font_path) + if path.exists(): + pdfmetrics.registerFont(TTFont(FONT_NAME, str(path))) + _font_registered = True + return FONT_NAME + return "Helvetica" + return FONT_NAME if Path(get_settings().font_path).exists() else "Helvetica" + + +DOC_TITLES = { + "perechen": "Перечень элементов", + "specification_pcb": "Спецификация", + "specification": "Спецификация", + "vedomost": "Ведомость покупных изделий", +} + +COLUMNS = { + "perechen": [ + ("position", 30 * mm, "Поз."), + ("designation", 100 * mm, "Наименование"), + ("quantity", 15 * mm, "Кол."), + ("note", 35 * mm, "Прим."), + ], + "specification_pcb": [ + ("format", 12 * mm, "Форм."), + ("zone", 10 * mm, "Зона"), + ("position", 10 * mm, "Поз."), + ("designation", 40 * mm, "Обозн."), + ("name", 55 * mm, "Наименование"), + ("quantity", 12 * mm, "Кол."), + ("note", 30 * mm, "Прим."), + ], + "specification": [ + ("format", 12 * mm, "Форм."), + ("zone", 10 * mm, "Зона"), + ("position", 10 * mm, "Поз."), + ("designation", 40 * mm, "Обозн."), + ("name", 55 * mm, "Наименование"), + ("quantity", 12 * mm, "Кол."), + ("note", 30 * mm, "Прим."), + ], + "vedomost": [ + ("name", 40 * mm, "Наименование"), + ("product_code", 20 * mm, "Код"), + ("document_code", 25 * mm, "Док."), + ("supplier", 20 * mm, "Пост."), + ("where_used", 20 * mm, "Куда"), + ("quantity_per_item", 12 * mm, "На изд."), + ("total_quantity", 12 * mm, "Всего"), + ("note", 20 * mm, "Прим."), + ], +} + +ROWS_FIRST = {"perechen": 25, "specification_pcb": 26, "specification": 27, "vedomost": 24} +ROWS_OTHER = {"perechen": 32, "specification_pcb": 32, "specification": 33, "vedomost": 29} + + +def _draw_frame(c: canvas.Canvas, w: float, h: float, page: int, total: int, inscriptions: dict[int, str], doc_type: str): + font = _ensure_font() + margin_left = 20 * mm + margin_right = 5 * mm + margin_top = 5 * mm + margin_bottom = 5 * mm + c.setLineWidth(0.8) + c.rect(margin_left, margin_bottom, w - margin_left - margin_right, h - margin_top - margin_bottom) + + # Title block (simplified bottom-right) + block_h = 55 * mm if page == 1 else 15 * mm + block_w = 185 * mm + x0 = w - margin_right - block_w + y0 = margin_bottom + c.setLineWidth(0.5) + c.rect(x0, y0, block_w, block_h) + + c.setFont(font, 8) + name = inscriptions.get(1, "") + designation = inscriptions.get(2, "") + org = inscriptions.get(9, "") + title = DOC_TITLES.get(doc_type, "") + c.drawString(x0 + 2 * mm, y0 + block_h - 6 * mm, f"{title}") + c.drawString(x0 + 2 * mm, y0 + block_h - 12 * mm, designation[:60]) + c.drawString(x0 + 2 * mm, y0 + block_h - 18 * mm, name[:60]) + if page == 1: + c.drawString(x0 + 2 * mm, y0 + 8 * mm, org[:40]) + c.drawString(x0 + 2 * mm, y0 + 3 * mm, f"Разраб. {inscriptions.get(111, '')}") + c.drawString(x0 + 50 * mm, y0 + 3 * mm, f"Пров. {inscriptions.get(112, '')}") + c.drawRightString(x0 + block_w - 2 * mm, y0 + 3 * mm, f"Лист {page}/{total}") + + +def _chunk_rows(rows: list[dict[str, Any]], first: int, other: int) -> list[list[dict[str, Any]]]: + if not rows: + return [[]] + pages: list[list[dict[str, Any]]] = [] + i = 0 + limit = first + while i < len(rows): + pages.append(rows[i : i + limit]) + i += limit + limit = other + return pages + + +def export_pdf( + table_type: str, + rows: list[dict[str, Any]], + inscriptions: dict[int, str] | None = None, +) -> bytes: + if table_type == "simple_list": + raise ValueError("PDF export is not available for simple_list") + inscriptions = inscriptions or {} + font = _ensure_font() + cols = COLUMNS[table_type] + first = ROWS_FIRST[table_type] + other = ROWS_OTHER[table_type] + pages = _chunk_rows(rows, first, other) + total = max(len(pages), 1) + + buf = BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + w, h = A4 + + for page_idx, page_rows in enumerate(pages, start=1): + _draw_frame(c, w, h, page_idx, total, inscriptions, table_type) + + # table area + left = 20 * mm + top = h - 10 * mm + row_h = 6 * mm + header_y = top - 8 * mm + + # column headers + x = left + 2 * mm + c.setFont(font, 7) + for field, width, label in cols: + c.drawString(x, header_y, label) + x += width + c.line(left, header_y - 2 * mm, left + sum(w for _, w, _ in cols) + 4 * mm, header_y - 2 * mm) + + y = header_y - row_h + for row in page_rows: + if y < 65 * mm and page_idx == 1: + break + if y < 25 * mm: + break + x = left + 2 * mm + style_size = 8 if row.get("is_header") else 7 + c.setFont(font, style_size) + for field, width, _ in cols: + text = "" if row.get("is_empty") else str(row.get(field, "") or "") + # truncate to fit roughly + max_chars = max(int(width / mm), 1) + c.drawString(x, y, text[: max_chars + 5]) + x += width + y -= row_h + + c.showPage() + + c.save() + return buf.getvalue() diff --git a/web/backend/app/services/project_service.py b/web/backend/app/services/project_service.py new file mode 100644 index 0000000..a65b408 --- /dev/null +++ b/web/backend/app/services/project_service.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import shutil +import zipfile +from pathlib import Path + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session, selectinload + +from app.core.config import get_settings +from app.models import ( + Component, + ComponentProperty, + ComponentVariant, + DesignatorMapping, + DielMaterial, + PcbData, + Project, + ProjectParam, + Variant, + VariantProperty, +) +from app.services.altium_parser import AltiumParser, ProjectData, find_prjpcb, make_complex_string +from app.services.designators import DEFAULT_MAPPINGS + + +def project_dir(project_id: int) -> Path: + root = Path(get_settings().data_dir) / "projects" / str(project_id) + root.mkdir(parents=True, exist_ok=True) + return root + + +def clear_parsed_data(db: Session, project: Project, keep_tables: bool = True) -> None: + """Remove components/variants/params/pcb; optionally keep edited tables.""" + comps = db.scalars(select(Component).where(Component.project_id == project.id)).all() + comp_ids = [c.id for c in comps] + if comp_ids: + db.execute(delete(VariantProperty).where(VariantProperty.component_id.in_(comp_ids))) + db.execute(delete(ComponentVariant).where(ComponentVariant.component_id.in_(comp_ids))) + db.execute(delete(ComponentProperty).where(ComponentProperty.component_id.in_(comp_ids))) + db.execute(delete(Component).where(Component.id.in_(comp_ids))) + db.execute(delete(Variant).where(Variant.project_id == project.id)) + db.execute(delete(ProjectParam).where(ProjectParam.project_id == project.id)) + pcb = db.scalar(select(PcbData).where(PcbData.project_id == project.id)) + if pcb: + db.execute(delete(DielMaterial).where(DielMaterial.pcb_data_id == pcb.id)) + db.delete(pcb) + if not keep_tables: + from app.models import ( + PerechenRow, + SimpleListRow, + SpecificationPcbRow, + SpecificationRow, + VedomostRow, + ) + + for model in (PerechenRow, SpecificationPcbRow, SpecificationRow, VedomostRow, SimpleListRow): + db.execute(delete(model).where(model.project_id == project.id)) + db.flush() + + +def ensure_default_mappings(db: Session, project: Project) -> None: + existing = db.scalars( + select(DesignatorMapping).where(DesignatorMapping.project_id == project.id) + ).all() + if existing: + return + for prefix, (sing, plur) in DEFAULT_MAPPINGS.items(): + db.add( + DesignatorMapping( + project_id=project.id, + prefix=prefix, + singular_name=sing, + plural_name=plur, + ) + ) + + +def save_project_data(db: Session, project: Project, data: ProjectData) -> None: + variant_objs: list[Variant] = [] + for name in data.variant_names: + v = Variant(project_id=project.id, name=name) + db.add(v) + variant_objs.append(v) + db.flush() + + # Flat props for expression resolution of project params + flat_props = [] + for comp_props in data.components_list: + flat_props.extend(comp_props) + + # Regular components + designator_to_component: dict[str, Component] = {} + for comp_props in data.components_list: + designator = "" + for p in comp_props: + if p.name.lower() == "designator": + designator = p.text + break + if not designator: + continue + if designator in designator_to_component: + continue + comp = Component(project_id=project.id, designator=designator) + db.add(comp) + db.flush() + for p in comp_props: + db.add(ComponentProperty(component_id=comp.id, key=p.name, value=p.text)) + designator_to_component[designator] = comp + # link to all variants as fitted by default + for v in variant_objs: + db.add(ComponentVariant(component_id=comp.id, variant_id=v.id, is_fitted=True)) + + # Variant overrides (skip No Variations at index 0 of variant_names) + for i, variant_comps in enumerate(data.components_variant_list): + variant_index = i + 1 + if variant_index >= len(variant_objs): + continue + variant = variant_objs[variant_index] + dnf_list = ( + data.dnf_variant_designators_list[i] + if i < len(data.dnf_variant_designators_list) + else [] + ) + for comp_props in variant_comps: + designator = "" + for p in comp_props: + if p.name == "Designator": + designator = p.text + break + if not designator: + continue + comp = designator_to_component.get(designator) + if not comp: + continue + is_dnf = designator in dnf_list + link = db.scalar( + select(ComponentVariant).where( + ComponentVariant.component_id == comp.id, + ComponentVariant.variant_id == variant.id, + ) + ) + if link: + link.is_fitted = not is_dnf + for p in comp_props: + if p.name in ("Designator", "Kind"): + continue + db.add( + VariantProperty( + component_id=comp.id, + variant_id=variant.id, + key=p.name, + value=p.text, + ) + ) + + # Project params + for variant_idx, params in enumerate(data.prj_params_variant_list): + for item in params: + if len(item) < 2: + continue + name, value = item[0], item[1] + value = make_complex_string(value, flat_props) + db.add( + ProjectParam( + project_id=project.id, + name=name, + value=value, + variant_name=data.variant_names[variant_idx] + if variant_idx < len(data.variant_names) + else "", + ) + ) + + # PCB + if data.pcb_layer_count or data.pcb_diel_materials: + pcb = PcbData(project_id=project.id, layer_count=data.pcb_layer_count) + db.add(pcb) + db.flush() + for m in data.pcb_diel_materials: + db.add( + DielMaterial( + pcb_data_id=pcb.id, + name=m.name, + value=m.value, + height=m.height, + diel_type=m.diel_type, + layer_number=m.layer_number, + ) + ) + + project.pcb_doc_name = Path(data.pcb_doc_file_name).name if data.pcb_doc_file_name else None + if "No Variations" in data.variant_names: + project.current_variant = "No Variations" + elif data.variant_names: + project.current_variant = data.variant_names[0] + + +def ingest_zip( + db: Session, + project: Project, + zip_bytes: bytes, + filename: str = "project.zip", + keep_tables: bool = True, +) -> Project: + settings = get_settings() + Path(settings.data_dir).mkdir(parents=True, exist_ok=True) + pdir = project_dir(project.id) + zip_path = pdir / filename + extract_path = pdir / "extract" + + if extract_path.exists(): + shutil.rmtree(extract_path) + extract_path.mkdir(parents=True, exist_ok=True) + zip_path.write_bytes(zip_bytes) + + try: + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_path) + except zipfile.BadZipFile as e: + project.status = "error" + project.error_message = f"Invalid zip: {e}" + db.commit() + raise ValueError(project.error_message) from e + + prj = find_prjpcb(extract_path) + if not prj: + project.status = "error" + project.error_message = "No .PrjPcb found in archive" + db.commit() + raise ValueError(project.error_message) + + project.status = "parsing" + project.zip_path = str(zip_path) + project.extract_path = str(extract_path) + project.error_message = None + db.commit() + + try: + clear_parsed_data(db, project, keep_tables=keep_tables) + parser = AltiumParser() + data = parser.parse_prjpcb(prj) + save_project_data(db, project, data) + ensure_default_mappings(db, project) + project.status = "ready" + db.commit() + db.refresh(project) + return project + except Exception as e: + project.status = "error" + project.error_message = str(e) + db.commit() + raise + + +def get_project_full(db: Session, project_id: int) -> Project | None: + return db.scalar( + select(Project) + .where(Project.id == project_id) + .options( + selectinload(Project.variants), + selectinload(Project.components).selectinload(Component.properties), + selectinload(Project.project_params), + selectinload(Project.pcb_data).selectinload(PcbData.diel_materials), + selectinload(Project.inscriptions), + selectinload(Project.designator_mappings), + ) + ) diff --git a/web/backend/app/services/table_generators.py b/web/backend/app/services/table_generators.py new file mode 100644 index 0000000..eedbb5a --- /dev/null +++ b/web/backend/app/services/table_generators.py @@ -0,0 +1,619 @@ +"""GOST table generators (ported from desktop table controllers).""" + +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass, field +from typing import Any, Optional + +from app.services.designators import ( + designator_sort_key, + format_consecutive_ranges, + format_designator_range, + get_type_names, + resolve_expression, + split_designator, + split_long_text, +) + + +@dataclass +class ComponentView: + designator: str + properties: dict[str, str] = field(default_factory=dict) + is_fitted: bool = True + + def get(self, key: str, default: str = "") -> str: + raw = self.properties.get(key, default) + return resolve_expression(raw, self.properties) if raw else default + + +@dataclass +class MaterialView: + name: str + value: str + height: float + diel_type: int + layer_number: int + + +def _empty_flags(**extra: Any) -> dict[str, Any]: + base = { + "is_header": False, + "is_empty": False, + "is_auto_generated": True, + "stretch": False, + "is_underline": False, + "page_number": 1, + } + base.update(extra) + return base + + +def _paginate(rows: list[dict[str, Any]], first: int, other: int) -> list[dict[str, Any]]: + page = 1 + used = 0 + limit = first + for i, row in enumerate(rows): + if used >= limit: + page += 1 + used = 0 + limit = other + row["row_index"] = i + row["page_number"] = page + used += 1 + return rows + + +def _group_by_letter(components: list[ComponentView]) -> OrderedDict[str, list[ComponentView]]: + groups: OrderedDict[str, list[ComponentView]] = OrderedDict() + for comp in sorted(components, key=lambda c: designator_sort_key(c.designator)): + letter, _ = split_designator(comp.designator) + letter = letter.upper() + groups.setdefault(letter, []).append(comp) + return groups + + +def generate_perechen( + components: list[ComponentView], + name_field: str = "Name", + mappings: dict[str, tuple[str, str]] | None = None, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + fitted = [c for c in components if c.is_fitted] + groups = _group_by_letter(fitted) + + for letter, comps in groups.items(): + singular, plural = get_type_names(comps[0].designator, mappings) + if len(comps) > 1: + rows.append(_empty_flags(is_empty=True, position="", designation="", quantity="", note="")) + rows.append( + _empty_flags( + is_header=True, + position="", + designation=plural, + quantity="", + note="", + is_underline=True, + ) + ) + rows.append(_empty_flags(is_empty=True, position="", designation="", quantity="", note="")) + + # subgroup by name value, preserving order + subgroups: OrderedDict[str, list[ComponentView]] = OrderedDict() + for c in comps: + key = c.get(name_field, "") + subgroups.setdefault(key, []).append(c) + + for value, group in subgroups.items(): + desigs = [c.designator for c in group] + desig_text = format_designator_range(desigs) + desig_parts = split_long_text(desig_text, 60) + name_parts = split_long_text(value, 60) + max_rows = max(len(desig_parts), len(name_parts), 1) + for i in range(max_rows): + rows.append( + _empty_flags( + position=desig_parts[i] if i < len(desig_parts) else "", + designation=name_parts[i] if i < len(name_parts) else "", + quantity=str(len(group)) if i == 0 else "", + note="", + ) + ) + else: + c = comps[0] + value = c.get(name_field, "") + designation = f"{singular} {value}".strip() + rows.append(_empty_flags(is_empty=True, position="", designation="", quantity="", note="")) + rows.append( + _empty_flags( + position=c.designator, + designation=designation, + quantity="1", + note="", + ) + ) + + return _paginate(rows, 25, 32) + + +def generate_specification_pcb( + components: list[ComponentView], + decimal_number: str = "", + board_name: str = "", + pcb_doc_name: str = "", + mappings: dict[str, tuple[str, str]] | None = None, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + dec = decimal_number or "" + + def add_empty(): + rows.append( + _empty_flags( + format="", zone="", position="", designation="", name="", quantity="", note="", is_empty=True + ) + ) + + def add_header(name: str): + rows.append( + _empty_flags( + format="", + zone="", + position="", + designation="", + name=name, + quantity="", + note="", + is_header=True, + is_underline=True, + ) + ) + + def add_doc(fmt: str, designation: str, name: str, note: str = "", pos: str = ""): + rows.append( + _empty_flags( + format=fmt, + zone="", + position=pos, + designation=designation, + name=name, + quantity="", + note=note, + ) + ) + + add_empty() + add_header("Документация") + add_empty() + add_doc("A1", f"{dec} СБ", "Сборочный чертеж") + add_doc("A3", f"{dec} Э3", "Схема электрическая принципиальная") + add_doc("A4", f"{dec} ПЭ3", "Перечень элементов") + add_doc("A3", f"{dec} ВП", "Ведомость покупных изделий") + add_doc("*)", f"{dec} Д33", "Данные результатов проектирования", "DVD диск") + add_empty() + add_doc("А4", f"{dec} Д10-УЛ", "Удостоверяющий лист", "Размножать") + add_doc("", "", "Данные результатов проектирования", "по указанию") + add_empty() + add_header("Сборочные единицы") + add_empty() + rows.append( + _empty_flags( + format="", + zone="", + position="1", + designation=f"{dec} платы" if dec else "платы", + name=board_name, + quantity="", + note="", + ) + ) + add_empty() + add_header("Детали") + add_empty() + add_header("Стандартные изделия") + add_empty() + add_header("Прочие изделия") + add_empty() + + pos = 2 + fitted = [c for c in components if c.is_fitted] + groups = _group_by_letter(fitted) + + for letter, comps in groups.items(): + subgroups: OrderedDict[str, list[ComponentView]] = OrderedDict() + for c in comps: + key = c.get("ManufacturerPartNumber", "") or c.get("Name", "") + subgroups.setdefault(key, []).append(c) + + for part_number, group in subgroups.items(): + singular, plural = get_type_names(group[0].designator, mappings) + qty = len(group) + type_name = plural if qty > 1 else singular + desigs = format_consecutive_ranges([c.designator for c in group]) + note_parts = split_long_text(desigs, 11) + name_full = f"{type_name} {part_number}".strip() + name_parts = split_long_text(name_full, 34) + # if type + first part too long, put type on its own line + if name_parts and len(f"{type_name} {name_parts[0]}") > 34 and part_number: + name_parts = [type_name] + split_long_text(part_number, 34) + + max_rows = max(len(name_parts), len(note_parts), 1) + for i in range(max_rows): + rows.append( + _empty_flags( + format="", + zone="", + position=str(pos) if i == 0 else "", + designation="", + name=name_parts[i] if i < len(name_parts) else "", + quantity=str(qty) if i == 0 else "", + note=note_parts[i] if i < len(note_parts) else "", + ) + ) + pos += 1 + + add_empty() + add_header("Примечание") + rows.append( + _empty_flags( + format="", zone="", position="", designation="", name="Изготовить плату печатную", quantity="", note="" + ) + ) + pcb_short = Path_name(pcb_doc_name) + rows.append( + _empty_flags( + format="", + zone="", + position="", + designation="", + name=f"По файлу {pcb_short}" if pcb_short else "По файлу", + quantity="", + note="", + ) + ) + rows.append( + _empty_flags( + format="", + zone="", + position="", + designation="", + name=f"из состава {dec} Д10" if dec else "из состава Д10", + quantity="", + note="", + ) + ) + + return _paginate(rows, 26, 32) + + +def Path_name(path: str) -> str: + if not path: + return "" + return path.replace("\\", "/").split("/")[-1] + + +def generate_specification( + materials: list[MaterialView], + layer_count: int = 0, + decimal_number: str = "", +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + dec = decimal_number or "" + + def add_empty(): + rows.append( + _empty_flags( + format="", zone="", position="", designation="", name="", quantity="", note="", is_empty=True + ) + ) + + def add_header(name: str): + rows.append( + _empty_flags( + format="", + zone="", + position="", + designation="", + name=name, + quantity="", + note="", + is_header=True, + is_underline=True, + ) + ) + + add_empty() + add_header("Документация") + add_empty() + rows.append( + _empty_flags( + format="A1", + zone="", + position="", + designation=f"{dec}Э3 СБ", + name="Сборочный чертеж", + quantity="", + note="", + ) + ) + rows.append( + _empty_flags( + format="*)", + zone="", + position="", + designation=f"{dec} Э3 Т5М", + name="Данные проектирования", + quantity="", + note="", + ) + ) + add_empty() + add_header("Материалы") + add_empty() + + sorted_mats = sorted(materials, key=lambda m: m.layer_number) + by_name: OrderedDict[str, list[MaterialView]] = OrderedDict() + for m in sorted_mats: + by_name.setdefault(m.name, []).append(m) + + core_count = sum(1 for m in materials if m.diel_type == 1) + foil_count = max(layer_count - core_count * 2, 0) + pos = 1 + + for name, group in by_name.items(): + if name == "DielMaterial": + by_value: OrderedDict[str, list[MaterialView]] = OrderedDict() + for m in group: + by_value.setdefault(m.value, []).append(m) + for value, subgroup in by_value.items(): + display = { + "FR4 PR": "Препрег FR4 PR", + "FR4 Tg150": "Стеклотекстолит FR4 Tg150", + "Solder Resist": "Паяльная маска", + }.get(value, value) + thickness = subgroup[0].height + display = f"{display} {thickness:.3f} мм" + rows.append( + _empty_flags( + format="", + zone="", + position=str(pos), + designation="", + name=display, + quantity=str(len(subgroup)), + note="", + ) + ) + pos += 1 + else: + rows.append( + _empty_flags( + format="", + zone="", + position=str(pos), + designation="", + name=name, + quantity=str(len(group)), + note="", + ) + ) + pos += 1 + + if foil_count > 0: + rows.append( + _empty_flags( + format="", + zone="", + position=str(pos), + designation="", + name="Фольга медная толщиной 18 мкм", + quantity=str(foil_count), + note="", + ) + ) + + return _paginate(rows, 27, 33) + + +def generate_vedomost( + components: list[ComponentView], + where_used: str = "", + mappings: dict[str, tuple[str, str]] | None = None, + column_mappings: Optional[dict[str, str]] = None, +) -> list[dict[str, Any]]: + cmap = column_mappings or { + "Name": "Name", + "ProductCode": "ProductCode", + "DocumentCode": "DocumentCode", + "Supplier": "Supplier", + "Note": "Note", + } + rows: list[dict[str, Any]] = [] + fitted = [c for c in components if c.is_fitted] + groups = _group_by_letter(fitted) + + for letter, comps in groups.items(): + subgroups: OrderedDict[str, list[ComponentView]] = OrderedDict() + for c in comps: + key = c.get(cmap.get("Name", "Name"), "") + subgroups.setdefault(key, []).append(c) + + multiple_names = len(subgroups) > 1 + if len(comps) > 1 and multiple_names: + _, plural = get_type_names(comps[0].designator, mappings) + rows.append( + _empty_flags( + is_empty=True, + name="", + product_code="", + document_code="", + supplier="", + where_used="", + quantity_per_item="", + quantity_in_set="", + quantity_for_reg="", + total_quantity="", + note="", + ) + ) + rows.append( + _empty_flags( + is_header=True, + is_underline=True, + name=plural, + product_code="", + document_code="", + supplier="", + where_used="", + quantity_per_item="", + quantity_in_set="", + quantity_for_reg="", + total_quantity="", + note="", + ) + ) + rows.append( + _empty_flags( + is_empty=True, + name="", + product_code="", + document_code="", + supplier="", + where_used="", + quantity_per_item="", + quantity_in_set="", + quantity_for_reg="", + total_quantity="", + note="", + ) + ) + + for value, group in subgroups.items(): + singular, plural = get_type_names(group[0].designator, mappings) + qty = len(group) + type_name = "" + if len(comps) == 1 or (len(comps) > 1 and not multiple_names): + type_name = plural if qty > 1 else singular + + name = f"{type_name} {value}".strip() if type_name else value + product = group[0].get(cmap.get("ProductCode", "ProductCode"), "") + document = group[0].get(cmap.get("DocumentCode", "DocumentCode"), "") + supplier = group[0].get(cmap.get("Supplier", "Supplier"), "") + note = group[0].get(cmap.get("Note", "Note"), "") + + name_parts = split_long_text(name, 32) + product_parts = split_long_text(product, 25) + document_parts = split_long_text(document, 35) + supplier_parts = split_long_text(supplier, 25) + where_parts = split_long_text(where_used, 35) + note_parts = split_long_text(note, 16) + max_rows = max( + len(name_parts), + len(product_parts), + len(document_parts), + len(supplier_parts), + len(where_parts), + len(note_parts), + 1, + ) + + if len(comps) == 1 or (len(comps) > 1 and not multiple_names): + rows.append( + _empty_flags( + is_empty=True, + name="", + product_code="", + document_code="", + supplier="", + where_used="", + quantity_per_item="", + quantity_in_set="", + quantity_for_reg="", + total_quantity="", + note="", + ) + ) + + for i in range(max_rows): + rows.append( + _empty_flags( + name=name_parts[i] if i < len(name_parts) else "", + product_code=product_parts[i] if i < len(product_parts) else "", + document_code=document_parts[i] if i < len(document_parts) else "", + supplier=supplier_parts[i] if i < len(supplier_parts) else "", + where_used=where_parts[i] if i < len(where_parts) else "", + quantity_per_item=str(qty) if i == 0 else "", + quantity_in_set="", + quantity_for_reg="", + total_quantity=str(qty) if i == 0 else "", + note=note_parts[i] if i < len(note_parts) else "", + ) + ) + + if len(comps) == 1 or (len(comps) > 1 and not multiple_names): + rows.append( + _empty_flags( + is_empty=True, + name="", + product_code="", + document_code="", + supplier="", + where_used="", + quantity_per_item="", + quantity_in_set="", + quantity_for_reg="", + total_quantity="", + note="", + ) + ) + + return _paginate(rows, 24, 29) + + +def generate_simple_list( + components: list[ComponentView], + name_field: str = "Name", + tech_reserve_percent: float = 10.0, + boards_count: int = 1, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + fitted = sorted( + [c for c in components if c.is_fitted], + key=lambda c: designator_sort_key(c.designator), + ) + # group consecutive same name + groups: list[list[ComponentView]] = [] + current: list[ComponentView] = [] + current_name = None + for c in fitted: + name = c.get(name_field, "") + if current and name != current_name: + groups.append(current) + current = [] + current.append(c) + current_name = name + if current: + groups.append(current) + + for group in groups: + base = len(group) + reserve = max(1, round(base * boards_count * tech_reserve_percent / 100)) + qty = boards_count * base + reserve + desigs = format_designator_range([c.designator for c in group]) + rows.append( + { + "row_index": 0, + "designator": desigs, + "name": group[0].get(name_field, ""), + "quantity": str(qty), + "is_auto_generated": True, + "is_empty": False, + } + ) + + for i, row in enumerate(rows): + row["row_index"] = i + return rows + + +TABLE_TYPES = ("perechen", "specification_pcb", "specification", "vedomost", "simple_list") diff --git a/web/backend/app/services/table_service.py b/web/backend/app/services/table_service.py new file mode 100644 index 0000000..dee0583 --- /dev/null +++ b/web/backend/app/services/table_service.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import delete, select +from sqlalchemy.orm import Session, selectinload + +from app.models import ( + Component, + ComponentVariant, + DesignatorMapping, + PerechenRow, + Project, + SimpleListRow, + SpecificationPcbRow, + SpecificationRow, + TitleInscription, + Variant, + VariantProperty, + VedomostRow, +) +from app.services.table_generators import ( + TABLE_TYPES, + ComponentView, + MaterialView, + generate_perechen, + generate_simple_list, + generate_specification, + generate_specification_pcb, + generate_vedomost, +) + +ROW_MODELS = { + "perechen": PerechenRow, + "specification_pcb": SpecificationPcbRow, + "specification": SpecificationRow, + "vedomost": VedomostRow, + "simple_list": SimpleListRow, +} + +EDITABLE_FIELDS = { + "perechen": {"position", "designation", "quantity", "note", "is_header", "is_empty", "stretch", "is_underline"}, + "specification_pcb": { + "format", "zone", "position", "designation", "name", "quantity", "note", + "is_header", "is_empty", "stretch", "is_underline", + }, + "specification": { + "format", "zone", "position", "designation", "name", "quantity", "note", + "is_header", "is_empty", "stretch", "is_underline", + }, + "vedomost": { + "name", "product_code", "document_code", "supplier", "where_used", + "quantity_per_item", "quantity_in_set", "quantity_for_reg", "total_quantity", "note", + "is_header", "is_empty", "stretch", "is_underline", + }, + "simple_list": {"designator", "name", "quantity", "is_empty"}, +} + + +def _mappings_dict(db: Session, project_id: int) -> dict[str, tuple[str, str]]: + rows = db.scalars( + select(DesignatorMapping).where(DesignatorMapping.project_id == project_id) + ).all() + return {r.prefix: (r.singular_name, r.plural_name) for r in rows} + + +def load_components_for_variant(db: Session, project: Project) -> list[ComponentView]: + variant = db.scalar( + select(Variant).where( + Variant.project_id == project.id, + Variant.name == project.current_variant, + ) + ) + components = db.scalars( + select(Component) + .where(Component.project_id == project.id) + .options(selectinload(Component.properties)) + ).all() + + views: list[ComponentView] = [] + for comp in components: + props = {p.key: p.value for p in comp.properties} + is_fitted = True + if variant: + link = db.scalar( + select(ComponentVariant).where( + ComponentVariant.component_id == comp.id, + ComponentVariant.variant_id == variant.id, + ) + ) + if link is not None: + is_fitted = link.is_fitted + overrides = db.scalars( + select(VariantProperty).where( + VariantProperty.component_id == comp.id, + VariantProperty.variant_id == variant.id, + ) + ).all() + for o in overrides: + props[o.key] = o.value + views.append(ComponentView(designator=comp.designator, properties=props, is_fitted=is_fitted)) + return views + + +def row_to_dict(row: Any) -> dict[str, Any]: + data = {} + for col in row.__table__.columns: + data[col.name] = getattr(row, col.name) + return data + + +def get_rows(db: Session, project_id: int, table_type: str) -> list[dict[str, Any]]: + model = ROW_MODELS[table_type] + rows = db.scalars( + select(model).where(model.project_id == project_id).order_by(model.row_index) + ).all() + return [row_to_dict(r) for r in rows] + + +def replace_rows(db: Session, project_id: int, table_type: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + model = ROW_MODELS[table_type] + db.execute(delete(model).where(model.project_id == project_id)) + allowed = EDITABLE_FIELDS[table_type] | {"row_index", "page_number", "is_auto_generated"} + result = [] + for i, raw in enumerate(rows): + payload = {k: v for k, v in raw.items() if k in allowed and k != "id"} + payload["project_id"] = project_id + payload["row_index"] = payload.get("row_index", i) + obj = model(**payload) + db.add(obj) + result.append(obj) + db.commit() + return get_rows(db, project_id, table_type) + + +def patch_rows(db: Session, project_id: int, table_type: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Upsert by id when present; otherwise full replace if no ids.""" + if not rows: + return replace_rows(db, project_id, table_type, []) + if all("id" in r and r["id"] for r in rows): + model = ROW_MODELS[table_type] + allowed = EDITABLE_FIELDS[table_type] | {"row_index", "page_number", "is_auto_generated"} + for raw in rows: + obj = db.get(model, raw["id"]) + if not obj or obj.project_id != project_id: + continue + for k, v in raw.items(): + if k in allowed: + setattr(obj, k, v) + db.commit() + return get_rows(db, project_id, table_type) + return replace_rows(db, project_id, table_type, rows) + + +def generate_table( + db: Session, + project: Project, + table_type: str, + name_field: str | None = None, + tech_reserve_percent: float | None = None, + boards_count: int | None = None, +) -> list[dict[str, Any]]: + if table_type not in TABLE_TYPES: + raise ValueError(f"Unknown table type: {table_type}") + + mappings = _mappings_dict(db, project.id) + components = load_components_for_variant(db, project) + + if table_type == "perechen": + rows = generate_perechen(components, name_field=name_field or "Name", mappings=mappings) + elif table_type == "specification_pcb": + rows = generate_specification_pcb( + components, + decimal_number=project.decimal_number, + board_name=project.board_name, + pcb_doc_name=project.pcb_doc_name or "", + mappings=mappings, + ) + elif table_type == "specification": + materials: list[MaterialView] = [] + layer_count = 0 + if project.pcb_data: + layer_count = project.pcb_data.layer_count + for m in project.pcb_data.diel_materials: + materials.append( + MaterialView( + name=m.name, + value=m.value, + height=m.height, + diel_type=m.diel_type, + layer_number=m.layer_number, + ) + ) + rows = generate_specification(materials, layer_count=layer_count, decimal_number=project.decimal_number) + elif table_type == "vedomost": + inscriptions = { + i.field_number: i.field_value + for i in db.scalars( + select(TitleInscription).where(TitleInscription.project_id == project.id) + ).all() + } + where_used = inscriptions.get(101) or inscriptions.get(1001) or project.decimal_number + rows = generate_vedomost(components, where_used=where_used, mappings=mappings) + else: + rows = generate_simple_list( + components, + name_field=name_field or "Name", + tech_reserve_percent=tech_reserve_percent if tech_reserve_percent is not None else 10.0, + boards_count=boards_count if boards_count is not None else 1, + ) + + return replace_rows(db, project.id, table_type, rows) + + +def get_inscriptions(db: Session, project_id: int) -> dict[int, str]: + rows = db.scalars( + select(TitleInscription).where(TitleInscription.project_id == project_id) + ).all() + return {r.field_number: r.field_value for r in rows} + + +def set_inscriptions(db: Session, project_id: int, values: dict[int, str]) -> dict[int, str]: + existing = { + r.field_number: r + for r in db.scalars( + select(TitleInscription).where(TitleInscription.project_id == project_id) + ).all() + } + for num, val in values.items(): + num = int(num) + if num in existing: + existing[num].field_value = val + else: + db.add(TitleInscription(project_id=project_id, field_number=num, field_value=val)) + db.commit() + return get_inscriptions(db, project_id) + + +def apply_llm_edits( + db: Session, project_id: int, table_type: str, edits: list[dict[str, Any]] +) -> list[dict[str, Any]]: + model = ROW_MODELS[table_type] + allowed = EDITABLE_FIELDS[table_type] + for edit in edits: + op = edit.get("op", "update_row") + if op == "update_row": + obj = None + if edit.get("row_id"): + obj = db.get(model, edit["row_id"]) + elif edit.get("row_index") is not None: + obj = db.scalar( + select(model).where( + model.project_id == project_id, + model.row_index == edit["row_index"], + ) + ) + if not obj or obj.project_id != project_id: + continue + for k, v in (edit.get("fields") or {}).items(): + if k in allowed: + setattr(obj, k, v) + elif op == "delete_row": + obj = None + if edit.get("row_id"): + obj = db.get(model, edit["row_id"]) + if obj and obj.project_id == project_id: + db.delete(obj) + elif op == "add_row": + payload = {k: v for k, v in (edit.get("fields") or {}).items() if k in allowed} + payload["project_id"] = project_id + payload["row_index"] = edit.get("row_index", 9999) + payload["is_auto_generated"] = False + db.add(model(**payload)) + db.commit() + # reindex + rows = db.scalars( + select(model).where(model.project_id == project_id).order_by(model.row_index) + ).all() + for i, r in enumerate(rows): + r.row_index = i + db.commit() + return get_rows(db, project_id, table_type) diff --git a/web/backend/fonts/GOST_A.TTF b/web/backend/fonts/GOST_A.TTF new file mode 100644 index 0000000..658f0e1 Binary files /dev/null and b/web/backend/fonts/GOST_A.TTF differ diff --git a/web/backend/requirements.txt b/web/backend/requirements.txt new file mode 100644 index 0000000..9c5adb0 --- /dev/null +++ b/web/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +psycopg[binary]==3.2.3 +pydantic==2.10.4 +pydantic-settings==2.7.0 +python-multipart==0.0.20 +httpx[socks]==0.28.1 +socksio==1.0.0 +openpyxl==3.1.5 +reportlab==4.2.5 +alembic==1.14.0 diff --git a/web/docker-compose.yml b/web/docker-compose.yml new file mode 100644 index 0000000..8cc3155 --- /dev/null +++ b/web/docker-compose.yml @@ -0,0 +1,45 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-gost} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-gost} + POSTGRES_DB: ${POSTGRES_DB:-gost} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-gost}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + api: + build: ./backend + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-gost}:${POSTGRES_PASSWORD:-gost}@db:5432/${POSTGRES_DB:-gost} + API_TOKEN: ${API_TOKEN:-changeme} + DATA_DIR: /data + OPENROUTER_BASE_URL: ${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + OPENROUTER_MODEL: ${OPENROUTER_MODEL:-deepseek/deepseek-chat} + OPENROUTER_PROXY: ${OPENROUTER_PROXY:-} + LLM_MAX_CONTEXT_ROWS: ${LLM_MAX_CONTEXT_ROWS:-80} + CORS_ORIGINS: ${CORS_ORIGINS:-*} + volumes: + - ./data:/data + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + web: + build: ./frontend + ports: + - "${HTTP_PORT:-8080}:80" + depends_on: + - api + restart: unless-stopped + +volumes: + pgdata: diff --git a/web/frontend/Dockerfile b/web/frontend/Dockerfile new file mode 100644 index 0000000..2a96591 --- /dev/null +++ b/web/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/web/frontend/index.html b/web/frontend/index.html new file mode 100644 index 0000000..0ef8925 --- /dev/null +++ b/web/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + GostGenerator Web + + +
+ + + diff --git a/web/frontend/nginx.conf b/web/frontend/nginx.conf new file mode 100644 index 0000000..85dac63 --- /dev/null +++ b/web/frontend/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://api:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + client_max_body_size 512m; + proxy_read_timeout 300s; + } + + location /docs { + proxy_pass http://api:8000/docs; + } + + location /openapi.json { + proxy_pass http://api:8000/openapi.json; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 0000000..ec06645 --- /dev/null +++ b/web/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "gostgenerator-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.11" + } +} diff --git a/web/frontend/src/App.jsx b/web/frontend/src/App.jsx new file mode 100644 index 0000000..9c9d503 --- /dev/null +++ b/web/frontend/src/App.jsx @@ -0,0 +1,36 @@ +import { Link, Route, Routes } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { getToken, setToken } from "./api/client"; +import ProjectsPage from "./pages/ProjectsPage"; +import ProjectPage from "./pages/ProjectPage"; + +export default function App() { + const [token, setTokenState] = useState(getToken()); + + useEffect(() => { + setToken(token); + }, [token]); + + return ( +
+
+ + GostGenerator Web + +
+ + setTokenState(e.target.value)} + placeholder="Bearer token" + /> +
+
+ + } /> + } /> + +
+ ); +} diff --git a/web/frontend/src/api/client.js b/web/frontend/src/api/client.js new file mode 100644 index 0000000..0e379a0 --- /dev/null +++ b/web/frontend/src/api/client.js @@ -0,0 +1,78 @@ +const TOKEN_KEY = "gost_api_token"; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) || ""; +} + +export function setToken(token) { + localStorage.setItem(TOKEN_KEY, token || ""); +} + +async function request(path, options = {}) { + const headers = new Headers(options.headers || {}); + const token = getToken(); + if (token) headers.set("Authorization", `Bearer ${token}`); + if (options.json !== undefined) { + headers.set("Content-Type", "application/json"); + } + const res = await fetch(path, { + ...options, + headers, + body: options.json !== undefined ? JSON.stringify(options.json) : options.body, + }); + if (!res.ok) { + let detail = res.statusText; + try { + const data = await res.json(); + detail = data.detail || JSON.stringify(data); + } catch (_) { + /* ignore */ + } + throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); + } + if (res.status === 204) return null; + const ct = res.headers.get("content-type") || ""; + if (ct.includes("application/json")) return res.json(); + return res.blob(); +} + +export const api = { + listProjects: () => request("/api/projects"), + createProject: (name) => request("/api/projects", { method: "POST", json: { name } }), + getProject: (id) => request(`/api/projects/${id}`), + updateProject: (id, data) => request(`/api/projects/${id}`, { method: "PATCH", json: data }), + deleteProject: (id) => request(`/api/projects/${id}`, { method: "DELETE" }), + uploadZip: async (id, file, method = "POST", keepTables = true) => { + const fd = new FormData(); + fd.append("file", file); + const q = method === "PUT" ? `?keep_tables=${keepTables}` : ""; + return request(`/api/projects/${id}/upload${q}`, { method, body: fd }); + }, + getComponents: (id) => request(`/api/projects/${id}/components`), + getParams: (id) => request(`/api/projects/${id}/params`), + getPcb: (id) => request(`/api/projects/${id}/pcb`), + getInscriptions: (id) => request(`/api/projects/${id}/inscriptions`), + patchInscriptions: (id, inscriptions) => + request(`/api/projects/${id}/inscriptions`, { method: "PATCH", json: { inscriptions } }), + getTable: (id, type) => request(`/api/projects/${id}/tables/${type}`), + patchTable: (id, type, rows) => + request(`/api/projects/${id}/tables/${type}`, { method: "PATCH", json: { rows } }), + generateTable: (id, type, body = {}) => + request(`/api/projects/${id}/tables/${type}/generate`, { method: "POST", json: body }), + exportDoc: async (id, table_type, format) => { + const blob = await request(`/api/projects/${id}/export`, { + method: "POST", + json: { table_type, format }, + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `project_${id}_${table_type}.${format}`; + a.click(); + URL.revokeObjectURL(url); + }, + llmChat: (id, message, table_type) => + request(`/api/projects/${id}/llm/chat`, { method: "POST", json: { message, table_type } }), + llmApply: (id, table_type, edits) => + request(`/api/projects/${id}/llm/apply`, { method: "POST", json: { table_type, edits } }), +}; diff --git a/web/frontend/src/main.jsx b/web/frontend/src/main.jsx new file mode 100644 index 0000000..5a9b84a --- /dev/null +++ b/web/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); diff --git a/web/frontend/src/pages/ProjectPage.jsx b/web/frontend/src/pages/ProjectPage.jsx new file mode 100644 index 0000000..5c06bef --- /dev/null +++ b/web/frontend/src/pages/ProjectPage.jsx @@ -0,0 +1,474 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { api } from "../api/client"; + +const TABLE_TYPES = [ + { id: "perechen", label: "Перечень" }, + { id: "specification_pcb", label: "Спец. ПП" }, + { id: "specification", label: "Спецификация" }, + { id: "vedomost", label: "Ведомость" }, + { id: "simple_list", label: "Бланк заказа" }, +]; + +const COLUMNS = { + perechen: [ + ["position", "Поз."], + ["designation", "Наименование"], + ["quantity", "Кол."], + ["note", "Прим."], + ], + specification_pcb: [ + ["format", "Форм."], + ["zone", "Зона"], + ["position", "Поз."], + ["designation", "Обозн."], + ["name", "Наименование"], + ["quantity", "Кол."], + ["note", "Прим."], + ], + specification: [ + ["format", "Форм."], + ["zone", "Зона"], + ["position", "Поз."], + ["designation", "Обозн."], + ["name", "Наименование"], + ["quantity", "Кол."], + ["note", "Прим."], + ], + vedomost: [ + ["name", "Наименование"], + ["product_code", "Код"], + ["document_code", "Док."], + ["supplier", "Поставщик"], + ["where_used", "Куда"], + ["quantity_per_item", "На изд."], + ["total_quantity", "Всего"], + ["note", "Прим."], + ], + simple_list: [ + ["designator", "Поз."], + ["name", "Наименование"], + ["quantity", "Кол."], + ], +}; + +const FRAME_FIELDS = [ + [1, "Наименование изделия"], + [2, "Обозначение документа"], + [4, "Литера"], + [9, "Организация"], + [10, "Характер работы"], + [11, "Фамилии"], + [111, "Разработал"], + [112, "Проверил"], + [113, "Н. контроль"], + [114, "Утвердил"], + [251, "Перв. примен. (перечень)"], + [252, "Перв. примен. (спец. ПП)"], + [253, "Перв. примен. (спец.)"], + [254, "Перв. примен. (ведомость)"], + [301, "Наименование документа (ведомость)"], + [1001, "Децимальный № (спец.)"], + [1002, "Название документа (спец.)"], +]; + +export default function ProjectPage() { + const { id } = useParams(); + const projectId = Number(id); + const [project, setProject] = useState(null); + const [params, setParams] = useState([]); + const [pcb, setPcb] = useState({ layer_count: 0, materials: [] }); + const [tableType, setTableType] = useState("perechen"); + const [rows, setRows] = useState([]); + const [inscriptions, setInscriptions] = useState({}); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [chatInput, setChatInput] = useState(""); + const [chat, setChat] = useState([]); + const [pendingEdits, setPendingEdits] = useState(null); + const [tab, setTab] = useState("tables"); + + const loadProject = useCallback(async () => { + const [p, par, pcbInfo, insc] = await Promise.all([ + api.getProject(projectId), + api.getParams(projectId), + api.getPcb(projectId), + api.getInscriptions(projectId), + ]); + setProject(p); + setParams(par); + setPcb(pcbInfo); + setInscriptions(insc || {}); + }, [projectId]); + + const loadTable = useCallback(async () => { + const data = await api.getTable(projectId, tableType); + setRows(data.rows || []); + }, [projectId, tableType]); + + useEffect(() => { + (async () => { + try { + setError(""); + await loadProject(); + await loadTable(); + } catch (e) { + setError(e.message); + } + })(); + }, [loadProject, loadTable]); + + const columns = useMemo(() => COLUMNS[tableType] || [], [tableType]); + + async function onUpload(e, method = "POST") { + const file = e.target.files?.[0]; + if (!file) return; + setBusy(true); + setError(""); + try { + await api.uploadZip(projectId, file, method, true); + await loadProject(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + e.target.value = ""; + } + } + + async function saveMeta(patch) { + setProject(await api.updateProject(projectId, patch)); + } + + async function generate() { + setBusy(true); + setError(""); + try { + const data = await api.generateTable(projectId, tableType); + setRows(data.rows || []); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + async function saveRows() { + setBusy(true); + try { + const data = await api.patchTable(projectId, tableType, rows); + setRows(data.rows || []); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + function updateCell(idx, field, value) { + setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, [field]: value } : r))); + } + + async function saveInscriptions() { + setBusy(true); + try { + const data = await api.patchInscriptions(projectId, inscriptions); + setInscriptions(data); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + async function sendChat() { + if (!chatInput.trim()) return; + const message = chatInput.trim(); + setChatInput(""); + setChat((c) => [...c, { role: "user", content: message }]); + setBusy(true); + try { + const res = await api.llmChat(projectId, message, tableType); + setChat((c) => [...c, { role: "assistant", content: res.reply, edits: res.edits }]); + setPendingEdits(res.edits?.length ? res.edits : null); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + async function applyEdits() { + if (!pendingEdits?.length) return; + setBusy(true); + try { + const data = await api.llmApply(projectId, tableType, pendingEdits); + setRows(data.rows || []); + setPendingEdits(null); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + } + + if (!project) { + return
{error || "Загрузка…"}
; + } + + return ( +
+
+
+
+ ← К списку +

{project.name}

+
+ Статус: {project.status} + {project.error_message ? ` — ${project.error_message}` : ""} +
+
+
+ + +
+
+ +
+ + + + setProject({ ...project, decimal_number: e.target.value })} + onBlur={(e) => saveMeta({ decimal_number: e.target.value })} + /> + + setProject({ ...project, board_name: e.target.value })} + onBlur={(e) => saveMeta({ board_name: e.target.value })} + /> +
+ +
+ Компонентов: {project.component_count}, слоёв PCB: {project.layer_count} + {project.pcb_doc_name ? `, файл: ${project.pcb_doc_name}` : ""} +
+ {error &&
{error}
} +
+ +
+ + + +
+ + {tab === "info" && ( +
+
+

Параметры проекта

+
+ + + + + + + + + {params.map((p) => ( + + + + + ))} + +
ИмяЗначение
{p.name}{p.value}
+
+
+
+

PCB / диэлектрики

+

Слоёв: {pcb.layer_count}

+
+ + + + + + + + + + + {pcb.materials?.map((m, i) => ( + + + + + + + ))} + +
СлойТипМатериалмм
{m.layer_number}{m.diel_type === 1 ? "ядро" : "препрег"}{m.value}{m.height?.toFixed?.(3)}
+
+
+
+ )} + + {tab === "frame" && ( +
+

Поля основной надписи

+
+ {FRAME_FIELDS.map(([num, label]) => ( + + ))} +
+
+ +
+
+ )} + + {tab === "tables" && ( +
+
+
+ {TABLE_TYPES.map((t) => ( + + ))} +
+
+ + + + {tableType !== "simple_list" && ( + + )} +
+
+ + + + + {columns.map(([key, label]) => ( + + ))} + + + + {rows.map((row, idx) => ( + + + {columns.map(([key]) => ( + + ))} + + ))} + +
#{label}
{row.row_index ?? idx} + updateCell(idx, key, e.target.value)} + /> +
+
+
+ +
+

LLM-помощник

+

Команды вроде «добавь примечание к R1». Правки применяются только после подтверждения.

+
+ {chat.map((m, i) => ( +
+ {m.content} + {m.edits?.length ? ( +
+ Предложено правок: {m.edits.length} +
+ ) : null} +
+ ))} +
+ {pendingEdits?.length ? ( +
+ Ожидают применения: {pendingEdits.length} +
+                  {JSON.stringify(pendingEdits, null, 2)}
+                
+
+ + +
+
+ ) : null} +
+ setChatInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendChat()} + /> + +
+
+
+ )} +
+ ); +} diff --git a/web/frontend/src/pages/ProjectsPage.jsx b/web/frontend/src/pages/ProjectsPage.jsx new file mode 100644 index 0000000..313308f --- /dev/null +++ b/web/frontend/src/pages/ProjectsPage.jsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { api } from "../api/client"; + +export default function ProjectsPage() { + const [projects, setProjects] = useState([]); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + async function load() { + setLoading(true); + setError(""); + try { + setProjects(await api.listProjects()); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); + + async function create() { + if (!name.trim()) return; + try { + const p = await api.createProject(name.trim()); + setName(""); + window.location.href = `/projects/${p.id}`; + } catch (e) { + setError(e.message); + } + } + + async function remove(id) { + if (!confirm("Удалить проект?")) return; + try { + await api.deleteProject(id); + load(); + } catch (e) { + setError(e.message); + } + } + + return ( +
+
+

Проекты

+
+ setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && create()} + /> + + +
+ {error &&
{error}
} +
+ +
+ {loading ? ( +
Загрузка…
+ ) : projects.length === 0 ? ( +
Пока нет проектов
+ ) : ( +
+ + + + + + + + + + + + + {projects.map((p) => ( + + + + + + + + + ))} + +
IDНазваниеСтатусКомпонентыСлои
{p.id} + {p.name} + + {p.status} + {p.component_count}{p.layer_count} + +
+
+ )} +
+
+ ); +} diff --git a/web/frontend/src/styles.css b/web/frontend/src/styles.css new file mode 100644 index 0000000..15b6158 --- /dev/null +++ b/web/frontend/src/styles.css @@ -0,0 +1,95 @@ +:root { + --bg: #f3f0e8; + --panel: #fffdf8; + --ink: #1c2420; + --muted: #5c6a63; + --line: #cfc6b4; + --accent: #1f6f5b; + --accent-2: #c45c26; + --danger: #9b2c2c; + --ok: #1f6f5b; + font-family: "Segoe UI", "IBM Plex Sans", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at 10% 10%, #e8efe8 0, transparent 40%), + radial-gradient(circle at 90% 0%, #f7e7d6 0, transparent 35%), + var(--bg); +} + +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; } +a { color: var(--accent); text-decoration: none; } +button, input, select, textarea { + font: inherit; +} +button { + background: var(--accent); + color: white; + border: none; + border-radius: 6px; + padding: 0.45rem 0.85rem; + cursor: pointer; +} +button.secondary { background: #4a5a53; } +button.danger { background: var(--danger); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +input, select, textarea { + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.45rem 0.6rem; + background: white; +} +.layout { max-width: 1400px; margin: 0 auto; padding: 1.25rem; } +.topbar { + display: flex; justify-content: space-between; align-items: center; + gap: 1rem; margin-bottom: 1.25rem; +} +.brand { font-size: 1.4rem; font-weight: 700; letter-spacing: 0.02em; } +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 1rem; + box-shadow: 0 8px 24px rgba(40, 30, 10, 0.04); +} +.grid { display: grid; gap: 1rem; } +.grid.two { grid-template-columns: 1.4fr 1fr; } +.muted { color: var(--muted); } +.table-wrap { overflow: auto; max-height: 60vh; border: 1px solid var(--line); border-radius: 8px; } +table.data { + width: 100%; border-collapse: collapse; font-size: 0.85rem; background: white; +} +table.data th, table.data td { + border-bottom: 1px solid #ebe4d6; padding: 0.25rem 0.35rem; vertical-align: top; +} +table.data th { position: sticky; top: 0; background: #efe8da; z-index: 1; } +table.data input { + width: 100%; border: 1px solid transparent; background: transparent; padding: 0.2rem; +} +table.data input:focus { border-color: var(--accent); background: #fff; } +table.data tr.header-row td { font-weight: 700; text-decoration: underline; } +.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-bottom: 0.75rem; } +.tab { + background: #e7e0d2; color: var(--ink); border-radius: 999px; padding: 0.35rem 0.8rem; +} +.tab.active { background: var(--accent); color: white; } +.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } +.stack { display: flex; flex-direction: column; gap: 0.6rem; } +.chat { + display: flex; flex-direction: column; gap: 0.5rem; max-height: 50vh; overflow: auto; + border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem; background: #fff; +} +.bubble { padding: 0.5rem 0.65rem; border-radius: 8px; white-space: pre-wrap; } +.bubble.user { background: #e5f0ec; align-self: flex-end; } +.bubble.assistant { background: #f4eee4; align-self: flex-start; } +.edits { font-size: 0.8rem; background: #fff7ea; border: 1px dashed var(--accent-2); padding: 0.5rem; border-radius: 6px; } +.status { + display: inline-block; padding: 0.1rem 0.45rem; border-radius: 999px; + background: #e7e0d2; font-size: 0.75rem; +} +.status.ready { background: #d7efe4; } +.status.error { background: #f5d5d5; } +.error { color: var(--danger); } +@media (max-width: 960px) { + .grid.two { grid-template-columns: 1fr; } +} diff --git a/web/frontend/vite.config.js b/web/frontend/vite.config.js new file mode 100644 index 0000000..e5c66de --- /dev/null +++ b/web/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": "http://127.0.0.1:8000", + }, + }, +});