Add biggest update for UI. Create Ordering module

This commit is contained in:
2025-11-11 16:24:18 +03:00
parent 910baf61f1
commit 38a8b06af2
15 changed files with 1651 additions and 15 deletions

24
pom.xml
View File

@@ -12,6 +12,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.12.1</junit.version>
<poi.version>5.2.5</poi.version>
</properties>
<dependencies>
@@ -61,17 +62,19 @@
<artifactId>bootstrapfx-core</artifactId>
<version>0.4.0</version>
</dependency>
<!-- Apache POI for Excel (XLSX) -->
<dependency>
<groupId>eu.hansolo</groupId>
<artifactId>tilesfx</artifactId>
<version>21.0.9</version>
<exclusions>
<exclusion>
<groupId>org.openjfx</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>${poi.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
@@ -103,10 +106,9 @@
<version>0.0.8</version>
<executions>
<execution>
<!-- Default configuration for running with: mvn clean javafx:run -->
<id>default-cli</id>
<configuration>
<mainClass>com.dsol.pki_management/com.dsol.pki_management.HelloApplication</mainClass>
<mainClass>com.dsol.pki_management.app/com.dsol.pki_management.app.PKIApplication</mainClass>
<launcher>app</launcher>
<jlinkZipName>app</jlinkZipName>
<jlinkImageName>app</jlinkImageName>

View File

@@ -0,0 +1,393 @@
package com.dsol.pki_management.components;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.*;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyCodeCombination;
import javafx.scene.input.KeyCombination;
import java.util.ArrayList;
import java.util.List;
/**
* Утилитный класс для настройки TableView с поддержкой выделения ячеек и копирования (как в Excel)
*/
public class TableViewEnhancer {
// Хранилище для отслеживания выделенных ячеек
private static final java.util.Map<TableView<?>, ObservableList<TablePosition<?, ?>>> selectedCellsMap = new java.util.concurrent.ConcurrentHashMap<>();
// Хранилище для отслеживания начальной позиции при drag selection
private static final java.util.Map<TableView<?>, TablePosition<?, ?>> dragStartMap = new java.util.concurrent.ConcurrentHashMap<>();
/**
* Настраивает таблицу для выделения ячеек и копирования
* @param tableView Таблица для настройки
* @param <T> Тип данных строки таблицы
*/
public static <T> void enhance(TableView<T> tableView) {
if (tableView == null) {
return;
}
// Создаем список для отслеживания выделенных ячеек
@SuppressWarnings("unchecked")
ObservableList<TablePosition<?, ?>> selectedCells = FXCollections.observableArrayList();
selectedCellsMap.put(tableView, selectedCells);
setupCellSelection(tableView);
setupCopyHandler(tableView);
}
/**
* Настраивает режим выделения ячеек вместо строк
*/
@SuppressWarnings("unchecked")
private static <T> void setupCellSelection(TableView<T> tableView) {
TableSelectionModel<T> selectionModel = tableView.getSelectionModel();
// Включаем выделение ячеек
selectionModel.setCellSelectionEnabled(true);
// Разрешаем множественное выделение
selectionModel.setSelectionMode(SelectionMode.MULTIPLE);
// Отслеживаем изменения выделения
ObservableList<TablePosition<?, ?>> trackedCells = selectedCellsMap.get(tableView);
// Обработчик начала выделения (mouse pressed)
tableView.setOnMousePressed(event -> {
if (event.getTarget() instanceof TableCell) {
@SuppressWarnings("unchecked")
TableCell<T, ?> clickedCell = (TableCell<T, ?>) event.getTarget();
TableRow<T> row = clickedCell.getTableRow();
if (row != null) {
int clickedRow = row.getIndex();
TableColumn<T, ?> clickedColumn = clickedCell.getTableColumn();
if (clickedRow >= 0 && clickedColumn != null) {
// Создаем начальную позицию для drag selection
TablePosition<T, ?> startPos = new TablePosition<>(tableView, clickedRow, clickedColumn);
dragStartMap.put(tableView, startPos);
// Если не Shift+Click, очищаем выделение и выделяем одну ячейку
if (!event.isShiftDown() && !event.isControlDown()) {
selectionModel.clearSelection();
selectionModel.select(clickedRow, clickedColumn);
trackedCells.clear();
trackedCells.add(startPos);
} else if (event.isShiftDown()) {
// Shift+Click: выделяем диапазон от последней выделенной ячейки
TablePosition<T, ?> currentSelection = trackedCells != null && !trackedCells.isEmpty()
? (TablePosition<T, ?>) trackedCells.get(trackedCells.size() - 1)
: null;
if (currentSelection != null) {
int startRow = Math.min(currentSelection.getRow(), clickedRow);
int endRow = Math.max(currentSelection.getRow(), clickedRow);
int startCol = Math.min(currentSelection.getColumn(), tableView.getColumns().indexOf(clickedColumn));
int endCol = Math.max(currentSelection.getColumn(), tableView.getColumns().indexOf(clickedColumn));
selectionModel.clearSelection();
trackedCells.clear();
for (int r = startRow; r <= endRow; r++) {
for (int c = startCol; c <= endCol; c++) {
if (r >= 0 && r < tableView.getItems().size()
&& c >= 0 && c < tableView.getColumns().size()) {
TableColumn<T, ?> col = tableView.getColumns().get(c);
selectionModel.select(r, col);
TablePosition<T, ?> pos = new TablePosition<>(tableView, r, col);
trackedCells.add(pos);
}
}
}
event.consume();
}
} else if (event.isControlDown()) {
// Ctrl+Click: добавляем/удаляем ячейку из выделения
TablePosition<T, ?> pos = new TablePosition<>(tableView, clickedRow, clickedColumn);
if (trackedCells.contains(pos)) {
selectionModel.clearSelection(clickedRow, clickedColumn);
trackedCells.remove(pos);
} else {
selectionModel.select(clickedRow, clickedColumn);
trackedCells.add(pos);
}
event.consume();
}
}
}
}
});
// Обработчик перетаскивания мыши для выделения диапазона (drag selection)
tableView.setOnMouseDragged(event -> {
TablePosition<?, ?> startPos = dragStartMap.get(tableView);
if (startPos == null) return;
// Находим ячейку под курсором мыши
javafx.geometry.Point2D localPoint = tableView.screenToLocal(event.getScreenX(), event.getScreenY());
int draggedRow = -1;
int draggedCol = -1;
// Вычисляем строку на основе Y координаты
// Учитываем заголовок таблицы (если есть)
double headerHeight = tableView.lookup(".column-header-background") != null
? tableView.lookup(".column-header-background").getBoundsInLocal().getHeight()
: 0;
double y = localPoint.getY() - headerHeight;
// Вычисляем индекс строки
double rowHeight = tableView.getFixedCellSize() > 0
? tableView.getFixedCellSize()
: 25.0; // примерная высота строки по умолчанию
if (y >= 0) {
draggedRow = (int) (y / rowHeight);
}
// Вычисляем колонку на основе X координаты
double x = localPoint.getX();
double currentX = 0;
for (int i = 0; i < tableView.getColumns().size(); i++) {
TableColumn<T, ?> col = tableView.getColumns().get(i);
double colWidth = col.getWidth();
if (x >= currentX && x < currentX + colWidth) {
draggedCol = i;
break;
}
currentX += colWidth;
}
// Ограничиваем индексы валидными значениями
if (draggedRow < 0) draggedRow = 0;
if (draggedRow >= tableView.getItems().size()) draggedRow = tableView.getItems().size() - 1;
if (draggedCol < 0) draggedCol = 0;
if (draggedCol >= tableView.getColumns().size()) draggedCol = tableView.getColumns().size() - 1;
// Выделяем диапазон
if (draggedRow >= 0 && draggedRow < tableView.getItems().size()
&& draggedCol >= 0 && draggedCol < tableView.getColumns().size()) {
int startRow = Math.min(startPos.getRow(), draggedRow);
int endRow = Math.max(startPos.getRow(), draggedRow);
int startCol = Math.min(startPos.getColumn(), draggedCol);
int endCol = Math.max(startPos.getColumn(), draggedCol);
selectionModel.clearSelection();
trackedCells.clear();
for (int r = startRow; r <= endRow; r++) {
for (int c = startCol; c <= endCol; c++) {
if (r >= 0 && r < tableView.getItems().size()
&& c >= 0 && c < tableView.getColumns().size()) {
TableColumn<T, ?> col = tableView.getColumns().get(c);
selectionModel.select(r, col);
TablePosition<T, ?> pos = new TablePosition<>(tableView, r, col);
trackedCells.add(pos);
}
}
}
event.consume();
}
});
// Обработчик окончания перетаскивания
tableView.setOnMouseReleased(event -> {
dragStartMap.remove(tableView);
});
// Сброс выделения при клике вне таблицы
// Устанавливаем обработчик на сцену, если она доступна
tableView.sceneProperty().addListener((obs, oldScene, newScene) -> {
if (newScene != null && oldScene == null) {
// Добавляем обработчик только один раз при первой установке сцены
newScene.addEventFilter(javafx.scene.input.MouseEvent.MOUSE_CLICKED, event -> {
// Проверяем, был ли клик вне таблицы
javafx.scene.Node target = (javafx.scene.Node) event.getTarget();
if (!isNodeInTableView(tableView, target)) {
selectionModel.clearSelection();
if (trackedCells != null) {
trackedCells.clear();
}
}
});
}
});
}
/**
* Проверяет, находится ли узел внутри TableView
*/
private static <T> boolean isNodeInTableView(TableView<T> tableView, javafx.scene.Node node) {
javafx.scene.Node current = node;
while (current != null) {
if (current == tableView) {
return true;
}
current = current.getParent();
}
return false;
}
/**
* Обновляет отслеживаемый список выделенных ячеек на основе текущего выделения модели
* Используется как fallback, если trackedCells не синхронизирован
*/
@SuppressWarnings("unchecked")
private static <T> void updateTrackedCells(TableView<T> tableView, ObservableList<TablePosition<?, ?>> trackedCells) {
if (trackedCells == null) return;
// Обновляем на основе текущего выделения в модели
// В режиме cell selection нужно проверять каждую ячейку отдельно
TableSelectionModel<T> selectionModel = tableView.getSelectionModel();
ObservableList<Integer> selectedIndices = selectionModel.getSelectedIndices();
trackedCells.clear();
// Проходим по всем выделенным строкам и проверяем каждую колонку
for (Integer rowIndex : selectedIndices) {
if (rowIndex >= 0 && rowIndex < tableView.getItems().size()) {
for (TableColumn<T, ?> column : tableView.getColumns()) {
// Проверяем, выделена ли конкретная ячейка
// В JavaFX с cell selection это можно проверить через попытку выделения
// или через отслеживание состояния
try {
// Пытаемся использовать рефлексию для проверки выделения ячейки
java.lang.reflect.Method isSelectedMethod = selectionModel.getClass()
.getMethod("isSelected", int.class, TableColumn.class);
Boolean isSelected = (Boolean) isSelectedMethod.invoke(selectionModel, rowIndex, column);
if (Boolean.TRUE.equals(isSelected)) {
TablePosition<T, ?> pos = new TablePosition<>(tableView, rowIndex, column);
if (!trackedCells.contains(pos)) {
trackedCells.add(pos);
}
}
} catch (Exception e) {
// Если метод недоступен, добавляем все ячейки выделенных строк
// Это не идеально, но лучше чем ничего
TablePosition<T, ?> pos = new TablePosition<>(tableView, rowIndex, column);
if (!trackedCells.contains(pos)) {
trackedCells.add(pos);
}
}
}
}
}
}
/**
* Настраивает обработчик копирования (Ctrl+C)
*/
private static <T> void setupCopyHandler(TableView<T> tableView) {
KeyCodeCombination copyKey = new KeyCodeCombination(KeyCode.C, KeyCombination.CONTROL_DOWN);
tableView.setOnKeyPressed(event -> {
if (copyKey.match(event)) {
copySelectedCells(tableView);
event.consume();
}
});
}
/**
* Копирует выделенные ячейки в буфер обмена
*/
@SuppressWarnings("unchecked")
private static <T> void copySelectedCells(TableView<T> tableView) {
TableSelectionModel<T> selectionModel = tableView.getSelectionModel();
if (!selectionModel.isCellSelectionEnabled()) {
return;
}
// Получаем отслеживаемые выделенные ячейки
ObservableList<TablePosition<?, ?>> trackedCells = selectedCellsMap.get(tableView);
// Если список пуст или не синхронизирован, обновляем его
if (trackedCells == null || trackedCells.isEmpty()) {
if (trackedCells == null) {
trackedCells = FXCollections.observableArrayList();
selectedCellsMap.put(tableView, trackedCells);
}
updateTrackedCells(tableView, trackedCells);
}
// Используем отслеживаемые ячейки для копирования
List<TablePosition<?, ?>> selectedCells = new ArrayList<>(trackedCells);
if (selectedCells.isEmpty()) {
return;
}
// Сортируем ячейки по строкам и колонкам
selectedCells.sort((a, b) -> {
int rowCompare = Integer.compare(a.getRow(), b.getRow());
if (rowCompare != 0) return rowCompare;
return Integer.compare(a.getColumn(), b.getColumn());
});
// Группируем по строкам
StringBuilder clipboardText = new StringBuilder();
int currentRow = -1;
boolean firstCell = true;
for (TablePosition<?, ?> pos : selectedCells) {
if (currentRow != pos.getRow() && !firstCell) {
clipboardText.append("\n");
}
if (currentRow != pos.getRow()) {
currentRow = pos.getRow();
firstCell = true;
} else {
clipboardText.append("\t");
}
// Получаем значение ячейки
@SuppressWarnings("unchecked")
TablePosition<T, ?> typedPos = (TablePosition<T, ?>) pos;
Object cellValue = getCellValue(tableView, typedPos);
clipboardText.append(cellValue != null ? cellValue.toString() : "");
firstCell = false;
}
// Копируем в буфер обмена
Clipboard clipboard = Clipboard.getSystemClipboard();
ClipboardContent content = new ClipboardContent();
content.putString(clipboardText.toString());
clipboard.setContent(content);
}
/**
* Получает значение ячейки по позиции
*/
private static <T> Object getCellValue(TableView<T> tableView, TablePosition<T, ?> pos) {
if (pos.getRow() < 0 || pos.getRow() >= tableView.getItems().size()) {
return "";
}
T rowItem = tableView.getItems().get(pos.getRow());
if (rowItem == null) {
return "";
}
if (pos.getColumn() < 0 || pos.getColumn() >= tableView.getColumns().size()) {
return "";
}
TableColumn<T, ?> column = tableView.getColumns().get(pos.getColumn());
if (column == null) {
return "";
}
// Получаем значение через cell value factory
Object cellValue = column.getCellData(rowItem);
return cellValue != null ? cellValue : "";
}
}

View File

@@ -12,6 +12,10 @@ public class AppConfig {
public static final String KEY_WINDOW_HEIGHT = "window.height";
public static final String KEY_WINDOW_X = "window.x";
public static final String KEY_WINDOW_Y = "window.y";
public static final String KEY_HIDDEN_SUBMENUS = "submenu.hidden"; // comma-separated texts
public static final String KEY_PURCHASES_EXCEL_PATH = "purchases.excel.path";
public static final String KEY_MES_URL = "mes.url";
public static final String KEY_MES_MATCH_ERROR_URL = "mes.match_error.url";
// Значения по умолчанию
public static final String DEFAULT_ADMIN_PASSWORD = "admin123";

View File

@@ -3,6 +3,11 @@ package com.dsol.pki_management.controllers;
import com.dsol.pki_management.components.MenuItem;
import com.dsol.pki_management.components.SubMenuItem;
import com.dsol.pki_management.modules.test1.Test1Controller;
import com.dsol.pki_management.config.ConfigManager;
import com.dsol.pki_management.config.AppConfig;
import com.dsol.pki_management.modules.purchases.PurchasesExcelValidator;
import com.dsol.pki_management.modules.purchases.PurchasesExcelParser;
import com.dsol.pki_management.modules.purchases.PurchasesDataStore;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.Button;
@@ -11,10 +16,18 @@ import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.Arrays;
import javafx.scene.input.MouseEvent;
import javafx.concurrent.Task;
import javafx.application.Platform;
/**
* Главный контроллер приложения
@@ -41,6 +54,8 @@ public class MainController {
menuOverlay.setOnMouseClicked(e -> closeMenu());
setupMenuItems();
applyHiddenSubMenusFromConfig();
startPurchasesScanIfConfigured();
}
private void setupMenuItems() {
@@ -59,6 +74,13 @@ public class MainController {
createSubMenuItem("Отчеты", e -> System.out.println("Открыты отчеты"))
);
MenuItem purchases = createMenuItem("Закупки",
createSubMenuItem("Закупочный перечень", e -> {
loadPurchasesListModule();
closeMenu();
})
);
MenuItem testMenu = createMenuItem("Test",
createSubMenuItem("Тест 1", e -> {
loadTest1Module();
@@ -68,10 +90,77 @@ public class MainController {
// Добавляем блоки меню в контент меню
if (menuContent != null) {
menuContent.getChildren().addAll(mainFunctions, management, testMenu);
menuContent.getChildren().addAll(mainFunctions, management, purchases, testMenu);
}
}
/**
* Применяет скрытые подменю из конфигурации
*/
private void applyHiddenSubMenusFromConfig() {
ConfigManager config = ConfigManager.getInstance();
String raw = config.getProperty(AppConfig.KEY_HIDDEN_SUBMENUS, "");
if (raw == null || raw.isBlank()) {
return;
}
Set<String> hidden = Arrays.stream(raw.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(HashSet::new));
for (SubMenuItem item : allSubMenuItems) {
if (hidden.contains(item.getText())) {
item.setItemVisible(false);
}
}
for (MenuItem menuItem : allMenuItems) {
menuItem.updateVisibility();
}
}
/**
* Запускает фоновую проверку Excel закупок при старте приложения (если путь задан)
*/
private void startPurchasesScanIfConfigured() {
String pathStr = ConfigManager.getInstance().getProperty(AppConfig.KEY_PURCHASES_EXCEL_PATH, "");
if (pathStr == null || pathStr.isBlank()) {
return;
}
Path path = Paths.get(pathStr);
Task<Void> task = new Task<>() {
@Override
protected Void call() {
try {
PurchasesDataStore.getInstance().setStatus("Сканирование файла...");
PurchasesExcelValidator validator = new PurchasesExcelValidator();
var results = validator.validate(path);
results.forEach(r -> {
if (r.valid) {
System.out.println("[OK] Лист '" + r.sheetName + "' — все обязательные заголовки найдены");
} else {
System.out.println("[WARN] Лист '" + r.sheetName + "' — отсутствуют заголовки: " + String.join(", ", r.missingHeaders));
}
});
// Парсим данные и кладём в стор
PurchasesExcelParser parser = new PurchasesExcelParser();
var rows = parser.parse(path);
Platform.runLater(() -> {
PurchasesDataStore.getInstance().setRows(rows);
PurchasesDataStore.getInstance().setStatus("Загружено записей: " + rows.size());
});
} catch (Exception ex) {
System.err.println("[ERROR] Ошибка валидации/парсинга Excel при старте: " + ex.getMessage());
ex.printStackTrace();
Platform.runLater(() -> PurchasesDataStore.getInstance().setStatus("Ошибка: " + ex.getMessage()));
}
return null;
}
};
Thread t = new Thread(task, "startup-purchases-excel-validate");
t.setDaemon(true);
t.start();
}
/**
* Создает MenuItem с указанным названием и подменю
*/
@@ -142,6 +231,24 @@ public class MainController {
e.printStackTrace();
}
}
/**
* Загружает модуль "Закупочный перечень" в центральную область
*/
private void loadPurchasesListModule() {
try {
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/dsol/pki_management/modules/purchases/purchases-list-view.fxml"));
VBox purchasesView = loader.load();
if (contentArea != null) {
contentArea.getChildren().clear();
contentArea.getChildren().add(purchasesView);
}
} catch (IOException e) {
System.err.println("Ошибка загрузки модуля Закупочный перечень: " + e.getMessage());
e.printStackTrace();
}
}
/**
* Загружает модуль настроек в центральную область

View File

@@ -12,9 +12,14 @@ import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;
import javafx.stage.Modality;
import javafx.stage.FileChooser;
import java.io.File;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Контроллер для модуля настроек
@@ -32,6 +37,12 @@ public class SettingsController {
private Label statusLabel;
@FXML
private VBox subMenuItemsContainer;
@FXML
private TextField purchasesExcelPathField;
@FXML
private TextField mesUrlField;
@FXML
private TextField mesMatchErrorUrlField;
private boolean isAdminMode = false;
private final ConfigManager configManager = ConfigManager.getInstance();
@@ -54,8 +65,69 @@ public class SettingsController {
if (exitAdminButton != null) {
exitAdminButton.setOnAction(e -> disableAdminMode());
}
// Инициализация поля пути к Excel файлу из конфига
if (purchasesExcelPathField != null) {
String path = configManager.getProperty(AppConfig.KEY_PURCHASES_EXCEL_PATH, "");
purchasesExcelPathField.setText(path);
purchasesExcelPathField.setOnAction(e -> savePurchasesExcelPath());
purchasesExcelPathField.focusedProperty().addListener((obs, oldV, newV) -> {
if (!newV) savePurchasesExcelPath();
});
}
// Инициализация полей MES из конфига
if (mesUrlField != null) {
String url = configManager.getProperty(AppConfig.KEY_MES_URL, "");
mesUrlField.setText(url);
mesUrlField.setOnAction(e -> saveMesUrls());
mesUrlField.focusedProperty().addListener((obs, oldV, newV) -> {
if (!newV) saveMesUrls();
});
}
if (mesMatchErrorUrlField != null) {
String url = configManager.getProperty(AppConfig.KEY_MES_MATCH_ERROR_URL, "");
mesMatchErrorUrlField.setText(url);
mesMatchErrorUrlField.setOnAction(e -> saveMesUrls());
mesMatchErrorUrlField.focusedProperty().addListener((obs, oldV, newV) -> {
if (!newV) saveMesUrls();
});
}
}
private void savePurchasesExcelPath() {
if (purchasesExcelPathField == null) return;
String path = purchasesExcelPathField.getText() == null ? "" : purchasesExcelPathField.getText().trim();
configManager.setProperty(AppConfig.KEY_PURCHASES_EXCEL_PATH, path);
configManager.saveConfig();
}
private void saveMesUrls() {
if (mesUrlField != null) {
String url = mesUrlField.getText() == null ? "" : mesUrlField.getText().trim();
configManager.setProperty(AppConfig.KEY_MES_URL, url);
}
if (mesMatchErrorUrlField != null) {
String url = mesMatchErrorUrlField.getText() == null ? "" : mesMatchErrorUrlField.getText().trim();
configManager.setProperty(AppConfig.KEY_MES_MATCH_ERROR_URL, url);
}
configManager.saveConfig();
}
@FXML
private void choosePurchasesExcelFile() {
FileChooser chooser = new FileChooser();
chooser.setTitle("Выберите Excel файл закупок");
chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Excel (*.xlsx)", "*.xlsx"));
File initialDir = new File(System.getProperty("user.home"));
if (initialDir.exists()) chooser.setInitialDirectory(initialDir);
File file = chooser.showOpenDialog(null);
if (file != null) {
purchasesExcelPathField.setText(file.getAbsolutePath());
savePurchasesExcelPath();
}
}
/**
* Устанавливает видимость контейнера
*/
@@ -130,7 +202,7 @@ public class SettingsController {
subMenuItemsContainer.setPadding(new Insets(15, 0, 15, 0));
subMenuItemsContainer.setSpacing(8);
// Получаем все MenuItem и SubMenuItem из HelloController
// Получаем все MenuItem и SubMenuItem из MainController
List<MenuItem> menuItems = MainController.getAllMenuItems();
if (menuItems.isEmpty()) {
@@ -194,7 +266,7 @@ public class SettingsController {
}
/**
* Переключает видимость SubMenuItem при клике
* Переключает видимость SubMenuItem при клике и сохраняет в конфиг
*/
private void toggleSubMenuItemVisibility(SubMenuItem subMenuItem, Text text) {
boolean newVisibility = !subMenuItem.isItemVisible();
@@ -203,6 +275,27 @@ public class SettingsController {
// Обновляем видимость всех MenuItem
MainController.getAllMenuItems().forEach(MenuItem::updateVisibility);
// Сохранить скрытые элементы в конфигурацию
persistHiddenSubMenusToConfig();
}
/**
* Сохраняет список скрытых SubMenuItem в конфигурационный файл
*/
private void persistHiddenSubMenusToConfig() {
List<MenuItem> all = MainController.getAllMenuItems();
Set<String> hidden = new HashSet<>();
for (MenuItem menu : all) {
for (SubMenuItem sub : menu.getSubMenuItems()) {
if (!sub.isItemVisible()) {
hidden.add(sub.getText());
}
}
}
String value = hidden.stream().sorted().collect(Collectors.joining(","));
configManager.setProperty(AppConfig.KEY_HIDDEN_SUBMENUS, value);
configManager.saveConfig();
}
/**

View File

@@ -0,0 +1,217 @@
package com.dsol.pki_management.modules.purchases;
import javafx.geometry.Pos;
import javafx.scene.control.TableCell;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Text;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* Кастомная ячейка таблицы с подсветкой текста по регулярным выражениям
*/
public class HighlightedTableCell extends TableCell<PurchaseRow, String> {
private static final Color HIGHLIGHT_COLOR = Color.web("#ff4444"); // Красный цвет для подсветки
private static final Color TEXT_COLOR = Color.BLACK;
// Статический кэш паттернов для переиспользования
private static List<Pattern> cachedPatterns = List.of();
private static List<String> cachedStrings = List.of();
private List<Pattern> highlightPatterns = List.of();
/**
* Устанавливает список строк для подсветки
* Из них будут сгенерированы регулярные выражения
* Оптимизировано: паттерны создаются один раз и кэшируются
*/
public void setHighlightStrings(List<String> highlightStrings) {
if (highlightStrings == null || highlightStrings.isEmpty()) {
this.highlightPatterns = List.of();
return;
}
// Нормализуем список (trim и фильтрация)
List<String> normalized = highlightStrings.stream()
.filter(s -> s != null && !s.trim().isEmpty())
.map(s -> s.trim())
.collect(Collectors.toList());
// Проверяем, совпадает ли список с закэшированным
if (normalized.equals(cachedStrings) && !cachedPatterns.isEmpty()) {
this.highlightPatterns = cachedPatterns;
return;
}
// Создаем паттерны один раз и кэшируем их
this.highlightPatterns = normalized.stream()
.map(s -> Pattern.compile(Pattern.quote(s), Pattern.CASE_INSENSITIVE))
.collect(Collectors.toList());
// Сохраняем в статический кэш
cachedPatterns = this.highlightPatterns;
cachedStrings = normalized;
}
@Override
protected void updateItem(String item, boolean empty) {
super.updateItem(item, empty);
if (empty || item == null) {
setGraphic(null);
setText(null);
} else {
setGraphic(createHighlightedText(item));
setText(null);
}
}
/**
* Создает HBox с подсветкой совпадающих фрагментов
*/
private HBox createHighlightedText(String text) {
HBox hbox = new HBox();
hbox.setAlignment(Pos.CENTER_LEFT);
hbox.setSpacing(0);
if (highlightPatterns.isEmpty()) {
// Если нет паттернов для подсветки, просто показываем текст
Text normalText = new Text(text);
normalText.setFill(TEXT_COLOR);
hbox.getChildren().add(normalText);
return hbox;
}
// Находим все совпадения для всех паттернов
List<MatchInfo> matches = findAllMatches(text);
if (matches.isEmpty()) {
// Нет совпадений - показываем обычный текст
Text normalText = new Text(text);
normalText.setFill(TEXT_COLOR);
hbox.getChildren().add(normalText);
return hbox;
}
// Сортируем совпадения по позиции
matches.sort((a, b) -> Integer.compare(a.start, b.start));
// Строим HBox с подсветкой
int lastIndex = 0;
for (MatchInfo match : matches) {
// Добавляем текст до совпадения
if (match.start > lastIndex) {
Text beforeText = new Text(text.substring(lastIndex, match.start));
beforeText.setFill(TEXT_COLOR);
hbox.getChildren().add(beforeText);
}
// Добавляем подсвеченный текст с фоном
String highlightedText = text.substring(match.start, match.end);
Text textNode = new Text(highlightedText);
textNode.setFill(TEXT_COLOR);
// Создаем фон для подсветки
Rectangle background = new Rectangle();
background.setFill(HIGHLIGHT_COLOR);
background.widthProperty().bind(textNode.layoutBoundsProperty().map(bounds -> bounds.getWidth() + 4));
background.heightProperty().bind(textNode.layoutBoundsProperty().map(bounds -> bounds.getHeight()));
background.setArcWidth(2);
background.setArcHeight(2);
StackPane highlightedPane = new StackPane();
highlightedPane.getChildren().addAll(background, textNode);
hbox.getChildren().add(highlightedPane);
lastIndex = match.end;
}
// Добавляем оставшийся текст
if (lastIndex < text.length()) {
Text afterText = new Text(text.substring(lastIndex));
afterText.setFill(TEXT_COLOR);
hbox.getChildren().add(afterText);
}
return hbox;
}
/**
* Находит все совпадения всех паттернов в тексте
*/
private List<MatchInfo> findAllMatches(String text) {
List<MatchInfo> allMatches = new java.util.ArrayList<>();
if (text == null || text.isEmpty()) {
return allMatches;
}
for (Pattern pattern : highlightPatterns) {
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
allMatches.add(new MatchInfo(matcher.start(), matcher.end()));
}
}
// Объединяем перекрывающиеся совпадения
return mergeOverlappingMatches(allMatches);
}
/**
* Объединяет перекрывающиеся совпадения
*/
private List<MatchInfo> mergeOverlappingMatches(List<MatchInfo> matches) {
if (matches.isEmpty()) {
return matches;
}
List<MatchInfo> merged = new java.util.ArrayList<>();
matches.sort((a, b) -> Integer.compare(a.start, b.start));
MatchInfo current = matches.get(0);
for (int i = 1; i < matches.size(); i++) {
MatchInfo next = matches.get(i);
if (next.start <= current.end) {
// Перекрываются - объединяем
current = new MatchInfo(current.start, Math.max(current.end, next.end));
} else {
// Не перекрываются - сохраняем текущее и переходим к следующему
merged.add(current);
current = next;
}
}
merged.add(current);
return merged;
}
/**
* Информация о совпадении
*/
private static class MatchInfo {
final int start;
final int end;
MatchInfo(int start, int end) {
this.start = start;
this.end = end;
}
}
/**
* Конвертирует Color в hex строку
*/
private String toHexString(Color color) {
return String.format("#%02X%02X%02X",
(int) (color.getRed() * 255),
(int) (color.getGreen() * 255),
(int) (color.getBlue() * 255));
}
}

View File

@@ -0,0 +1,162 @@
package com.dsol.pki_management.modules.purchases;
import com.dsol.pki_management.config.AppConfig;
import com.dsol.pki_management.config.ConfigManager;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Клиент для работы с API MES
*/
public class MesApiClient {
private static final Logger logger = Logger.getLogger(MesApiClient.class.getName());
private static final int TIMEOUT_SECONDS = 10;
private final ConfigManager configManager;
private final HttpClient httpClient;
public MesApiClient() {
this.configManager = ConfigManager.getInstance();
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
.build();
}
/**
* Получает список строк для регулярных выражений из API MES
*
* @return Список строк для подсветки, или пустой список в случае ошибки
*/
public List<String> fetchMatchErrorStrings() {
String mesUrl = configManager.getProperty(AppConfig.KEY_MES_URL, "");
String matchErrorUrl = configManager.getProperty(AppConfig.KEY_MES_MATCH_ERROR_URL, "");
if (mesUrl == null || mesUrl.trim().isEmpty() ||
matchErrorUrl == null || matchErrorUrl.trim().isEmpty()) {
logger.warning("URL MES или URL Match Error не настроены");
return new ArrayList<>();
}
// Объединяем URL
String fullUrl = mesUrl.trim();
if (!fullUrl.endsWith("/") && !matchErrorUrl.startsWith("/")) {
fullUrl += "/";
}
fullUrl += matchErrorUrl.trim();
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(fullUrl))
.GET()
.timeout(Duration.ofSeconds(TIMEOUT_SECONDS))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return parseJsonResponse(response.body());
} else {
logger.warning("API MES вернул статус " + response.statusCode() + " для URL: " + fullUrl);
return new ArrayList<>();
}
} catch (IOException | InterruptedException e) {
logger.severe("Ошибка при запросе к API MES: " + e.getMessage());
e.printStackTrace();
return new ArrayList<>();
}
}
/**
* Парсит JSON ответ от API
* Ожидается массив строк: ["строка1", "строка2", ...]
*/
private List<String> parseJsonResponse(String jsonBody) {
List<String> result = new ArrayList<>();
try {
logger.info("Парсинг JSON ответа (длина: " + jsonBody.length() + "): " + jsonBody);
// Используем многострочный режим для обработки JSON с переносами строк
// Ищем строки в формате: "текст" (только двойные кавычки, как в стандартном JSON)
// Улучшенное регулярное выражение для обработки экранированных кавычек и многострочного режима
Pattern pattern = Pattern.compile("\"((?:[^\"\\\\]|\\\\.)*)\"", Pattern.MULTILINE | Pattern.DOTALL);
Matcher matcher = pattern.matcher(jsonBody);
while (matcher.find()) {
String value = matcher.group(1);
if (value != null) {
// Убираем пробелы в начале и конце, но сохраняем внутренние пробелы
value = value.trim();
if (!value.isEmpty()) {
// Декодируем Unicode escape-последовательности (\\uXXXX) в обычные символы
value = decodeUnicodeEscapes(value);
result.add(value);
logger.info("Извлечена строка для подсветки: '" + value + "'");
}
}
}
if (result.isEmpty()) {
logger.warning("Не удалось извлечь строки из JSON ответа. Исходный ответ: " + jsonBody);
// Попробуем альтернативный метод - простое извлечение строк между кавычками
Pattern simplePattern = Pattern.compile("\"([^\"]+)\"");
Matcher simpleMatcher = simplePattern.matcher(jsonBody);
while (simpleMatcher.find()) {
String value = simpleMatcher.group(1).trim();
if (!value.isEmpty()) {
// Декодируем Unicode escape-последовательности (\\uXXXX) в обычные символы
value = decodeUnicodeEscapes(value);
result.add(value);
logger.info("Извлечена строка альтернативным методом: '" + value + "'");
}
}
} else {
logger.info("Успешно извлечено строк для подсветки: " + result.size());
}
} catch (Exception e) {
logger.severe("Ошибка при парсинге JSON ответа от API MES: " + e.getMessage());
e.printStackTrace();
}
return result;
}
/**
* Декодирует Unicode escape-последовательности (\\uXXXX) в обычные символы
* Например: "\\u0437\\u0430\\u043c\\u0435\\u043d\\u0430" преобразуется в "замена"
*/
private String decodeUnicodeEscapes(String input) {
if (input == null || input.isEmpty()) {
return input;
}
// Ищем паттерн \\uXXXX (4 шестнадцатеричные цифры)
// Используем конкатенацию строк, чтобы избежать интерпретации \\u компилятором
String backslash = "\\";
String unicodePatternStr = backslash + backslash + "u([0-9a-fA-F]{4})";
Pattern unicodePattern = Pattern.compile(unicodePatternStr);
Matcher matcher = unicodePattern.matcher(input);
StringBuffer result = new StringBuffer();
while (matcher.find()) {
// Извлекаем код символа из шестнадцатеричного представления
int codePoint = Integer.parseInt(matcher.group(1), 16);
// Заменяем escape-последовательность на реальный символ
matcher.appendReplacement(result, new String(Character.toChars(codePoint)));
}
matcher.appendTail(result);
return result.toString();
}
}

