added web service

This commit is contained in:
2026-09-02 12:48:03 +03:00
parent 84b7eceb9f
commit 02c43072c4
60 changed files with 6148 additions and 22 deletions
+4
View File
@@ -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
+4
View File
@@ -13,3 +13,7 @@ IDI_ICON1 ICON DISCARDABLE "assets/icon2.ico"
+11
View File
@@ -3,6 +3,7 @@
#include "../model/specificationtablemodel.h"
#include "../model/perechentablemodel.h"
#include "../model/vedomosttablemodel.h"
#include "../model/simplelisttablemodel.h"
#include <QFile>
#include <QTextStream>
#include <QTextCodec>
@@ -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) {
+9
View File
@@ -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 Путь к файлу для сохранения
+154
View File
@@ -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()) {
+4
View File
@@ -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<ComponentData> getComponentsForVariant(int variantId);
QList<ComponentData> getComponentsForVariant(const QString &variantName);
+77
View File
@@ -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;
+11
View File
@@ -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;
@@ -99,6 +103,10 @@ public:
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);
bool parseFileAsync(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;
+7 -5
View File
@@ -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, "Ведомость покупных изделий");
+648
View File
@@ -0,0 +1,648 @@
#include "simplelisttablecontroller.h"
#include "../model/componentmodel.h"
#include "../model/specificationpcbtablemodel.h"
#include <QDebug>
#include <QCollator>
#include <QRegExp>
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<ComponentModel*> &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<QString, QString> partsA = splitDesignator(designatorA);
QPair<QString, QString> 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<SpecificationPCBRowData> 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<SpecificationPCBRowData> 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<QString, QList<SpecificationPCBRowData>> 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<SpecificationPCBRowData>();
positionOrder.append(position); // Сохраняем порядок
}
groupedByPosition[position].append(row);
}
}
qDebug() << "SimpleListTableController::generateTableFromSpecificationPCB: Сгруппировано позиций:" << groupedByPosition.size();
// Обрабатываем каждую группу позиций в порядке появления
for (const QString &position : positionOrder) {
if (!groupedByPosition.contains(position)) {
continue;
}
QList<SpecificationPCBRowData> 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<QString, QString> 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<QString, QString> 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<QString, QString> 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<QString, QString> 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<QString, QString> SimpleListTableController::splitDesignator(const QString &designator)
{
if (designator.isEmpty()) {
return QPair<QString, QString>("", "");
}
// Используем регулярное выражение для разделения буквенной и цифровой части
QRegExp regex("([A-Za-z]+)(\\d*)");
if (regex.indexIn(designator) != -1) {
QString letterPart = regex.cap(1);
QString numberPart = regex.cap(2);
return QPair<QString, QString>(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<QString, QString>(letterPart, numberPart);
}
}
// Если цифр нет, возвращаем весь десигнатор как буквенную часть
return QPair<QString, QString>(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;
}
+65
View File
@@ -0,0 +1,65 @@
#ifndef SIMPLELISTTABLECONTROLLER_H
#define SIMPLELISTTABLECONTROLLER_H
#include <QObject>
#include <QList>
#include <QStringList>
#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<ComponentModel*> &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<QString, QString> splitDesignator(const QString &designator);
SimpleListTableModel *m_tableModel;
QList<ComponentModel*> m_components;
QStringList m_propertyNames;
QString m_nameField; // Поле для наименования компонента (по умолчанию "Name")
int m_techReservePercent; // Процент тех запаса (по умолчанию 10)
int m_boardsCount; // Кол-во плат (по умолчанию 1)
};
#endif // SIMPLELISTTABLECONTROLLER_H
+378
View File
@@ -0,0 +1,378 @@
#include "simplelisttablemodel.h"
#include "componentmodel.h"
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
// Регистрируем структуры для работы с 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<int>() << 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<ComponentModel*> &components)
{
qDebug() << "SimpleListTableModel::setComponents: Получено" << components.size() << "компонентов";
// Проверяем компоненты на null
QList<ComponentModel*> 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;
}
+98
View File
@@ -0,0 +1,98 @@
#ifndef SIMPLELISTTABLEMODEL_H
#define SIMPLELISTTABLEMODEL_H
#include <QAbstractTableModel>
#include <QObject>
#include <QString>
#include <QList>
#include <QMap>
#include <QSet>
#include <QPair>
// 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<ComponentModel*> &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<SimpleListRowData> m_rows;
QList<ComponentModel*> m_components; // Компоненты для генерации
int m_techReservePercent; // Процент тех запаса
static const int COLUMN_COUNT = 3;
static const QStringList DEFAULT_HEADERS;
};
#endif // SIMPLELISTTABLEMODEL_H
+5
View File
@@ -1003,6 +1003,11 @@ QString SpecificationPCBTableModel::getDocumentType() const
return "SpecificationPCB";
}
QList<SpecificationPCBRowData> SpecificationPCBTableModel::getRows() const
{
return m_rows;
}
bool SpecificationPCBTableModel::isRowHeader(int row) const
{
if (row < 0 || row >= m_rows.size()) {
+3
View File
@@ -111,6 +111,9 @@ public:
QString getDocumentTitle() const;
QString getDocumentType() const;
// Метод для получения строк (для использования в SimpleList)
QList<SpecificationPCBRowData> getRows() const;
// Методы для проверки статуса строки
bool isRowHeader(int row) const;
+165 -3
View File
@@ -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<ComponentModel*> &components)
{
qDebug() << "SimpleListProcessor::processComponents: Получено" << components.size() << "компонентов";
// Передаем компоненты в контроллер
if (m_controller) {
m_controller->setComponents(components);
qDebug() << "SimpleListProcessor::processComponents: Компоненты переданы в контроллер";
}
}
void SimpleListProcessor::processProjectParams(const QMap<QString, QString> &params)
{
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<VedomostProcessor*>(m_processor)) {
docType = DocumentType::Vedomost;
} else if (qobject_cast<SimpleListProcessor*>(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<SimpleListProcessor*>(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<SimpleListProcessor*>(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)
+29
View File
@@ -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<ComponentModel*> &components) override;
void processProjectParams(const QMap<QString, QString> &params) 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
{
+171
View File
@@ -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 <QDebug>
#include <QMessageBox>
@@ -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<int>(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<QString, QString>
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<QGridLayout*>(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 сохранены";
}
+12 -1
View File
@@ -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
+20
View File
@@ -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()
+2
View File
@@ -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;
+101 -12
View File
@@ -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<int, QString> 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(){}
+1
View File
@@ -93,6 +93,7 @@ private slots:
void onExportPerechenToCsv();
void onExportPurchaseListToCsv();
void onExportSpecificationPcbToCsv();
void onExportSimpleListToCsv();
void onUndo();
void onRedo();
+22
View File
@@ -0,0 +1,22 @@
# HTTP
HTTP_PORT=8080
# PostgreSQL
POSTGRES_USER=gost
POSTGRES_PASSWORD=gost
POSTGRES_DB=gost
# API auth (send as Authorization: Bearer <token> 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=*
+7
View File
@@ -0,0 +1,7 @@
data/
**/__pycache__/
**/.venv/
**/node_modules/
**/dist/
.env
*.pyc
+84
View File
@@ -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://<server>: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://<host>:8080/docs` (через nginx) или `:8000/docs` напрямую к api.
Основные эндпоинты: `/api/projects`, `…/upload`, `…/tables/{type}/generate`, `…/inscriptions`, `…/export`, `…/llm/chat`, `…/llm/apply`.
Заголовок: `Authorization: Bearer <API_TOKEN>` или `X-API-Token`.
## LLM и стоимость
Вызовы только по явной команде в чате. Автозаполнения всех строк нет. В промпт уходит сжатый снимок таблицы (лимит `LLM_MAX_CONTEXT_ROWS`). Правки не применяются автоматически.
+21
View File
@@ -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"]
View File
View File
+368
View File
@@ -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)
View File
+30
View File
@@ -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)
+33
View File
@@ -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()
+23
View File
@@ -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()
+35
View File
@@ -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"}
+318
View File
@@ -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")
+193
View File
@@ -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
+359
View File
@@ -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
+178
View File
@@ -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)
+73
View File
@@ -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()
+124
View File
@@ -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}
+182
View File
@@ -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()
+268
View File
@@ -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),
)
)
@@ -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")
+282
View File
@@ -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)
Binary file not shown.
+12
View File
@@ -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
+45
View File
@@ -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:
+11
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GostGenerator Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+28
View File
@@ -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;
}
}
+22
View File
@@ -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"
}
}
+36
View File
@@ -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 (
<div className="layout">
<header className="topbar">
<Link to="/" className="brand">
GostGenerator Web
</Link>
<div className="row">
<label className="muted">API token</label>
<input
style={{ minWidth: 180 }}
value={token}
onChange={(e) => setTokenState(e.target.value)}
placeholder="Bearer token"
/>
</div>
</header>
<Routes>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:id" element={<ProjectPage />} />
</Routes>
</div>
);
}
+78
View File
@@ -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 } }),
};
+13
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+474
View File
@@ -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 <div className="card">{error || "Загрузка…"}</div>;
}
return (
<div className="stack">
<div className="card stack">
<div className="row" style={{ justifyContent: "space-between" }}>
<div>
<Link to="/"> К списку</Link>
<h2 style={{ margin: "0.4rem 0 0" }}>{project.name}</h2>
<div className="muted">
Статус: <span className={`status ${project.status}`}>{project.status}</span>
{project.error_message ? `${project.error_message}` : ""}
</div>
</div>
<div className="row">
<label className="secondary" style={{ background: "#4a5a53", color: "#fff", padding: "0.45rem 0.85rem", borderRadius: 6, cursor: "pointer" }}>
Загрузить ZIP
<input type="file" accept=".zip" hidden onChange={(e) => onUpload(e, "POST")} />
</label>
<label className="secondary" style={{ background: "#4a5a53", color: "#fff", padding: "0.45rem 0.85rem", borderRadius: 6, cursor: "pointer" }}>
Обновить ZIP
<input type="file" accept=".zip" hidden onChange={(e) => onUpload(e, "PUT")} />
</label>
</div>
</div>
<div className="row">
<label>Вариант</label>
<select
value={project.current_variant}
onChange={(e) => saveMeta({ current_variant: e.target.value })}
>
{(project.variants?.length ? project.variants : ["No Variations"]).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
<label>Децимальный </label>
<input
value={project.decimal_number || ""}
onChange={(e) => setProject({ ...project, decimal_number: e.target.value })}
onBlur={(e) => saveMeta({ decimal_number: e.target.value })}
/>
<label>Название платы</label>
<input
value={project.board_name || ""}
onChange={(e) => setProject({ ...project, board_name: e.target.value })}
onBlur={(e) => saveMeta({ board_name: e.target.value })}
/>
</div>
<div className="muted">
Компонентов: {project.component_count}, слоёв PCB: {project.layer_count}
{project.pcb_doc_name ? `, файл: ${project.pcb_doc_name}` : ""}
</div>
{error && <div className="error">{error}</div>}
</div>
<div className="tabs">
<button className={`tab ${tab === "tables" ? "active" : ""}`} onClick={() => setTab("tables")}>
Таблицы
</button>
<button className={`tab ${tab === "frame" ? "active" : ""}`} onClick={() => setTab("frame")}>
Рамка
</button>
<button className={`tab ${tab === "info" ? "active" : ""}`} onClick={() => setTab("info")}>
Инфо проекта
</button>
</div>
{tab === "info" && (
<div className="grid two">
<div className="card">
<h3>Параметры проекта</h3>
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Имя</th>
<th>Значение</th>
</tr>
</thead>
<tbody>
{params.map((p) => (
<tr key={p.id}>
<td>{p.name}</td>
<td>{p.value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<h3>PCB / диэлектрики</h3>
<p className="muted">Слоёв: {pcb.layer_count}</p>
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>Слой</th>
<th>Тип</th>
<th>Материал</th>
<th>мм</th>
</tr>
</thead>
<tbody>
{pcb.materials?.map((m, i) => (
<tr key={i}>
<td>{m.layer_number}</td>
<td>{m.diel_type === 1 ? "ядро" : "препрег"}</td>
<td>{m.value}</td>
<td>{m.height?.toFixed?.(3)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{tab === "frame" && (
<div className="card stack">
<h3>Поля основной надписи</h3>
<div className="grid" style={{ gridTemplateColumns: "repeat(auto-fill,minmax(280px,1fr))" }}>
{FRAME_FIELDS.map(([num, label]) => (
<label key={num} className="stack">
<span className="muted">
{num}. {label}
</span>
<input
value={inscriptions[num] || inscriptions[String(num)] || ""}
onChange={(e) =>
setInscriptions((prev) => ({ ...prev, [num]: e.target.value }))
}
/>
</label>
))}
</div>
<div>
<button disabled={busy} onClick={saveInscriptions}>
Сохранить рамку
</button>
</div>
</div>
)}
{tab === "tables" && (
<div className="grid two">
<div className="card stack">
<div className="tabs">
{TABLE_TYPES.map((t) => (
<button
key={t.id}
className={`tab ${tableType === t.id ? "active" : ""}`}
onClick={() => setTableType(t.id)}
>
{t.label}
</button>
))}
</div>
<div className="row">
<button disabled={busy} onClick={generate}>
Сгенерировать
</button>
<button disabled={busy} className="secondary" onClick={saveRows}>
Сохранить строки
</button>
<button disabled={busy} onClick={() => api.exportDoc(projectId, tableType, "xlsx")}>
Excel
</button>
{tableType !== "simple_list" && (
<button disabled={busy} onClick={() => api.exportDoc(projectId, tableType, "pdf")}>
PDF
</button>
)}
</div>
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>#</th>
{columns.map(([key, label]) => (
<th key={key}>{label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, idx) => (
<tr key={row.id || idx} className={row.is_header ? "header-row" : ""}>
<td className="muted">{row.row_index ?? idx}</td>
{columns.map(([key]) => (
<td key={key}>
<input
value={row[key] ?? ""}
disabled={row.is_empty}
onChange={(e) => updateCell(idx, key, e.target.value)}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card stack">
<h3>LLM-помощник</h3>
<p className="muted">Команды вроде «добавь примечание к R1». Правки применяются только после подтверждения.</p>
<div className="chat">
{chat.map((m, i) => (
<div key={i} className={`bubble ${m.role}`}>
{m.content}
{m.edits?.length ? (
<div className="edits" style={{ marginTop: 6 }}>
Предложено правок: {m.edits.length}
</div>
) : null}
</div>
))}
</div>
{pendingEdits?.length ? (
<div className="edits stack">
<strong>Ожидают применения: {pendingEdits.length}</strong>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{JSON.stringify(pendingEdits, null, 2)}
</pre>
<div className="row">
<button disabled={busy} onClick={applyEdits}>
Применить
</button>
<button className="secondary" onClick={() => setPendingEdits(null)}>
Отклонить
</button>
</div>
</div>
) : null}
<div className="row">
<input
style={{ flex: 1 }}
value={chatInput}
placeholder="Сообщение…"
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendChat()}
/>
<button disabled={busy} onClick={sendChat}>
Отправить
</button>
</div>
</div>
</div>
)}
</div>
);
}
+111
View File
@@ -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 (
<div className="stack">
<div className="card stack">
<h2 style={{ margin: 0 }}>Проекты</h2>
<div className="row">
<input
placeholder="Название проекта"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
/>
<button onClick={create}>Создать</button>
<button className="secondary" onClick={load}>
Обновить
</button>
</div>
{error && <div className="error">{error}</div>}
</div>
<div className="card">
{loading ? (
<div className="muted">Загрузка</div>
) : projects.length === 0 ? (
<div className="muted">Пока нет проектов</div>
) : (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Статус</th>
<th>Компоненты</th>
<th>Слои</th>
<th></th>
</tr>
</thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}>
<td>{p.id}</td>
<td>
<Link to={`/projects/${p.id}`}>{p.name}</Link>
</td>
<td>
<span className={`status ${p.status}`}>{p.status}</span>
</td>
<td>{p.component_count}</td>
<td>{p.layer_count}</td>
<td>
<button className="danger" onClick={() => remove(p.id)}>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+95
View File
@@ -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; }
}
+12
View File
@@ -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",
},
},
});