From 38a8b06af2f659f35cb6aa8dd32f645e347a486b Mon Sep 17 00:00:00 2001 From: qsethuk Date: Tue, 11 Nov 2025 16:24:18 +0300 Subject: [PATCH] Add biggest update for UI. Create Ordering module --- pom.xml | 24 +- .../components/TableViewEnhancer.java | 393 ++++++++++++++++++ .../dsol/pki_management/config/AppConfig.java | 4 + .../controllers/MainController.java | 109 ++++- .../controllers/SettingsController.java | 97 ++++- .../purchases/HighlightedTableCell.java | 217 ++++++++++ .../modules/purchases/MesApiClient.java | 162 ++++++++ .../modules/purchases/PurchaseRow.java | 32 ++ .../purchases/PurchasesController.java | 242 +++++++++++ .../modules/purchases/PurchasesDataStore.java | 42 ++ .../purchases/PurchasesExcelParser.java | 141 +++++++ .../purchases/PurchasesExcelValidator.java | 99 +++++ src/main/java/module-info.java | 7 +- .../purchases/purchases-list-view.fxml | 66 +++ .../modules/settings/settings-view.fxml | 31 ++ 15 files changed, 1651 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/dsol/pki_management/components/TableViewEnhancer.java create mode 100644 src/main/java/com/dsol/pki_management/modules/purchases/HighlightedTableCell.java create mode 100644 src/main/java/com/dsol/pki_management/modules/purchases/MesApiClient.java create mode 100644 src/main/java/com/dsol/pki_management/modules/purchases/PurchaseRow.java create mode 100644 src/main/java/com/dsol/pki_management/modules/purchases/PurchasesController.java create mode 100644 src/main/java/com/dsol/pki_management/modules/purchases/PurchasesDataStore.java create mode 100644 src/main/java/com/dsol/pki_management/modules/purchases/PurchasesExcelParser.java create mode 100644 src/main/java/com/dsol/pki_management/modules/purchases/PurchasesExcelValidator.java create mode 100644 src/main/resources/com/dsol/pki_management/modules/purchases/purchases-list-view.fxml diff --git a/pom.xml b/pom.xml index 46d0e7d..07d5222 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ UTF-8 5.12.1 + 5.2.5 @@ -61,17 +62,19 @@ bootstrapfx-core 0.4.0 + + - eu.hansolo - tilesfx - 21.0.9 - - - org.openjfx - * - - + org.apache.poi + poi + ${poi.version} + + org.apache.poi + poi-ooxml + ${poi.version} + + org.junit.jupiter junit-jupiter-api @@ -103,10 +106,9 @@ 0.0.8 - default-cli - com.dsol.pki_management/com.dsol.pki_management.HelloApplication + com.dsol.pki_management.app/com.dsol.pki_management.app.PKIApplication app app app diff --git a/src/main/java/com/dsol/pki_management/components/TableViewEnhancer.java b/src/main/java/com/dsol/pki_management/components/TableViewEnhancer.java new file mode 100644 index 0000000..f49468f --- /dev/null +++ b/src/main/java/com/dsol/pki_management/components/TableViewEnhancer.java @@ -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, ObservableList>> selectedCellsMap = new java.util.concurrent.ConcurrentHashMap<>(); + + // Хранилище для отслеживания начальной позиции при drag selection + private static final java.util.Map, TablePosition> dragStartMap = new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * Настраивает таблицу для выделения ячеек и копирования + * @param tableView Таблица для настройки + * @param Тип данных строки таблицы + */ + public static void enhance(TableView tableView) { + if (tableView == null) { + return; + } + + // Создаем список для отслеживания выделенных ячеек + @SuppressWarnings("unchecked") + ObservableList> selectedCells = FXCollections.observableArrayList(); + selectedCellsMap.put(tableView, selectedCells); + + setupCellSelection(tableView); + setupCopyHandler(tableView); + } + + /** + * Настраивает режим выделения ячеек вместо строк + */ + @SuppressWarnings("unchecked") + private static void setupCellSelection(TableView tableView) { + TableSelectionModel selectionModel = tableView.getSelectionModel(); + + // Включаем выделение ячеек + selectionModel.setCellSelectionEnabled(true); + + // Разрешаем множественное выделение + selectionModel.setSelectionMode(SelectionMode.MULTIPLE); + + // Отслеживаем изменения выделения + ObservableList> trackedCells = selectedCellsMap.get(tableView); + + // Обработчик начала выделения (mouse pressed) + tableView.setOnMousePressed(event -> { + if (event.getTarget() instanceof TableCell) { + @SuppressWarnings("unchecked") + TableCell clickedCell = (TableCell) event.getTarget(); + TableRow row = clickedCell.getTableRow(); + + if (row != null) { + int clickedRow = row.getIndex(); + TableColumn clickedColumn = clickedCell.getTableColumn(); + + if (clickedRow >= 0 && clickedColumn != null) { + // Создаем начальную позицию для drag selection + TablePosition 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 currentSelection = trackedCells != null && !trackedCells.isEmpty() + ? (TablePosition) 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 col = tableView.getColumns().get(c); + selectionModel.select(r, col); + TablePosition pos = new TablePosition<>(tableView, r, col); + trackedCells.add(pos); + } + } + } + event.consume(); + } + } else if (event.isControlDown()) { + // Ctrl+Click: добавляем/удаляем ячейку из выделения + TablePosition 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 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 col = tableView.getColumns().get(c); + selectionModel.select(r, col); + TablePosition 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 boolean isNodeInTableView(TableView 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 void updateTrackedCells(TableView tableView, ObservableList> trackedCells) { + if (trackedCells == null) return; + + // Обновляем на основе текущего выделения в модели + // В режиме cell selection нужно проверять каждую ячейку отдельно + TableSelectionModel selectionModel = tableView.getSelectionModel(); + ObservableList selectedIndices = selectionModel.getSelectedIndices(); + + trackedCells.clear(); + + // Проходим по всем выделенным строкам и проверяем каждую колонку + for (Integer rowIndex : selectedIndices) { + if (rowIndex >= 0 && rowIndex < tableView.getItems().size()) { + for (TableColumn 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 pos = new TablePosition<>(tableView, rowIndex, column); + if (!trackedCells.contains(pos)) { + trackedCells.add(pos); + } + } + } catch (Exception e) { + // Если метод недоступен, добавляем все ячейки выделенных строк + // Это не идеально, но лучше чем ничего + TablePosition pos = new TablePosition<>(tableView, rowIndex, column); + if (!trackedCells.contains(pos)) { + trackedCells.add(pos); + } + } + } + } + } + } + + /** + * Настраивает обработчик копирования (Ctrl+C) + */ + private static void setupCopyHandler(TableView 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 void copySelectedCells(TableView tableView) { + TableSelectionModel selectionModel = tableView.getSelectionModel(); + + if (!selectionModel.isCellSelectionEnabled()) { + return; + } + + // Получаем отслеживаемые выделенные ячейки + ObservableList> trackedCells = selectedCellsMap.get(tableView); + + // Если список пуст или не синхронизирован, обновляем его + if (trackedCells == null || trackedCells.isEmpty()) { + if (trackedCells == null) { + trackedCells = FXCollections.observableArrayList(); + selectedCellsMap.put(tableView, trackedCells); + } + updateTrackedCells(tableView, trackedCells); + } + + // Используем отслеживаемые ячейки для копирования + List> 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 typedPos = (TablePosition) 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 Object getCellValue(TableView tableView, TablePosition 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 column = tableView.getColumns().get(pos.getColumn()); + if (column == null) { + return ""; + } + + // Получаем значение через cell value factory + Object cellValue = column.getCellData(rowItem); + return cellValue != null ? cellValue : ""; + } +} + diff --git a/src/main/java/com/dsol/pki_management/config/AppConfig.java b/src/main/java/com/dsol/pki_management/config/AppConfig.java index bcac8c5..18c1cf1 100644 --- a/src/main/java/com/dsol/pki_management/config/AppConfig.java +++ b/src/main/java/com/dsol/pki_management/config/AppConfig.java @@ -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"; diff --git a/src/main/java/com/dsol/pki_management/controllers/MainController.java b/src/main/java/com/dsol/pki_management/controllers/MainController.java index 7cbed6a..68ffa22 100644 --- a/src/main/java/com/dsol/pki_management/controllers/MainController.java +++ b/src/main/java/com/dsol/pki_management/controllers/MainController.java @@ -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 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 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(); + } + } /** * Загружает модуль настроек в центральную область diff --git a/src/main/java/com/dsol/pki_management/controllers/SettingsController.java b/src/main/java/com/dsol/pki_management/controllers/SettingsController.java index 0760057..b8d87f4 100644 --- a/src/main/java/com/dsol/pki_management/controllers/SettingsController.java +++ b/src/main/java/com/dsol/pki_management/controllers/SettingsController.java @@ -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 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 all = MainController.getAllMenuItems(); + Set 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(); } /** diff --git a/src/main/java/com/dsol/pki_management/modules/purchases/HighlightedTableCell.java b/src/main/java/com/dsol/pki_management/modules/purchases/HighlightedTableCell.java new file mode 100644 index 0000000..57c469a --- /dev/null +++ b/src/main/java/com/dsol/pki_management/modules/purchases/HighlightedTableCell.java @@ -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 { + private static final Color HIGHLIGHT_COLOR = Color.web("#ff4444"); // Красный цвет для подсветки + private static final Color TEXT_COLOR = Color.BLACK; + + // Статический кэш паттернов для переиспользования + private static List cachedPatterns = List.of(); + private static List cachedStrings = List.of(); + + private List highlightPatterns = List.of(); + + /** + * Устанавливает список строк для подсветки + * Из них будут сгенерированы регулярные выражения + * Оптимизировано: паттерны создаются один раз и кэшируются + */ + public void setHighlightStrings(List highlightStrings) { + if (highlightStrings == null || highlightStrings.isEmpty()) { + this.highlightPatterns = List.of(); + return; + } + + // Нормализуем список (trim и фильтрация) + List 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 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 findAllMatches(String text) { + List 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 mergeOverlappingMatches(List matches) { + if (matches.isEmpty()) { + return matches; + } + + List 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)); + } +} + diff --git a/src/main/java/com/dsol/pki_management/modules/purchases/MesApiClient.java b/src/main/java/com/dsol/pki_management/modules/purchases/MesApiClient.java new file mode 100644 index 0000000..ceef86b --- /dev/null +++ b/src/main/java/com/dsol/pki_management/modules/purchases/MesApiClient.java @@ -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 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 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 parseJsonResponse(String jsonBody) { + List 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(); + } +} + diff --git a/src/main/java/com/dsol/pki_management/modules/purchases/PurchaseRow.java b/src/main/java/com/dsol/pki_management/modules/purchases/PurchaseRow.java new file mode 100644 index 0000000..38d169f --- /dev/null +++ b/src/main/java/com/dsol/pki_management/modules/purchases/PurchaseRow.java @@ -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; } +} diff --git a/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesController.java b/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesController.java new file mode 100644 index 0000000..ee84d51 --- /dev/null +++ b/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesController.java @@ -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 purchasesTable; + @FXML + private TableColumn colType; + @FXML + private TableColumn colNameFull; + @FXML + private TableColumn colNameUPD; + @FXML + private TableColumn colQty; + @FXML + private TableColumn colOne; + @FXML + private TableColumn colStockCode; + @FXML + private TableColumn colExpectedDate; + @FXML + private TableColumn colActualDate; + @FXML + private TableColumn colInvoice; + @FXML + private TableColumn colOrderNumber; + @FXML + private TableColumn colRedmine; + @FXML + private TableColumn 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 filteredData; + private MesApiClient mesApiClient; + private List highlightStrings = List.of(); + + @FXML + public void initialize() { + if (titleLabel != null) { + titleLabel.setText("Закупочный перечень"); + } + + mesApiClient = new MesApiClient(); + initTable(); + initFilters(); + // Подключаем таблицу к общему стору, чтобы отразить данные, загруженные при старте + ObservableList 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> task = new Task<>() { + @Override + protected List call() { + return mesApiClient.fetchMatchErrorStrings(); + } + }; + + task.setOnSucceeded(e -> { + List 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; + } +} diff --git a/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesDataStore.java b/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesDataStore.java new file mode 100644 index 0000000..f87c514 --- /dev/null +++ b/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesDataStore.java @@ -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 rows = FXCollections.observableArrayList(); + private volatile String status = ""; + private volatile Instant lastUpdated = null; + + private PurchasesDataStore() {} + + public static PurchasesDataStore getInstance() { + return INSTANCE; + } + + public ObservableList getRows() { + return rows; + } + + public void setRows(List 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; + } +} diff --git a/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesExcelParser.java b/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesExcelParser.java new file mode 100644 index 0000000..bb05d77 --- /dev/null +++ b/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesExcelParser.java @@ -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 NEEDED_HEADERS = List.of( + "Тип", + "Наименование компонента (полное)", + "Наименование в УПД", + "К-во", + "1 шт", + "Шифр Склад/Бухгалтерия", + "Ожидаемая дата", + "Фактическая дата", + "Накладная, №, дата", + "Номер заказа", + "Redmine", + "Ответственный" + ); + + public List parse(Path xlsxPath) throws Exception { + if (xlsxPath == null || !Files.exists(xlsxPath)) { + return List.of(); + } + List 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 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 readHeaderIndexes(Sheet sheet) { + Map 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 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 -> ""; + }; + } +} diff --git a/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesExcelValidator.java b/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesExcelValidator.java new file mode 100644 index 0000000..d157a99 --- /dev/null +++ b/src/main/java/com/dsol/pki_management/modules/purchases/PurchasesExcelValidator.java @@ -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 REQUIRED_HEADERS = List.of( + "Наименование компонента (полное)", + "Наименование в УПД", + "К-во", + "Шифр Склад/Бухгалтерия", + "Поставщик", + "Фактическая дата", + "Накладная, №, дата" + ); + + public static class SheetValidationResult { + public final String sheetName; + public final boolean valid; + public final List missingHeaders; + + public SheetValidationResult(String sheetName, boolean valid, List missingHeaders) { + this.sheetName = sheetName; + this.valid = valid; + this.missingHeaders = missingHeaders; + } + } + + /** + * Валидирует файл и возвращает результаты по всем листам + */ + public List 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 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 headersInRow = readHeaderRow(sheet); + List 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 readHeaderRow(Sheet sheet) { + Set 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 -> ""; + }; + } +} diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 6a3d047..790aedd 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -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; } \ No newline at end of file diff --git a/src/main/resources/com/dsol/pki_management/modules/purchases/purchases-list-view.fxml b/src/main/resources/com/dsol/pki_management/modules/purchases/purchases-list-view.fxml new file mode 100644 index 0000000..1f9dbbd --- /dev/null +++ b/src/main/resources/com/dsol/pki_management/modules/purchases/purchases-list-view.fxml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + diff --git a/src/main/resources/com/dsol/pki_management/modules/settings/settings-view.fxml b/src/main/resources/com/dsol/pki_management/modules/settings/settings-view.fxml index 8a18782..a3922dc 100644 --- a/src/main/resources/com/dsol/pki_management/modules/settings/settings-view.fxml +++ b/src/main/resources/com/dsol/pki_management/modules/settings/settings-view.fxml @@ -4,6 +4,8 @@ + + + +