View File

@@ -0,0 +1,32 @@
package com.dsol.pki_management.modules.purchases;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
public class PurchaseRow {
private final StringProperty type = new SimpleStringProperty("");
private final StringProperty nameFull = new SimpleStringProperty("");
private final StringProperty nameUPD = new SimpleStringProperty("");
private final StringProperty quantity = new SimpleStringProperty("");
private final StringProperty onePiece = new SimpleStringProperty("");
private final StringProperty stockCode = new SimpleStringProperty("");
private final StringProperty expectedDate = new SimpleStringProperty("");
private final StringProperty actualDate = new SimpleStringProperty("");
private final StringProperty invoice = new SimpleStringProperty("");
private final StringProperty orderNumber = new SimpleStringProperty("");
private final StringProperty redmine = new SimpleStringProperty("");
private final StringProperty owner = new SimpleStringProperty("");
public StringProperty typeProperty() { return type; }
public StringProperty nameFullProperty() { return nameFull; }
public StringProperty nameUPDProperty() { return nameUPD; }
public StringProperty quantityProperty() { return quantity; }
public StringProperty onePieceProperty() { return onePiece; }
public StringProperty stockCodeProperty() { return stockCode; }
public StringProperty expectedDateProperty() { return expectedDate; }
public StringProperty actualDateProperty() { return actualDate; }
public StringProperty invoiceProperty() { return invoice; }
public StringProperty orderNumberProperty() { return orderNumber; }
public StringProperty redmineProperty() { return redmine; }
public StringProperty ownerProperty() { return owner; }
}

