added web service
This commit is contained in:
@@ -32,6 +32,7 @@ set(SOURCES
|
|||||||
model/specificationpcbtablemodel.cpp
|
model/specificationpcbtablemodel.cpp
|
||||||
model/specificationtablemodel.cpp
|
model/specificationtablemodel.cpp
|
||||||
model/vedomosttablemodel.cpp
|
model/vedomosttablemodel.cpp
|
||||||
|
model/simplelisttablemodel.cpp
|
||||||
model/pcbmaterialmodel.cpp
|
model/pcbmaterialmodel.cpp
|
||||||
view/adddesignatordialog.cpp
|
view/adddesignatordialog.cpp
|
||||||
view/basegosttable.cpp
|
view/basegosttable.cpp
|
||||||
@@ -45,6 +46,7 @@ set(SOURCES
|
|||||||
controller/specificationpcbcontroller.cpp
|
controller/specificationpcbcontroller.cpp
|
||||||
controller/specificationcontroller.cpp
|
controller/specificationcontroller.cpp
|
||||||
controller/vedomostcontroller.cpp
|
controller/vedomostcontroller.cpp
|
||||||
|
controller/simplelisttablecontroller.cpp
|
||||||
controller/csvexporter.cpp
|
controller/csvexporter.cpp
|
||||||
view/editpdfwidget.cpp
|
view/editpdfwidget.cpp
|
||||||
view/importeddataview.cpp
|
view/importeddataview.cpp
|
||||||
@@ -76,6 +78,7 @@ set(HEADERS
|
|||||||
model/specificationpcbtablemodel.h
|
model/specificationpcbtablemodel.h
|
||||||
model/specificationtablemodel.h
|
model/specificationtablemodel.h
|
||||||
model/vedomosttablemodel.h
|
model/vedomosttablemodel.h
|
||||||
|
model/simplelisttablemodel.h
|
||||||
model/pcbmaterialmodel.h
|
model/pcbmaterialmodel.h
|
||||||
view/adddesignatordialog.h
|
view/adddesignatordialog.h
|
||||||
view/basegosttable.h
|
view/basegosttable.h
|
||||||
@@ -89,6 +92,7 @@ set(HEADERS
|
|||||||
controller/specificationpcbcontroller.h
|
controller/specificationpcbcontroller.h
|
||||||
controller/specificationcontroller.h
|
controller/specificationcontroller.h
|
||||||
controller/vedomostcontroller.h
|
controller/vedomostcontroller.h
|
||||||
|
controller/simplelisttablecontroller.h
|
||||||
controller/csvexporter.h
|
controller/csvexporter.h
|
||||||
view/editpdfwidget.h
|
view/editpdfwidget.h
|
||||||
view/importeddataview.h
|
view/importeddataview.h
|
||||||
|
|||||||
@@ -13,3 +13,7 @@ IDI_ICON1 ICON DISCARDABLE "assets/icon2.ico"
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "../model/specificationtablemodel.h"
|
#include "../model/specificationtablemodel.h"
|
||||||
#include "../model/perechentablemodel.h"
|
#include "../model/perechentablemodel.h"
|
||||||
#include "../model/vedomosttablemodel.h"
|
#include "../model/vedomosttablemodel.h"
|
||||||
|
#include "../model/simplelisttablemodel.h"
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QTextStream>
|
#include <QTextStream>
|
||||||
#include <QTextCodec>
|
#include <QTextCodec>
|
||||||
@@ -54,6 +55,16 @@ bool CSVExporter::exportVedomost(const QString &filePath, VedomostTableModel *mo
|
|||||||
return exportTableModel(filePath, model, true);
|
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)
|
bool CSVExporter::exportTableModel(const QString &filePath, QAbstractTableModel *model, bool includeHeaders)
|
||||||
{
|
{
|
||||||
if (!model) {
|
if (!model) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class SpecificationPCBTableModel;
|
|||||||
class SpecificationTableModel;
|
class SpecificationTableModel;
|
||||||
class PerechenTableModel;
|
class PerechenTableModel;
|
||||||
class VedomostTableModel;
|
class VedomostTableModel;
|
||||||
|
class SimpleListTableModel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Класс для экспорта таблиц в формат CSV
|
* @brief Класс для экспорта таблиц в формат CSV
|
||||||
@@ -58,6 +59,14 @@ public:
|
|||||||
*/
|
*/
|
||||||
bool exportVedomost(const QString &filePath, VedomostTableModel *model);
|
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
|
* @brief Экспортирует любую модель таблицы в CSV
|
||||||
* @param filePath Путь к файлу для сохранения
|
* @param filePath Путь к файлу для сохранения
|
||||||
|
|||||||
@@ -215,6 +215,20 @@ bool DataBaseController::createTables()
|
|||||||
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP,"
|
" created_at DATETIME DEFAULT CURRENT_TIMESTAMP,"
|
||||||
" updated_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 ("
|
"CREATE TABLE IF NOT EXISTS pcb_data ("
|
||||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||||
@@ -2789,6 +2803,146 @@ bool DataBaseController::saveVedomostTable(const QByteArray &tableData, const QS
|
|||||||
return true;
|
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)
|
QByteArray DataBaseController::loadVedomostTable(const QString &projectName)
|
||||||
{
|
{
|
||||||
if (!m_database.isOpen()) {
|
if (!m_database.isOpen()) {
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ public:
|
|||||||
// Метод для получения информации о структуре ведомости покупных изделий в БД
|
// Метод для получения информации о структуре ведомости покупных изделий в БД
|
||||||
QString getVedomostTableInfo(const QString &projectName = QString());
|
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(int variantId);
|
||||||
QList<ComponentData> getComponentsForVariant(const QString &variantName);
|
QList<ComponentData> getComponentsForVariant(const QString &variantName);
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
#include "../model/specificationpcbtablemodel.h"
|
#include "../model/specificationpcbtablemodel.h"
|
||||||
#include "../model/specificationtablemodel.h"
|
#include "../model/specificationtablemodel.h"
|
||||||
#include "../model/vedomosttablemodel.h"
|
#include "../model/vedomosttablemodel.h"
|
||||||
|
#include "../model/simplelisttablemodel.h"
|
||||||
#include "../model/titleinscriptionsmodel.h"
|
#include "../model/titleinscriptionsmodel.h"
|
||||||
#include "../model/pcbmaterialmodel.h"
|
#include "../model/pcbmaterialmodel.h"
|
||||||
#include "perechentablecontroller.h"
|
#include "perechentablecontroller.h"
|
||||||
|
#include "simplelisttablecontroller.h"
|
||||||
#include "specificationpcbcontroller.h"
|
#include "specificationpcbcontroller.h"
|
||||||
#include "specificationcontroller.h"
|
#include "specificationcontroller.h"
|
||||||
#include "vedomostcontroller.h"
|
#include "vedomostcontroller.h"
|
||||||
@@ -37,6 +39,8 @@ MainController::MainController(QObject *parent)
|
|||||||
, m_specificationController(new SpecificationController(this))
|
, m_specificationController(new SpecificationController(this))
|
||||||
, m_vedomostTableModel(new VedomostTableModel(this))
|
, m_vedomostTableModel(new VedomostTableModel(this))
|
||||||
, m_vedomostController(new VedomostController(this))
|
, m_vedomostController(new VedomostController(this))
|
||||||
|
, m_simpleListTableModel(new SimpleListTableModel(this))
|
||||||
|
, m_simpleListTableController(new SimpleListTableController(this))
|
||||||
, m_titleInscriptionsModel(new TitleInscriptionsModel(this))
|
, m_titleInscriptionsModel(new TitleInscriptionsModel(this))
|
||||||
, m_pcbMaterialModel(new PCBMaterialModel(this))
|
, m_pcbMaterialModel(new PCBMaterialModel(this))
|
||||||
, m_databaseController(new DataBaseController(this))
|
, m_databaseController(new DataBaseController(this))
|
||||||
@@ -94,6 +98,9 @@ MainController::MainController(QObject *parent)
|
|||||||
// Устанавливаем модель ведомости в контроллер
|
// Устанавливаем модель ведомости в контроллер
|
||||||
m_vedomostController->setModel(m_vedomostTableModel);
|
m_vedomostController->setModel(m_vedomostTableModel);
|
||||||
|
|
||||||
|
// Устанавливаем модель SimpleList в контроллер
|
||||||
|
m_simpleListTableController->setModel(m_simpleListTableModel);
|
||||||
|
|
||||||
connect(m_databaseController, &DataBaseController::databaseError,
|
connect(m_databaseController, &DataBaseController::databaseError,
|
||||||
this, &MainController::databaseError);
|
this, &MainController::databaseError);
|
||||||
|
|
||||||
@@ -165,6 +172,14 @@ VedomostController* MainController::vedomostController() const {
|
|||||||
return m_vedomostController;
|
return m_vedomostController;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SimpleListTableModel* MainController::simpleListTableModel() const {
|
||||||
|
return m_simpleListTableModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
SimpleListTableController* MainController::simpleListTableController() const {
|
||||||
|
return m_simpleListTableController;
|
||||||
|
}
|
||||||
|
|
||||||
TitleInscriptionsModel* MainController::titleInscriptionsModel() const {
|
TitleInscriptionsModel* MainController::titleInscriptionsModel() const {
|
||||||
return m_titleInscriptionsModel;
|
return m_titleInscriptionsModel;
|
||||||
}
|
}
|
||||||
@@ -439,6 +454,16 @@ bool MainController::loadProjectSettingsFromDatabase() {
|
|||||||
m_projectSettingsModel->setFontStretch(docType, stretch);
|
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);
|
m_projectSettingsModel->setModified(false);
|
||||||
|
|
||||||
// Устанавливаем текущий вариант в контроллере
|
// Устанавливаем текущий вариант в контроллере
|
||||||
@@ -489,6 +514,13 @@ bool MainController::saveProjectSettingsToDatabase() {
|
|||||||
success &= m_databaseController->setProjectSetting(stretchKey, QString::number(stretch));
|
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()) {
|
if (m_designatorMappingModel->isModified()) {
|
||||||
m_designatorMappingModel->saveToDatabase();
|
m_designatorMappingModel->saveToDatabase();
|
||||||
@@ -943,6 +975,51 @@ bool MainController::exportVedomostToCsv(const QString &filePath)
|
|||||||
return m_csvExporter->exportVedomost(filePath, m_vedomostTableModel);
|
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
|
PDFController* MainController::pdfController() const
|
||||||
{
|
{
|
||||||
return m_pdfController;
|
return m_pdfController;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class SpecificationTableModel;
|
|||||||
class SpecificationController;
|
class SpecificationController;
|
||||||
class VedomostTableModel;
|
class VedomostTableModel;
|
||||||
class VedomostController;
|
class VedomostController;
|
||||||
|
class SimpleListTableModel;
|
||||||
|
class SimpleListTableController;
|
||||||
class TitleInscriptionsModel;
|
class TitleInscriptionsModel;
|
||||||
class PCBMaterialModel;
|
class PCBMaterialModel;
|
||||||
class AltiumParser;
|
class AltiumParser;
|
||||||
@@ -45,6 +47,8 @@ public:
|
|||||||
SpecificationController* specificationController() const;
|
SpecificationController* specificationController() const;
|
||||||
VedomostTableModel* vedomostTableModel() const;
|
VedomostTableModel* vedomostTableModel() const;
|
||||||
VedomostController* vedomostController() const;
|
VedomostController* vedomostController() const;
|
||||||
|
SimpleListTableModel* simpleListTableModel() const;
|
||||||
|
SimpleListTableController* simpleListTableController() const;
|
||||||
TitleInscriptionsModel* titleInscriptionsModel() const;
|
TitleInscriptionsModel* titleInscriptionsModel() const;
|
||||||
PCBMaterialModel* pcbMaterialModel() const;
|
PCBMaterialModel* pcbMaterialModel() const;
|
||||||
|
|
||||||
@@ -98,6 +102,10 @@ public:
|
|||||||
bool saveVedomostTableToDatabase(const QByteArray &tableData, const QString &projectName = QString());
|
bool saveVedomostTableToDatabase(const QByteArray &tableData, const QString &projectName = QString());
|
||||||
QByteArray loadVedomostTableFromDatabase(const QString &projectName = QString());
|
QByteArray loadVedomostTableFromDatabase(const QString &projectName = QString());
|
||||||
QString getVedomostTableInfo(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 parseFile(const QString &filePath);
|
||||||
@@ -124,6 +132,7 @@ public:
|
|||||||
bool exportSpecificationPcbToCsv(const QString &filePath);
|
bool exportSpecificationPcbToCsv(const QString &filePath);
|
||||||
bool exportSpecificationToCsv(const QString &filePath);
|
bool exportSpecificationToCsv(const QString &filePath);
|
||||||
bool exportVedomostToCsv(const QString &filePath);
|
bool exportVedomostToCsv(const QString &filePath);
|
||||||
|
bool exportSimpleListToCsv(const QString &filePath);
|
||||||
|
|
||||||
// Метод для получения PDFController
|
// Метод для получения PDFController
|
||||||
PDFController* pdfController() const;
|
PDFController* pdfController() const;
|
||||||
@@ -148,6 +157,8 @@ private:
|
|||||||
SpecificationController* m_specificationController;
|
SpecificationController* m_specificationController;
|
||||||
VedomostTableModel* m_vedomostTableModel;
|
VedomostTableModel* m_vedomostTableModel;
|
||||||
VedomostController* m_vedomostController;
|
VedomostController* m_vedomostController;
|
||||||
|
SimpleListTableModel* m_simpleListTableModel;
|
||||||
|
SimpleListTableController* m_simpleListTableController;
|
||||||
TitleInscriptionsModel* m_titleInscriptionsModel;
|
TitleInscriptionsModel* m_titleInscriptionsModel;
|
||||||
PCBMaterialModel* m_pcbMaterialModel;
|
PCBMaterialModel* m_pcbMaterialModel;
|
||||||
DataBaseController* m_databaseController;
|
DataBaseController* m_databaseController;
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ bool PDFController::exportSpecificationPcbToPdf(const QString &filePath, MainCon
|
|||||||
pageNumber++;
|
pageNumber++;
|
||||||
}
|
}
|
||||||
writer.newPage();
|
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);
|
QRectF tableRect = pageRect.adjusted(20*mmToPixels, 5*mmToPixels, -5*mmToPixels, -20*mmToPixels);
|
||||||
addListRegistrationTable(painter, tableRect);
|
addListRegistrationTable(painter, tableRect);
|
||||||
painter.end();
|
painter.end();
|
||||||
@@ -386,7 +386,7 @@ bool PDFController::exportSpecificationToPdf(const QString &filePath, MainContro
|
|||||||
pageNumber++;
|
pageNumber++;
|
||||||
}
|
}
|
||||||
writer.newPage();
|
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);
|
QRectF tableRect = pageRect.adjusted(20*mmToPixels, 5*mmToPixels, -5*mmToPixels, -20*mmToPixels);
|
||||||
addListRegistrationTable(painter, tableRect);
|
addListRegistrationTable(painter, tableRect);
|
||||||
painter.end();
|
painter.end();
|
||||||
@@ -509,7 +509,7 @@ bool PDFController::exportVedomostToPdf(const QString &filePath, MainController
|
|||||||
// Обновляем pageRect для A4 формата после изменения размера и ориентации страницы
|
// Обновляем pageRect для A4 формата после изменения размера и ориентации страницы
|
||||||
QRectF pageRectA4 = writer.pageLayout().paintRectPixels(writer.resolution());
|
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);
|
QRectF tableRect = pageRectA4.adjusted(20*mmToPixels, 5*mmToPixels, -5*mmToPixels, -20*mmToPixels);
|
||||||
addListRegistrationTable(painter, tableRect);
|
addListRegistrationTable(painter, tableRect);
|
||||||
painter.end();
|
painter.end();
|
||||||
@@ -896,8 +896,10 @@ bool PDFController::addGostFrameA4FirstPage(QPainter &painter, const QRectF &pag
|
|||||||
|
|
||||||
}
|
}
|
||||||
else{
|
else{
|
||||||
painter.setFont(createGostFont(18, false, true));
|
QFont nameFont = createGostFont(18, false, true);
|
||||||
painter.drawText(mainTableRect.left()+65*mmToPixels, mainTableRect.top()+15*mmToPixels,70*mmToPixels, 20*mmToPixels,Qt::AlignCenter, getInscriptionValueWithProjectParams(model, projectParamModel, 1));
|
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.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.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, "Ведомость покупных изделий");
|
painter.drawText(mainTableRect.left()+65*mmToPixels, mainTableRect.bottom()-20*mmToPixels,70*mmToPixels, 20*mmToPixels,Qt::AlignHCenter|Qt::AlignBottom, "Ведомость покупных изделий");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -1003,6 +1003,11 @@ QString SpecificationPCBTableModel::getDocumentType() const
|
|||||||
return "SpecificationPCB";
|
return "SpecificationPCB";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QList<SpecificationPCBRowData> SpecificationPCBTableModel::getRows() const
|
||||||
|
{
|
||||||
|
return m_rows;
|
||||||
|
}
|
||||||
|
|
||||||
bool SpecificationPCBTableModel::isRowHeader(int row) const
|
bool SpecificationPCBTableModel::isRowHeader(int row) const
|
||||||
{
|
{
|
||||||
if (row < 0 || row >= m_rows.size()) {
|
if (row < 0 || row >= m_rows.size()) {
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ public:
|
|||||||
QString getDocumentTitle() const;
|
QString getDocumentTitle() const;
|
||||||
QString getDocumentType() const;
|
QString getDocumentType() const;
|
||||||
|
|
||||||
|
// Метод для получения строк (для использования в SimpleList)
|
||||||
|
QList<SpecificationPCBRowData> getRows() const;
|
||||||
|
|
||||||
// Методы для проверки статуса строки
|
// Методы для проверки статуса строки
|
||||||
bool isRowHeader(int row) const;
|
bool isRowHeader(int row) const;
|
||||||
|
|
||||||
|
|||||||
+165
-3
@@ -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> ¶ms)
|
||||||
|
{
|
||||||
|
qDebug() << "SimpleListProcessor::processProjectParams: Получено" << params.size() << "параметров";
|
||||||
|
// Пока не используем параметры проекта
|
||||||
|
}
|
||||||
|
|
||||||
|
QTableView* SimpleListProcessor::createTableView()
|
||||||
|
{
|
||||||
|
QTableView *tableView = new QTableView();
|
||||||
|
if (m_controller) {
|
||||||
|
tableView->setModel(m_controller->getTableModel());
|
||||||
|
|
||||||
|
// Настройка растягивания столбцов
|
||||||
|
QHeaderView *horizontalHeader = tableView->horizontalHeader();
|
||||||
|
horizontalHeader->setStretchLastSection(false);
|
||||||
|
|
||||||
|
// Устанавливаем режимы для каждого столбца
|
||||||
|
horizontalHeader->setSectionResizeMode(0, QHeaderView::Interactive); // Поз.обозначение
|
||||||
|
horizontalHeader->setSectionResizeMode(1, QHeaderView::Interactive); // Наименование
|
||||||
|
horizontalHeader->setSectionResizeMode(2, QHeaderView::Interactive); // Количество
|
||||||
|
|
||||||
|
// Устанавливаем начальные размеры для столбцов
|
||||||
|
horizontalHeader->resizeSection(0, 150); // Поз.обозначение
|
||||||
|
horizontalHeader->resizeSection(1, 300); // Наименование
|
||||||
|
horizontalHeader->resizeSection(2, 80); // Количество
|
||||||
|
|
||||||
|
// Настройка вертикального заголовка
|
||||||
|
QHeaderView *verticalHeader = tableView->verticalHeader();
|
||||||
|
verticalHeader->setDefaultSectionSize(25);
|
||||||
|
verticalHeader->setSectionResizeMode(QHeaderView::Fixed);
|
||||||
|
|
||||||
|
qDebug() << "SimpleListProcessor::createTableView: Настроено растягивание столбцов";
|
||||||
|
} else {
|
||||||
|
// Если контроллер не установлен, создаем временную модель
|
||||||
|
QStandardItemModel *tempModel = new QStandardItemModel(tableView);
|
||||||
|
QStringList headers;
|
||||||
|
headers << "Поз.обозначение" << "Наименование" << "Кол.";
|
||||||
|
tempModel->setHorizontalHeaderLabels(headers);
|
||||||
|
tableView->setModel(tempModel);
|
||||||
|
|
||||||
|
// Настройка столбцов
|
||||||
|
QHeaderView *horizontalHeader = tableView->horizontalHeader();
|
||||||
|
horizontalHeader->setStretchLastSection(false);
|
||||||
|
horizontalHeader->setSectionResizeMode(0, QHeaderView::Interactive);
|
||||||
|
horizontalHeader->setSectionResizeMode(1, QHeaderView::Interactive);
|
||||||
|
horizontalHeader->setSectionResizeMode(2, QHeaderView::Interactive);
|
||||||
|
horizontalHeader->resizeSection(0, 150);
|
||||||
|
horizontalHeader->resizeSection(1, 300);
|
||||||
|
horizontalHeader->resizeSection(2, 80);
|
||||||
|
|
||||||
|
QHeaderView *verticalHeader = tableView->verticalHeader();
|
||||||
|
verticalHeader->setDefaultSectionSize(25);
|
||||||
|
verticalHeader->setSectionResizeMode(QHeaderView::Fixed);
|
||||||
|
|
||||||
|
qDebug() << "SimpleListProcessor::createTableView: Создана временная модель";
|
||||||
|
}
|
||||||
|
return tableView;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SimpleListProcessor::updateTable()
|
||||||
|
{
|
||||||
|
qDebug() << "SimpleListProcessor::updateTable: Обновляем таблицу";
|
||||||
|
if (m_controller) {
|
||||||
|
m_controller->updateTableFromComponents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SimpleListProcessor::setController(SimpleListTableController *controller)
|
||||||
|
{
|
||||||
|
qDebug() << "SimpleListProcessor::setController: Устанавливаем контроллер";
|
||||||
|
m_controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SimpleListProcessor::setModel(SimpleListTableModel *model)
|
||||||
|
{
|
||||||
|
qDebug() << "SimpleListProcessor::setModel: Модель установлена в контроллер";
|
||||||
|
if (m_controller) {
|
||||||
|
m_controller->setModel(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DocumentEditWidget
|
// DocumentEditWidget
|
||||||
DocumentEditWidget::DocumentEditWidget(DocumentProcessor *processor, QWidget *parent)
|
DocumentEditWidget::DocumentEditWidget(DocumentProcessor *processor, QWidget *parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
@@ -759,6 +862,8 @@ void DocumentEditWidget::onOpenColumnSettings()
|
|||||||
docType = DocumentType::Specification;
|
docType = DocumentType::Specification;
|
||||||
} else if (qobject_cast<VedomostProcessor*>(m_processor)) {
|
} else if (qobject_cast<VedomostProcessor*>(m_processor)) {
|
||||||
docType = DocumentType::Vedomost;
|
docType = DocumentType::Vedomost;
|
||||||
|
} else if (qobject_cast<SimpleListProcessor*>(m_processor)) {
|
||||||
|
docType = DocumentType::SimpleList;
|
||||||
} else {
|
} else {
|
||||||
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Процессор не поддерживает настройки";
|
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Процессор не поддерживает настройки";
|
||||||
return;
|
return;
|
||||||
@@ -774,7 +879,13 @@ void DocumentEditWidget::onOpenColumnSettings()
|
|||||||
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Получены настройки таблицы:" << newTableMappings;
|
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Получены настройки таблицы:" << newTableMappings;
|
||||||
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Получены настройки надписей:" << newInscriptionValues;
|
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Получены настройки надписей:" << newInscriptionValues;
|
||||||
|
|
||||||
// Настройки таблицы и надписей уже сохранены в диалоге
|
// Настройки таблицы и надписей уже сохранены в диалоге в контроллер
|
||||||
|
// Сохраняем настройки в БД
|
||||||
|
if (m_controller) {
|
||||||
|
m_controller->saveProjectSettingsToDatabase();
|
||||||
|
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Настройки сохранены в БД";
|
||||||
|
}
|
||||||
|
|
||||||
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Настройки обновлены, но таблица не генерируется автоматически";
|
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Настройки обновлены, но таблица не генерируется автоматически";
|
||||||
} else {
|
} else {
|
||||||
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Диалог настроек отменен";
|
qDebug() << "DocumentEditWidget::onOpenColumnSettings: Диалог настроек отменен";
|
||||||
@@ -887,7 +998,45 @@ void DocumentEditWidget::onGenerateTable()
|
|||||||
qDebug() << "DocumentEditWidget::onGenerateTable: VedomostController недоступен";
|
qDebug() << "DocumentEditWidget::onGenerateTable: VedomostController недоступен";
|
||||||
}
|
}
|
||||||
} else {
|
} 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;
|
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)
|
void DocumentEditWidget::setData(ComponentTableModel *componentModel, ProjectParamTableModel *projectParamModel)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
#include "../controller/specificationpcbcontroller.h"
|
#include "../controller/specificationpcbcontroller.h"
|
||||||
#include "../controller/specificationcontroller.h"
|
#include "../controller/specificationcontroller.h"
|
||||||
#include "../controller/vedomostcontroller.h"
|
#include "../controller/vedomostcontroller.h"
|
||||||
|
#include "../model/simplelisttablemodel.h"
|
||||||
|
#include "../controller/simplelisttablecontroller.h"
|
||||||
#include "documentsettingsdialog.h"
|
#include "documentsettingsdialog.h"
|
||||||
#include "../model/titleinscriptionsmodel.h"
|
#include "../model/titleinscriptionsmodel.h"
|
||||||
|
|
||||||
@@ -176,6 +178,33 @@ private:
|
|||||||
VedomostController *m_controller;
|
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> ¶ms) override;
|
||||||
|
QTableView* createTableView() override;
|
||||||
|
|
||||||
|
// Метод для обновления таблицы по кнопке
|
||||||
|
void updateTable();
|
||||||
|
|
||||||
|
// Метод для получения контроллера
|
||||||
|
SimpleListTableController* getController() const { return m_controller; }
|
||||||
|
|
||||||
|
// Метод для установки контроллера (для интеграции с MainController)
|
||||||
|
void setController(SimpleListTableController *controller);
|
||||||
|
|
||||||
|
// Метод для установки модели (для интеграции с MainController)
|
||||||
|
void setModel(SimpleListTableModel *model);
|
||||||
|
|
||||||
|
private:
|
||||||
|
SimpleListTableController *m_controller;
|
||||||
|
};
|
||||||
|
|
||||||
// Универсальный виджет для работы с документами
|
// Универсальный виджет для работы с документами
|
||||||
class DocumentEditWidget : public QWidget
|
class DocumentEditWidget : public QWidget
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "../controller/specificationpcbcontroller.h"
|
#include "../controller/specificationpcbcontroller.h"
|
||||||
#include "../controller/specificationcontroller.h"
|
#include "../controller/specificationcontroller.h"
|
||||||
#include "../controller/vedomostcontroller.h"
|
#include "../controller/vedomostcontroller.h"
|
||||||
|
#include "../controller/simplelisttablecontroller.h"
|
||||||
#include "../model/componenttablemodel.h"
|
#include "../model/componenttablemodel.h"
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
@@ -83,6 +84,9 @@ DocumentSettingsDialog::DocumentSettingsDialog(DocumentType docType, MainControl
|
|||||||
, m_titleInscriptionsModel(nullptr)
|
, m_titleInscriptionsModel(nullptr)
|
||||||
, m_primaryApplicationComboBox(nullptr)
|
, m_primaryApplicationComboBox(nullptr)
|
||||||
, m_primaryApplicationFieldNumber(0)
|
, m_primaryApplicationFieldNumber(0)
|
||||||
|
, m_simpleListNameFieldComboBox(nullptr)
|
||||||
|
, m_simpleListTechReserveSpinBox(nullptr)
|
||||||
|
, m_simpleListBoardsCountSpinBox(nullptr)
|
||||||
{
|
{
|
||||||
qDebug() << "DocumentSettingsDialog: Конструктор для типа документа:" << static_cast<int>(docType);
|
qDebug() << "DocumentSettingsDialog: Конструктор для типа документа:" << static_cast<int>(docType);
|
||||||
|
|
||||||
@@ -127,6 +131,11 @@ void DocumentSettingsDialog::setUp()
|
|||||||
// Загружаем настройки "Первичное применение" для всех типов документов
|
// Загружаем настройки "Первичное применение" для всех типов документов
|
||||||
loadPrimaryApplicationSettings();
|
loadPrimaryApplicationSettings();
|
||||||
|
|
||||||
|
// Загружаем настройки SimpleList
|
||||||
|
if (m_documentType == DocumentType::SimpleList) {
|
||||||
|
loadSimpleListSettings();
|
||||||
|
}
|
||||||
|
|
||||||
qDebug() << "DocumentSettingsDialog::setUp: Настройка диалога завершена";
|
qDebug() << "DocumentSettingsDialog::setUp: Настройка диалога завершена";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +192,11 @@ void DocumentSettingsDialog::createTableSettingsTab()
|
|||||||
createWhereUsedField();
|
createWhereUsedField();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Создаем поля настроек для SimpleList
|
||||||
|
if (m_documentType == DocumentType::SimpleList) {
|
||||||
|
createSimpleListSettingsFields();
|
||||||
|
}
|
||||||
|
|
||||||
// Создаем поля для размера шрифта и поджима
|
// Создаем поля для размера шрифта и поджима
|
||||||
createFontSettingsFields();
|
createFontSettingsFields();
|
||||||
|
|
||||||
@@ -991,6 +1005,10 @@ void DocumentSettingsDialog::loadTableSettings()
|
|||||||
qDebug() << "DocumentSettingsDialog::loadTableSettings: VedomostController недоступен";
|
qDebug() << "DocumentSettingsDialog::loadTableSettings: VedomostController недоступен";
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case DocumentType::SimpleList:
|
||||||
|
// Для SimpleList настройки колонок не используются
|
||||||
|
qDebug() << "DocumentSettingsDialog::loadTableSettings: SimpleList - настройки колонок не требуются";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
qDebug() << "DocumentSettingsDialog::loadTableSettings: Контроллер недоступен";
|
qDebug() << "DocumentSettingsDialog::loadTableSettings: Контроллер недоступен";
|
||||||
@@ -1100,6 +1118,9 @@ void DocumentSettingsDialog::loadFontSettings()
|
|||||||
fontStretch = m_controller->vedomostController()->getFontStretch();
|
fontStretch = m_controller->vedomostController()->getFontStretch();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case DocumentType::SimpleList:
|
||||||
|
// Для SimpleList настройки шрифта не используются
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
m_fontSizeSpinBox->setValue(fontSize);
|
m_fontSizeSpinBox->setValue(fontSize);
|
||||||
@@ -1146,6 +1167,9 @@ void DocumentSettingsDialog::saveFontSettings()
|
|||||||
m_controller->vedomostController()->setFontStretch(fontStretch);
|
m_controller->vedomostController()->setFontStretch(fontStretch);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case DocumentType::SimpleList:
|
||||||
|
// Для SimpleList настройки шрифта не используются
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
@@ -1235,6 +1259,10 @@ void DocumentSettingsDialog::saveTableSettings()
|
|||||||
qDebug() << "DocumentSettingsDialog::saveTableSettings: VedomostController недоступен";
|
qDebug() << "DocumentSettingsDialog::saveTableSettings: VedomostController недоступен";
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case DocumentType::SimpleList:
|
||||||
|
// Для SimpleList настройки колонок не используются
|
||||||
|
qDebug() << "DocumentSettingsDialog::saveTableSettings: SimpleList - настройки колонок не требуются";
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
qDebug() << "DocumentSettingsDialog::saveTableSettings: Контроллер недоступен";
|
qDebug() << "DocumentSettingsDialog::saveTableSettings: Контроллер недоступен";
|
||||||
@@ -1371,6 +1399,11 @@ void DocumentSettingsDialog::onAccept()
|
|||||||
// Сохраняем настройки "Первичное применение" для всех типов документов
|
// Сохраняем настройки "Первичное применение" для всех типов документов
|
||||||
savePrimaryApplicationSettings();
|
savePrimaryApplicationSettings();
|
||||||
|
|
||||||
|
// Сохраняем настройки SimpleList
|
||||||
|
if (m_documentType == DocumentType::SimpleList) {
|
||||||
|
saveSimpleListSettings();
|
||||||
|
}
|
||||||
|
|
||||||
accept();
|
accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1896,3 +1929,141 @@ void DocumentSettingsDialog::setDecimalNumberValues(const QMap<QString, QString>
|
|||||||
loadDecimalNumberSettings();
|
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 сохранены";
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ enum class DocumentType {
|
|||||||
Perechen, // Перечень элементов
|
Perechen, // Перечень элементов
|
||||||
SpecificationPCB, // Спецификация ПП
|
SpecificationPCB, // Спецификация ПП
|
||||||
Specification, // Спецификация материалов
|
Specification, // Спецификация материалов
|
||||||
Vedomost // Ведомость покупных изделий
|
Vedomost, // Ведомость покупных изделий
|
||||||
|
SimpleList // Список элементов для экспорта
|
||||||
};
|
};
|
||||||
|
|
||||||
class DocumentSettingsDialog : public QDialog
|
class DocumentSettingsDialog : public QDialog
|
||||||
@@ -160,6 +161,16 @@ private:
|
|||||||
void createFontSettingsFields();
|
void createFontSettingsFields();
|
||||||
void loadFontSettings();
|
void loadFontSettings();
|
||||||
void saveFontSettings();
|
void saveFontSettings();
|
||||||
|
|
||||||
|
// Методы для работы с настройками SimpleList
|
||||||
|
void createSimpleListSettingsFields();
|
||||||
|
void loadSimpleListSettings();
|
||||||
|
void saveSimpleListSettings();
|
||||||
|
|
||||||
|
// Виджеты для настроек SimpleList
|
||||||
|
QComboBox *m_simpleListNameFieldComboBox;
|
||||||
|
QSpinBox *m_simpleListTechReserveSpinBox;
|
||||||
|
QSpinBox *m_simpleListBoardsCountSpinBox;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // DOCUMENTSETTINGSDIALOG_H
|
#endif // DOCUMENTSETTINGSDIALOG_H
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ void EditPDFWidget::setWidgets(){
|
|||||||
m_specificationProcessor = new SpecificationProcessor(this);
|
m_specificationProcessor = new SpecificationProcessor(this);
|
||||||
m_specificationPCBProcessor = new SpecificationPCBProcessor(this);
|
m_specificationPCBProcessor = new SpecificationPCBProcessor(this);
|
||||||
m_vedomostProcessor = new VedomostProcessor(this);
|
m_vedomostProcessor = new VedomostProcessor(this);
|
||||||
|
m_simpleListProcessor = new SimpleListProcessor(this);
|
||||||
|
|
||||||
// Устанавливаем PerechenTableController из MainController в PerechenProcessor
|
// Устанавливаем PerechenTableController из MainController в PerechenProcessor
|
||||||
if (m_controller && m_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) {
|
if (m_controller) {
|
||||||
// Используем новый конструктор с контроллером для сохранения настроек
|
// Используем новый конструктор с контроллером для сохранения настроек
|
||||||
@@ -113,18 +129,21 @@ void EditPDFWidget::setWidgets(){
|
|||||||
m_specificationEditWidget = new DocumentEditWidget(m_specificationProcessor, m_controller, this);
|
m_specificationEditWidget = new DocumentEditWidget(m_specificationProcessor, m_controller, this);
|
||||||
m_specificationPCBEditWidget = new DocumentEditWidget(m_specificationPCBProcessor, m_controller, this);
|
m_specificationPCBEditWidget = new DocumentEditWidget(m_specificationPCBProcessor, m_controller, this);
|
||||||
m_vedomostEditWidget = new DocumentEditWidget(m_vedomostProcessor, m_controller, this);
|
m_vedomostEditWidget = new DocumentEditWidget(m_vedomostProcessor, m_controller, this);
|
||||||
|
m_simpleListEditWidget = new DocumentEditWidget(m_simpleListProcessor, m_controller, this);
|
||||||
} else {
|
} else {
|
||||||
// Используем старый конструктор для обратной совместимости
|
// Используем старый конструктор для обратной совместимости
|
||||||
m_perechenEditWidget = new DocumentEditWidget(m_perechenProcessor, this);
|
m_perechenEditWidget = new DocumentEditWidget(m_perechenProcessor, this);
|
||||||
m_specificationEditWidget = new DocumentEditWidget(m_specificationProcessor, this);
|
m_specificationEditWidget = new DocumentEditWidget(m_specificationProcessor, this);
|
||||||
m_specificationPCBEditWidget = new DocumentEditWidget(m_specificationPCBProcessor, this);
|
m_specificationPCBEditWidget = new DocumentEditWidget(m_specificationPCBProcessor, this);
|
||||||
m_vedomostEditWidget = new DocumentEditWidget(m_vedomostProcessor, this);
|
m_vedomostEditWidget = new DocumentEditWidget(m_vedomostProcessor, this);
|
||||||
|
m_simpleListEditWidget = new DocumentEditWidget(m_simpleListProcessor, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
m_tabWidget->addTab(m_specificationEditWidget, "Спецификация");
|
m_tabWidget->addTab(m_specificationEditWidget, "Спецификация");
|
||||||
m_tabWidget->addTab(m_specificationPCBEditWidget, "Спецификация ПП");
|
m_tabWidget->addTab(m_specificationPCBEditWidget, "Спецификация ПП");
|
||||||
m_tabWidget->addTab(m_vedomostEditWidget, "Ведомость");
|
m_tabWidget->addTab(m_vedomostEditWidget, "Ведомость");
|
||||||
m_tabWidget->addTab(m_perechenEditWidget, "Перечень");
|
m_tabWidget->addTab(m_perechenEditWidget, "Перечень");
|
||||||
|
m_tabWidget->addTab(m_simpleListEditWidget, "Бланк заказа");
|
||||||
}
|
}
|
||||||
|
|
||||||
void EditPDFWidget::setUpLayout(){
|
void EditPDFWidget::setUpLayout(){
|
||||||
@@ -172,6 +191,7 @@ void EditPDFWidget::setData(ComponentTableModel *componentModel, ProjectParamTab
|
|||||||
m_specificationEditWidget->setData(componentModel, projectParamModel);
|
m_specificationEditWidget->setData(componentModel, projectParamModel);
|
||||||
m_specificationPCBEditWidget->setData(componentModel, projectParamModel);
|
m_specificationPCBEditWidget->setData(componentModel, projectParamModel);
|
||||||
m_vedomostEditWidget->setData(componentModel, projectParamModel);
|
m_vedomostEditWidget->setData(componentModel, projectParamModel);
|
||||||
|
m_simpleListEditWidget->setData(componentModel, projectParamModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
void EditPDFWidget::updateAllViews()
|
void EditPDFWidget::updateAllViews()
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ private:
|
|||||||
SpecificationProcessor* m_specificationProcessor;
|
SpecificationProcessor* m_specificationProcessor;
|
||||||
SpecificationPCBProcessor* m_specificationPCBProcessor;
|
SpecificationPCBProcessor* m_specificationPCBProcessor;
|
||||||
VedomostProcessor* m_vedomostProcessor;
|
VedomostProcessor* m_vedomostProcessor;
|
||||||
|
SimpleListProcessor* m_simpleListProcessor;
|
||||||
|
|
||||||
// Виджеты документов
|
// Виджеты документов
|
||||||
DocumentEditWidget* m_perechenEditWidget;
|
DocumentEditWidget* m_perechenEditWidget;
|
||||||
DocumentEditWidget* m_specificationEditWidget;
|
DocumentEditWidget* m_specificationEditWidget;
|
||||||
DocumentEditWidget* m_specificationPCBEditWidget;
|
DocumentEditWidget* m_specificationPCBEditWidget;
|
||||||
DocumentEditWidget* m_vedomostEditWidget;
|
DocumentEditWidget* m_vedomostEditWidget;
|
||||||
|
DocumentEditWidget* m_simpleListEditWidget;
|
||||||
|
|
||||||
// Модели данных
|
// Модели данных
|
||||||
ComponentTableModel* m_componentModel;
|
ComponentTableModel* m_componentModel;
|
||||||
|
|||||||
+102
-13
@@ -4,6 +4,7 @@
|
|||||||
#include "../model/specificationpcbtablemodel.h"
|
#include "../model/specificationpcbtablemodel.h"
|
||||||
#include "../model/specificationtablemodel.h"
|
#include "../model/specificationtablemodel.h"
|
||||||
#include "../model/vedomosttablemodel.h"
|
#include "../model/vedomosttablemodel.h"
|
||||||
|
#include "../model/simplelisttablemodel.h"
|
||||||
#include "../model/titleinscriptionsmodel.h"
|
#include "../model/titleinscriptionsmodel.h"
|
||||||
#include "../model/projectparamtablemodel.h"
|
#include "../model/projectparamtablemodel.h"
|
||||||
#include "../controller/altiumparser.h"
|
#include "../controller/altiumparser.h"
|
||||||
@@ -129,6 +130,7 @@ void MainWindow::setUpLayout(){
|
|||||||
csvMenu->addAction("&Перечень элементов (CSV)", this, &MainWindow::onExportPerechenToCsv);
|
csvMenu->addAction("&Перечень элементов (CSV)", this, &MainWindow::onExportPerechenToCsv);
|
||||||
csvMenu->addAction("&Ведомость покупных изделий (CSV)", this, &MainWindow::onExportPurchaseListToCsv);
|
csvMenu->addAction("&Ведомость покупных изделий (CSV)", this, &MainWindow::onExportPurchaseListToCsv);
|
||||||
csvMenu->addAction("Спецификация &PCB (CSV)", this, &MainWindow::onExportSpecificationPcbToCsv);
|
csvMenu->addAction("Спецификация &PCB (CSV)", this, &MainWindow::onExportSpecificationPcbToCsv);
|
||||||
|
csvMenu->addAction("&Бланк заказа (CSV)", this, &MainWindow::onExportSimpleListToCsv);
|
||||||
m_viewMenu = m_menuBar->addMenu(tr("&Вид"));
|
m_viewMenu = m_menuBar->addMenu(tr("&Вид"));
|
||||||
QAction* dataPanelAction = m_viewMenu->addAction(tr("&Панель данных"), this, &MainWindow::onViewDataPanel);
|
QAction* dataPanelAction = m_viewMenu->addAction(tr("&Панель данных"), this, &MainWindow::onViewDataPanel);
|
||||||
dataPanelAction->setCheckable(true);
|
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::onExportPerechenToCsv);
|
||||||
m_toolBar2->addAction(tr("Ведомость (CSV)"), this, &MainWindow::onExportPurchaseListToCsv);
|
m_toolBar2->addAction(tr("Ведомость (CSV)"), this, &MainWindow::onExportPurchaseListToCsv);
|
||||||
m_toolBar2->addAction(tr("Спецификация PCB (CSV)"), this, &MainWindow::onExportSpecificationPcbToCsv);
|
m_toolBar2->addAction(tr("Спецификация PCB (CSV)"), this, &MainWindow::onExportSpecificationPcbToCsv);
|
||||||
|
m_toolBar2->addAction(tr("Бланк заказа (CSV)"), this, &MainWindow::onExportSimpleListToCsv);
|
||||||
m_toolBar2->addSeparator();
|
m_toolBar2->addSeparator();
|
||||||
QAction* undoToolAction = m_toolBar2->addAction(tr("&Отменить"), this, &MainWindow::onUndo);
|
QAction* undoToolAction = m_toolBar2->addAction(tr("&Отменить"), this, &MainWindow::onUndo);
|
||||||
QAction* redoToolAction = m_toolBar2->addAction(tr("&Повторить"), this, &MainWindow::onRedo);
|
QAction* redoToolAction = m_toolBar2->addAction(tr("&Повторить"), this, &MainWindow::onRedo);
|
||||||
@@ -421,6 +424,20 @@ void MainWindow::openProjectFile(const QString &fileName) {
|
|||||||
qDebug() << "MainWindow::onFileOpen: Данные таблицы ведомости покупных изделий не найдены в БД";
|
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
|
// Загружаем надписи титульного листа в MainController
|
||||||
QMap<int, QString> inscriptions;
|
QMap<int, QString> inscriptions;
|
||||||
if (m_controller->loadTitleInscriptionsFromDatabase(inscriptions)) {
|
if (m_controller->loadTitleInscriptionsFromDatabase(inscriptions)) {
|
||||||
@@ -548,6 +565,20 @@ bool MainWindow::onFileSave(){
|
|||||||
qDebug() << "MainWindow::onFileSave: VedomostTableModel недоступен в MainController";
|
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) {
|
if (success) {
|
||||||
m_statusBar->showMessage(tr("Проект сохранен: %1").arg(m_currentProjectPath));
|
m_statusBar->showMessage(tr("Проект сохранен: %1").arg(m_currentProjectPath));
|
||||||
return true;
|
return true;
|
||||||
@@ -632,21 +663,35 @@ void MainWindow::onFileSaveAs(){
|
|||||||
qDebug() << "MainWindow::onFileSaveAs: SpecificationTableModel недоступен в MainController";
|
qDebug() << "MainWindow::onFileSaveAs: SpecificationTableModel недоступен в MainController";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохраняем ведомость покупных изделий из модели MainController
|
// Сохраняем ведомость покупных изделий из модели MainController
|
||||||
VedomostTableModel *vedomostModel = m_controller->vedomostTableModel();
|
VedomostTableModel *vedomostModel = m_controller->vedomostTableModel();
|
||||||
if (vedomostModel) {
|
if (vedomostModel) {
|
||||||
QByteArray tableData = vedomostModel->saveToDatabase();
|
QByteArray tableData = vedomostModel->saveToDatabase();
|
||||||
if (!tableData.isEmpty()) {
|
if (!tableData.isEmpty()) {
|
||||||
success &= m_controller->saveVedomostTableToDatabase(tableData);
|
success &= m_controller->saveVedomostTableToDatabase(tableData);
|
||||||
qDebug() << "MainWindow::onFileSaveAs: Таблица ведомости покупных изделий сохранена из MainController";
|
qDebug() << "MainWindow::onFileSaveAs: Таблица ведомости покупных изделий сохранена из MainController";
|
||||||
|
} else {
|
||||||
|
qDebug() << "MainWindow::onFileSaveAs: Данные таблицы ведомости покупных изделий пусты";
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
qDebug() << "MainWindow::onFileSaveAs: Данные таблицы ведомости покупных изделий пусты";
|
qDebug() << "MainWindow::onFileSaveAs: VedomostTableModel недоступен в MainController";
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
qDebug() << "MainWindow::onFileSaveAs: VedomostTableModel недоступен в MainController";
|
// Сохраняем бланк заказа из модели MainController
|
||||||
}
|
SimpleListTableModel *simpleListModel = m_controller->simpleListTableModel();
|
||||||
|
if (simpleListModel) {
|
||||||
if (success) {
|
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;
|
m_currentProjectPath = fileName;
|
||||||
updateWindowTitle();
|
updateWindowTitle();
|
||||||
updateStatusBar();
|
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::onUndo(){}
|
||||||
|
|
||||||
void MainWindow::onRedo(){}
|
void MainWindow::onRedo(){}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ private slots:
|
|||||||
void onExportPerechenToCsv();
|
void onExportPerechenToCsv();
|
||||||
void onExportPurchaseListToCsv();
|
void onExportPurchaseListToCsv();
|
||||||
void onExportSpecificationPcbToCsv();
|
void onExportSpecificationPcbToCsv();
|
||||||
|
void onExportSimpleListToCsv();
|
||||||
void onUndo();
|
void onUndo();
|
||||||
void onRedo();
|
void onRedo();
|
||||||
|
|
||||||
|
|||||||
@@ -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=*
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
data/
|
||||||
|
**/__pycache__/
|
||||||
|
**/.venv/
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
.env
|
||||||
|
*.pyc
|
||||||
@@ -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`). Правки не применяются автоматически.
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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.
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 } }),
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user