View File

@@ -0,0 +1,242 @@
package com.dsol.pki_management.modules.purchases;
import com.dsol.pki_management.components.TableViewEnhancer;
import javafx.application.Platform;
import javafx.collections.ObservableList;
import javafx.collections.transformation.FilteredList;
import javafx.concurrent.Task;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.layout.VBox;
import java.util.List;
/**
* Контроллер для модуля "Закупочный перечень"
*/
public class PurchasesController {
@FXML
private VBox root;
@FXML
private Label titleLabel;
@FXML
private TableView<PurchaseRow> purchasesTable;
@FXML
private TableColumn<PurchaseRow, String> colType;
@FXML
private TableColumn<PurchaseRow, String> colNameFull;
@FXML
private TableColumn<PurchaseRow, String> colNameUPD;
@FXML
private TableColumn<PurchaseRow, String> colQty;
@FXML
private TableColumn<PurchaseRow, String> colOne;
@FXML
private TableColumn<PurchaseRow, String> colStockCode;
@FXML
private TableColumn<PurchaseRow, String> colExpectedDate;
@FXML
private TableColumn<PurchaseRow, String> colActualDate;
@FXML
private TableColumn<PurchaseRow, String> colInvoice;
@FXML
private TableColumn<PurchaseRow, String> colOrderNumber;
@FXML
private TableColumn<PurchaseRow, String> colRedmine;
@FXML
private TableColumn<PurchaseRow, String> colOwner;
// Фильтры
@FXML
private TextField filterType;
@FXML
private TextField filterNameFull;
@FXML
private TextField filterNameUPD;
@FXML
private TextField filterQty;
@FXML
private TextField filterOne;
@FXML
private TextField filterStockCode;
@FXML
private TextField filterExpectedDate;
@FXML
private TextField filterActualDate;
@FXML
private TextField filterInvoice;
@FXML
private TextField filterOrderNumber;
@FXML
private TextField filterRedmine;
@FXML
private TextField filterOwner;
private FilteredList<PurchaseRow> filteredData;
private MesApiClient mesApiClient;
private List<String> highlightStrings = List.of();
@FXML
public void initialize() {
if (titleLabel != null) {
titleLabel.setText("Закупочный перечень");
}
mesApiClient = new MesApiClient();
initTable();
initFilters();
// Подключаем таблицу к общему стору, чтобы отразить данные, загруженные при старте
ObservableList<PurchaseRow> allData = PurchasesDataStore.getInstance().getRows();
filteredData = new FilteredList<>(allData, p -> true);
purchasesTable.setItems(filteredData);
// Настраиваем расширенные возможности таблицы (выделение ячеек, копирование)
TableViewEnhancer.enhance(purchasesTable);
// Загружаем данные для подсветки из API в фоновом режиме
loadHighlightData();
}
private void initTable() {
colType.setCellValueFactory(new PropertyValueFactory<>("type"));
colNameFull.setCellValueFactory(new PropertyValueFactory<>("nameFull"));
colNameUPD.setCellValueFactory(new PropertyValueFactory<>("nameUPD"));
colQty.setCellValueFactory(new PropertyValueFactory<>("quantity"));
colOne.setCellValueFactory(new PropertyValueFactory<>("onePiece"));
colStockCode.setCellValueFactory(new PropertyValueFactory<>("stockCode"));
colExpectedDate.setCellValueFactory(new PropertyValueFactory<>("expectedDate"));
colActualDate.setCellValueFactory(new PropertyValueFactory<>("actualDate"));
colInvoice.setCellValueFactory(new PropertyValueFactory<>("invoice"));
colOrderNumber.setCellValueFactory(new PropertyValueFactory<>("orderNumber"));
colRedmine.setCellValueFactory(new PropertyValueFactory<>("redmine"));
colOwner.setCellValueFactory(new PropertyValueFactory<>("owner"));
purchasesTable.setPlaceholder(new Label("Нет данных"));
// Настраиваем кастомную ячейку с подсветкой для колонки "Наименование компонента (полное)"
// Данные для подсветки будут загружены асинхронно из API
updateNameFullCellFactory();
}
/**
* Обновляет cellFactory для колонки "Наименование компонента (полное)" с текущими данными для подсветки
*/
private void updateNameFullCellFactory() {
colNameFull.setCellFactory(column -> {
HighlightedTableCell cell = new HighlightedTableCell();
cell.setHighlightStrings(highlightStrings);
return cell;
});
}
/**
* Загружает данные для подсветки из API MES в фоновом режиме
*/
private void loadHighlightData() {
Task<List<String>> task = new Task<>() {
@Override
protected List<String> call() {
return mesApiClient.fetchMatchErrorStrings();
}
};
task.setOnSucceeded(e -> {
List<String> loadedStrings = task.getValue();
Platform.runLater(() -> {
// Сохраняем загруженные данные
highlightStrings = loadedStrings;
// Обновляем cellFactory с новыми данными
updateNameFullCellFactory();
// Принудительно обновляем колонку и таблицу
colNameFull.setVisible(false);
colNameFull.setVisible(true);
purchasesTable.refresh();
});
});
task.setOnFailed(e -> {
// В случае ошибки просто логируем, подсветка не будет работать
System.err.println("Ошибка при загрузке данных для подсветки: " + task.getException().getMessage());
if (task.getException() != null) {
task.getException().printStackTrace();
}
});
Thread thread = new Thread(task);
thread.setDaemon(true);
thread.start();
}
private void initFilters() {
// Настраиваем обработчики для всех фильтров
filterType.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterNameFull.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterNameUPD.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterQty.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterOne.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterStockCode.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterExpectedDate.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterActualDate.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterInvoice.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterOrderNumber.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterRedmine.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
filterOwner.textProperty().addListener((obs, oldVal, newVal) -> applyFilters());
}
private void applyFilters() {
if (filteredData == null) return;
filteredData.setPredicate(row -> {
if (!matchesFilter(filterType.getText(), row.typeProperty().get())) return false;
if (!matchesFilter(filterNameFull.getText(), row.nameFullProperty().get())) return false;
if (!matchesFilter(filterNameUPD.getText(), row.nameUPDProperty().get())) return false;
if (!matchesFilter(filterQty.getText(), row.quantityProperty().get())) return false;
if (!matchesFilter(filterOne.getText(), row.onePieceProperty().get())) return false;
if (!matchesFilter(filterStockCode.getText(), row.stockCodeProperty().get())) return false;
if (!matchesFilter(filterExpectedDate.getText(), row.expectedDateProperty().get())) return false;
if (!matchesFilter(filterActualDate.getText(), row.actualDateProperty().get())) return false;
if (!matchesFilter(filterInvoice.getText(), row.invoiceProperty().get())) return false;
if (!matchesFilter(filterOrderNumber.getText(), row.orderNumberProperty().get())) return false;
if (!matchesFilter(filterRedmine.getText(), row.redmineProperty().get())) return false;
if (!matchesFilter(filterOwner.getText(), row.ownerProperty().get())) return false;
return true;
});
}
/**
* Проверяет, соответствует ли значение ячейки фильтру.
* Поддерживает множественные значения через запятую (например: "Резистор,Конденсатор").
*
* @param filterText Текст фильтра (может содержать несколько значений через запятую)
* @param cellValue Значение ячейки для проверки
* @return true, если значение соответствует фильтру
*/
private boolean matchesFilter(String filterText, String cellValue) {
if (filterText == null || filterText.trim().isEmpty()) {
return true;
}
if (cellValue == null) {
return false;
}
String lowerCellValue = cellValue.toLowerCase();
// Разбиваем фильтр по запятой и проверяем каждое значение
String[] filterValues = filterText.split(",");
for (String filterValue : filterValues) {
String trimmedFilter = filterValue.trim();
if (!trimmedFilter.isEmpty()) {
// Если хотя бы одно значение из списка найдено - возвращаем true
if (lowerCellValue.contains(trimmedFilter.toLowerCase())) {
return true;
}
}
}
// Если ни одно значение не найдено - возвращаем false
return false;
}
}

View File

@@ -0,0 +1,42 @@
package com.dsol.pki_management.modules.purchases;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import java.time.Instant;
import java.util.List;
public class PurchasesDataStore {
private static final PurchasesDataStore INSTANCE = new PurchasesDataStore();
private final ObservableList<PurchaseRow> rows = FXCollections.observableArrayList();
private volatile String status = "";
private volatile Instant lastUpdated = null;
private PurchasesDataStore() {}
public static PurchasesDataStore getInstance() {
return INSTANCE;
}
public ObservableList<PurchaseRow> getRows() {
return rows;
}
public void setRows(List<PurchaseRow> newRows) {
rows.setAll(newRows);
lastUpdated = Instant.now();
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Instant getLastUpdated() {
return lastUpdated;
}
}

View File

@@ -0,0 +1,141 @@
package com.dsol.pki_management.modules.purchases;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* Парсер Excel для модуля закупок. Читает листы с корректными заголовками и
* формирует список PurchaseRow по колонкам.
*/
public class PurchasesExcelParser {
private static final int HEADER_ROW_INDEX = 3; // 4-я строка
private static final int FIRST_DATA_ROW_INDEX = 5; // 6-я строка (после пустой строки 5)
// Названия колонок, которые нужно загрузить
private static final List<String> NEEDED_HEADERS = List.of(
"Тип",
"Наименование компонента (полное)",
"Наименование в УПД",
"К-во",
"1 шт",
"Шифр Склад/Бухгалтерия",
"Ожидаемая дата",
"Фактическая дата",
"Накладная, №, дата",
"Номер заказа",
"Redmine",
"Ответственный"
);
public List<PurchaseRow> parse(Path xlsxPath) throws Exception {
if (xlsxPath == null || !Files.exists(xlsxPath)) {
return List.of();
}
List<PurchaseRow> result = new ArrayList<>();
try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = new XSSFWorkbook(in)) {
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
Sheet sheet = wb.getSheetAt(i);
Map<String, Integer> headerToIndex = readHeaderIndexes(sheet);
if (!headerToIndex.keySet().containsAll(NEEDED_HEADERS)) {
// Пропускаем листы без необходимых заголовков
continue;
}
// Читаем строки данных
int lastRow = sheet.getLastRowNum();
for (int r = FIRST_DATA_ROW_INDEX; r <= lastRow; r++) {
Row row = sheet.getRow(r);
if (row == null) continue;
// Признак пустой строки: все нужные ячейки пустые
if (isRowEmpty(row, headerToIndex)) {
continue;
}
PurchaseRow item = new PurchaseRow();
item.typeProperty().set(getCellString(row, headerToIndex.get("Тип")));
item.nameFullProperty().set(getCellString(row, headerToIndex.get("Наименование компонента (полное)")));
item.nameUPDProperty().set(getCellString(row, headerToIndex.get("Наименование в УПД")));
item.quantityProperty().set(getCellString(row, headerToIndex.get("К-во")));
item.onePieceProperty().set(getCellString(row, headerToIndex.get("1 шт")));
item.stockCodeProperty().set(getCellString(row, headerToIndex.get("Шифр Склад/Бухгалтерия")));
item.expectedDateProperty().set(getCellString(row, headerToIndex.get("Ожидаемая дата")));
item.actualDateProperty().set(getCellString(row, headerToIndex.get("Фактическая дата")));
item.invoiceProperty().set(getCellString(row, headerToIndex.get("Накладная, №, дата")));
item.orderNumberProperty().set(getCellString(row, headerToIndex.get("Номер заказа")));
item.redmineProperty().set(getCellString(row, headerToIndex.get("Redmine")));
item.ownerProperty().set(getCellString(row, headerToIndex.get("Ответственный")));
result.add(item);
}
}
}
return result;
}
private Map<String, Integer> readHeaderIndexes(Sheet sheet) {
Map<String, Integer> map = new HashMap<>();
if (sheet == null) return map;
Row headerRow = sheet.getRow(HEADER_ROW_INDEX);
if (headerRow == null) return map;
int lastCell = headerRow.getLastCellNum();
for (int c = 0; c < lastCell; c++) {
Cell cell = headerRow.getCell(c);
String text = getCellString(cell).trim();
if (!text.isEmpty()) {
map.put(text, c);
}
}
return map;
}
private boolean isRowEmpty(Row row, Map<String, Integer> headerToIndex) {
for (String header : NEEDED_HEADERS) {
Integer idx = headerToIndex.get(header);
if (idx == null) continue;
String v = getCellString(row, idx).trim();
if (!v.isEmpty()) return false;
}
return true;
}
private String getCellString(Row row, int columnIndex) {
if (columnIndex < 0) return "";
Cell cell = row.getCell(columnIndex, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
return getCellString(cell);
}
private String getCellString(Cell cell) {
if (cell == null) return "";
return switch (cell.getCellType()) {
case STRING -> cell.getStringCellValue();
case NUMERIC -> {
if (DateUtil.isCellDateFormatted(cell)) {
// Преобразуем дату в строку по умолчанию (yyyy-MM-dd)
java.util.Date d = cell.getDateCellValue();
yield new java.text.SimpleDateFormat("yyyy-MM-dd").format(d);
}
double val = cell.getNumericCellValue();
// Убираем .0 у целых чисел
if (Math.floor(val) == val) {
yield String.valueOf((long) val);
}
// Ограничиваем до 2 знаков после запятой
java.text.DecimalFormat df = new java.text.DecimalFormat("#.##");
df.setMaximumFractionDigits(2);
df.setMinimumFractionDigits(0);
yield df.format(val);
}
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
case FORMULA -> {
try {
yield cell.getStringCellValue();
} catch (IllegalStateException e) {
yield String.valueOf(cell.getNumericCellValue());
}
}
default -> "";
};
}
}

View File

@@ -0,0 +1,99 @@
package com.dsol.pki_management.modules.purchases;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* Валидатор Excel (XLSX) по шаблону закупок.
* Проверяет, что на каждой странице (Sheet) в строке 4 присутствуют необходимые заголовки.
*/
public class PurchasesExcelValidator {
// Строка с заголовками (0-based index)
private static final int HEADER_ROW_INDEX = 3; // 4-я строка
// Обязательные заголовки
private static final List<String> REQUIRED_HEADERS = List.of(
"Наименование компонента (полное)",
"Наименование в УПД",
"К-во",
"Шифр Склад/Бухгалтерия",
"Поставщик",
"Фактическая дата",
"Накладная, №, дата"
);
public static class SheetValidationResult {
public final String sheetName;
public final boolean valid;
public final List<String> missingHeaders;
public SheetValidationResult(String sheetName, boolean valid, List<String> missingHeaders) {
this.sheetName = sheetName;
this.valid = valid;
this.missingHeaders = missingHeaders;
}
}
/**
* Валидирует файл и возвращает результаты по всем листам
*/
public List<SheetValidationResult> validate(Path xlsxPath) throws IOException {
if (xlsxPath == null || !Files.exists(xlsxPath)) {
throw new IOException("Файл не найден: " + xlsxPath);
}
try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = new XSSFWorkbook(in)) {
List<SheetValidationResult> results = new ArrayList<>();
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
Sheet sheet = wb.getSheetAt(i);
SheetValidationResult r = validateSheet(sheet);
results.add(r);
}
return results;
}
}
private SheetValidationResult validateSheet(Sheet sheet) {
Set<String> headersInRow = readHeaderRow(sheet);
List<String> missing = new ArrayList<>();
for (String required : REQUIRED_HEADERS) {
if (!headersInRow.contains(required)) {
missing.add(required);
}
}
return new SheetValidationResult(sheet.getSheetName(), missing.isEmpty(), missing);
}
private Set<String> readHeaderRow(Sheet sheet) {
Set<String> headers = new HashSet<>();
if (sheet == null) return headers;
Row headerRow = sheet.getRow(HEADER_ROW_INDEX);
if (headerRow == null) return headers;
int lastCell = headerRow.getLastCellNum();
for (int c = 0; c < lastCell; c++) {
Cell cell = headerRow.getCell(c);
if (cell == null) continue;
String value = cellToString(cell).trim();
if (!value.isEmpty()) headers.add(value);
}
return headers;
}
private String cellToString(Cell cell) {
return switch (cell.getCellType()) {
case STRING -> cell.getStringCellValue();
case NUMERIC -> String.valueOf(cell.getNumericCellValue());
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
case FORMULA -> cell.getCellFormula();
default -> "";
};
}
}

View File

@@ -2,21 +2,26 @@ module com.dsol.pki_management {
requires javafx.controls;
requires javafx.fxml;
requires javafx.web;
requires java.logging;
requires java.net.http;
requires org.apache.poi.ooxml;
requires org.apache.poi.poi;
requires org.controlsfx.controls;
requires com.dlsc.formsfx;
requires org.kordamp.ikonli.javafx;
requires org.kordamp.bootstrapfx.core;
requires eu.hansolo.tilesfx;
opens com.dsol.pki_management.app to javafx.fxml;
opens com.dsol.pki_management.controllers to javafx.fxml;
opens com.dsol.pki_management.components to javafx.fxml;
opens com.dsol.pki_management.modules.test1 to javafx.fxml;
opens com.dsol.pki_management.modules.purchases to javafx.fxml;
exports com.dsol.pki_management.app;
exports com.dsol.pki_management.controllers;
exports com.dsol.pki_management.components;
exports com.dsol.pki_management.modules.test1;
exports com.dsol.pki_management.modules.purchases;
exports com.dsol.pki_management.config;
}

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.TableColumn?>
<?import javafx.scene.control.TableView?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.layout.HBox?>
<VBox fx:controller="com.dsol.pki_management.modules.purchases.PurchasesController"
xmlns="http://javafx.com/javafx/21"
xmlns:fx="http://javafx.com/fxml/1"
alignment="TOP_LEFT"
spacing="16.0"
style="-fx-background-color: white; -fx-padding: 24;">
<children>
<Label fx:id="titleLabel"
style="-fx-font-size: 24px; -fx-font-weight: bold; -fx-text-fill: #0d6efd;"
text="Закупочный перечень"/>
<!-- Строка фильтров -->
<HBox spacing="2.0" style="-fx-background-color: #f8f9fa; -fx-padding: 10 0 10 0; -fx-border-color: #dee2e6; -fx-border-width: 0 0 1 0;">
<TextField fx:id="filterType" prefWidth="100.0" minWidth="100.0" maxWidth="100.0" promptText="Тип"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterNameFull" prefWidth="260.0" minWidth="260.0" maxWidth="260.0" promptText="Наименование компонента (полное)"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterNameUPD" prefWidth="220.0" minWidth="220.0" maxWidth="220.0" promptText="Наименование в УПД"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterQty" prefWidth="70.0" minWidth="70.0" maxWidth="70.0" promptText="К-во"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterOne" prefWidth="70.0" minWidth="70.0" maxWidth="70.0" promptText="1 шт"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterStockCode" prefWidth="200.0" minWidth="200.0" maxWidth="200.0" promptText="Шифр Склад/Бухгалтерия"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterExpectedDate" prefWidth="140.0" minWidth="140.0" maxWidth="140.0" promptText="Ожидаемая дата"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterActualDate" prefWidth="140.0" minWidth="140.0" maxWidth="140.0" promptText="Фактическая дата"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterInvoice" prefWidth="180.0" minWidth="180.0" maxWidth="180.0" promptText="Накладная, №, дата"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterOrderNumber" prefWidth="140.0" minWidth="140.0" maxWidth="140.0" promptText="Номер заказа"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterRedmine" prefWidth="120.0" minWidth="120.0" maxWidth="120.0" promptText="Redmine"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
<TextField fx:id="filterOwner" prefWidth="160.0" minWidth="160.0" maxWidth="160.0" promptText="Ответственный"
style="-fx-background-color: white; -fx-border-color: #ced4da; -fx-border-width: 1; -fx-border-radius: 4; -fx-padding: 6 10 6 10; -fx-font-size: 12px;"/>
</HBox>
<TableView fx:id="purchasesTable" prefHeight="600.0" prefWidth="1200.0">
<columns>
<TableColumn fx:id="colType" text="Тип" prefWidth="100.0"/>
<TableColumn fx:id="colNameFull" text="Наименование компонента (полное)" prefWidth="260.0"/>
<TableColumn fx:id="colNameUPD" text="Наименование в УПД" prefWidth="220.0"/>
<TableColumn fx:id="colQty" text="К-во" prefWidth="70.0"/>
<TableColumn fx:id="colOne" text="1 шт" prefWidth="70.0"/>
<TableColumn fx:id="colStockCode" text="Шифр Склад/Бухгалтерия" prefWidth="200.0"/>
<TableColumn fx:id="colExpectedDate" text="Ожидаемая дата" prefWidth="140.0"/>
<TableColumn fx:id="colActualDate" text="Фактическая дата" prefWidth="140.0"/>
<TableColumn fx:id="colInvoice" text="Накладная, №, дата" prefWidth="180.0"/>
<TableColumn fx:id="colOrderNumber" text="Номер заказа" prefWidth="140.0"/>
<TableColumn fx:id="colRedmine" text="Redmine" prefWidth="120.0"/>
<TableColumn fx:id="colOwner" text="Ответственный" prefWidth="160.0"/>
</columns>
</TableView>
</children>
</VBox>

View File

@@ -4,6 +4,8 @@
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Separator?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.VBox?>
<VBox fx:controller="com.dsol.pki_management.controllers.SettingsController"
@@ -29,6 +31,21 @@
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;"
text="• Уведомления"/>
<!-- Закупки (из общих настроек) -->
<Label style="-fx-font-size: 16px; -fx-font-weight: bold; -fx-text-fill: #495057;"
text="Закупки"/>
<VBox spacing="10.0">
<children>
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;" text="Excel файл"/>
<HBox spacing="8.0">
<children>
<TextField fx:id="purchasesExcelPathField" promptText="Путь к Excel файлу (*.xlsx)" prefWidth="600.0" HBox.hgrow="ALWAYS"/>
<Button text="Выбрать..." onAction="#choosePurchasesExcelFile"/>
</children>
</HBox>
</children>
</VBox>
<Separator/>
<Button fx:id="adminAccessButton"
@@ -56,6 +73,20 @@
<Separator/>
<!-- Блок MES -->
<Label style="-fx-font-size: 16px; -fx-font-weight: bold; -fx-text-fill: #495057;"
text="MES"/>
<VBox spacing="10.0">
<children>
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;" text="URL MES"/>
<TextField fx:id="mesUrlField" promptText="https://example.com/api" prefWidth="600.0" HBox.hgrow="ALWAYS"/>
<Label style="-fx-font-size: 14px; -fx-text-fill: #495057;" text="URL Match Error"/>
<TextField fx:id="mesMatchErrorUrlField" promptText="/match-error" prefWidth="600.0" HBox.hgrow="ALWAYS"/>
</children>
</VBox>
<Separator/>
<Label style="-fx-font-size: 16px; -fx-font-weight: bold; -fx-text-fill: #495057;"
text="Управление видимостью элементов меню"/>
<VBox fx:id="subMenuItemsContainer" spacing="8.0">