Дев-релиз — 2026-05-07

Дев-релиз — 2026-05-07

Добавлено

Журнал действий — полный аудит пользовательских действий

Подключён пакет owen-it/laravel-auditing и собрана единая таблица audits для всех действий в системе.

Что логируется:

  • CRUD моделей (created / updated / deleted / restored) — для всех 26 моделей: 15 журналов + справочники (Cabinet, Equipment, Disinfectant, Vaccine, CleaningType, Contractor, JournalTemplate, GeneralCleaningPlan, StructuralUnit) + системные (User, Role).
    • При удалении в old_values сохраняется полный снапшот строки (выполнение требования «полный дамп при удалении»).
    • При изменении сохраняется diff: old_valuesnew_values только по изменённым атрибутам.
    • При создании в new_values лежит весь снапшот новой записи.
    • Из значений принудительно исключены signed_payload, signature, certificate, password, remember_token.
  • Аутентификация: успешный вход (login), выход (logout), неудачный вход (login_failed) — с IP, user-agent, URL.
  • ЭЦП: подписание журнала (signed) с привязкой к конкретной записи и подписавшему пользователю.
  • Печать: каждое открытие печатной формы журнала (printed) с кодом журнала и фильтрами.
  • RBAC: role_attached / role_detached / permission_attached / permission_detached — кто, кому и что выдал/отозвал.

Append-only:

  • В Eloquent-модели Audit запрещены update и delete — обе операции выкидывают LogicException. Через MoonShine UI редактирование/удаление лога недоступно (ресурс открыт только во view).
  • На уровне БД для дополнительной защиты нужно один раз выполнить REVOKE UPDATE, DELETE ON audits FROM <db_user> (комментарий с инструкцией оставлен в миграции). Это задача DevOps на dev/prod.

Просмотр в админке:

  • /resource/audit-resource/audit-index-page — список с фильтрами по периоду, пользователю, действию, типу объекта.
  • queryTags «Сегодня» (по умолчанию) / «За неделю» / «За месяц» / «Все».
  • Страница детализации с pretty-print diff old_valuesnew_values, IP, user-agent, URL.
  • Сортировка id DESC (новые сверху).
  • Доступ — только super-admin (роль id=1). Пункт меню «Журнал действий» в группе «Система» виден только этой роли.

Хранение и ретеншн:

  • Таблица audits в Postgres с jsonb для значений и индексами по event и created_at.
  • Авторазмер не настроен — храним всё, чистим вручную при необходимости.

Изменено

Сортировка журналов по умолчанию: всегда самые свежие записи сверху

Раньше сортировка по умолчанию была неконсистентна между журналами. Теперь во всех журналах сортировка по умолчанию id DESC, то есть сверху всегда самая свежая по факту ввода запись — даже если её внесли задним числом за прошлую дату.

Журналы УФ-обеззараживания и текущих уборок: «Сегодня» по умолчанию

В журналах УФ-обеззараживания и текущих уборок запрос-фильтр по умолчанию переключён с «За месяц» на «Сегодня» — оператор сразу видит сегодняшние записи.

Дашборд: натуральная сортировка кабинетов в фильтре

В выпадающем списке «Кабинет» на странице статистики раньше был лексикографический порядок: «Кабинет №1, №10, №11, …, №2, №20, №3». Теперь — натуральный: №1, №2, №3, …, №9, №10, №11, …

Журнал генеральных уборок: автор подставляется только при внесении факта

Раньше при создании плана через клик на календаре в author_id записывался текущий пользователь — некорректно, потому что план не означает, что именно этот человек проведёт уборку.

Теперь:

  • При клике на день в календаре создаётся «голый» план: только cabinet_id + planned_date. author_id = NULL, actual_date = NULL.
  • В форме поле «Провел уборку» скрыто при создании — оно появляется только при редактировании, когда оператор вносит факт уборки.
  • В БД general_cleaning_entries.author_id сделан nullable. Подписать ЭЦП незаполненную запись по-прежнему нельзя — getSignPayload() падает без автора.

Окно редактирования журналов — пер-полевая блокировка

Раньше окно редактирования (поле «Окно редактирования журналов, ч» в карточке пользователя) блокировало запись целиком: после истечения часов карандаш редактирования исчезал и форму нельзя было открыть. Это создавало две проблемы:

  • Запланированные генеральные уборки: запись создаётся при планировании на месяц вперёд, к моменту реального выполнения окно давно истекло — поставить actual_date нельзя без super-admin.
  • Журналы с утром/вечером (t°/влажность, холодильники): если утром сохранили только утренний замер, вечером дозаполнить уже нельзя.

Теперь логика такая:

  • Карандаш редактирования всегда виден, форма всегда открывается.
  • Внутри окна редактирования — все поля доступны, как и раньше.
  • После истечения окна — каждое поле, у которого есть непустое значение, становится readonly. Пустые поля остаются доступны для дозаполнения.
  • Полностью пустой подблок (например, measurements.evening) считается пустым целиком — все его внутренние поля доступны для ввода.
  • super-admin не имеет автоматического обхода окна — поведение совпадает с прежним. При необходимости фикса старых записей выставите edit_window_hours побольше у конкретного пользователя в его карточке.

Это автоматически решает оба сценария:

  • В запланированной ген. уборке actual_date пустой → его можно поставить когда угодно. После заполнения и истечения окна — заблокируется.
  • В журнале t°/влажности с заполненным только утром — поля «Вечер» доступны для дозаполнения; «Утро» уже заблокировано.
  • В полностью заполненной записи (УФ, текущая уборка) после окна — все поля заблокированы (то же поведение, что раньше, только пенсель виден и можно открыть для просмотра).

Скрытие иконки редактирования в списках. Если запись полностью заблокирована (окно истекло и все её поля уже заполнены), иконка карандаша в индексной таблице скрывается — нет смысла открывать форму, в которой нечего менять. Если в записи остались пустые поля (пустой «Вечер» в t°/влажности, незаполненный actual_date в плане ген. уборки) — карандаш остаётся видимым.

Поведение по журналам:

Журнал Карандаш виден после истечения окна
t°/влажности, холодильники пока хотя бы один из подблоков morning/evening пуст
Ген. уборки пока actual_date не установлен
Текущие уборки, УФ, биопсии, прививки и др. нет (полная блокировка)

Скрытие вспомогательных контролов на read-only форме. В журнале текущих уборок есть JS-кнопки «Утро 8:00» и «Вечер 16:00» для быстрой подстановки времени. На read-only форме они теряют смысл и теперь не показываются (через хелпер AbstractJournalFormPage::isReadOnlyMode()).

Серверная защита от подделки POST. Для top-level полей (BelongsTo, Date, Number и т.п.) применяется тройная блокировка: readonly + disabled + canApply(fn => false) — даже если значение в POST подменили через DevTools, MoonShine проигнорирует изменения и сохранит исходное из БД. Для полей внутри JSON-подобъектов (Json::object()->fields([...])) применяется только readonlydisabled/canApply(false) нельзя, иначе значения подблока выпадут из POST и MoonShine соберёт JSON без них (тем самым стерев заблокированное «утро» при сохранении «вечера»).

Корректное определение «заполнено». Для BelongsTo берётся реальный foreign key через getForeignKeyName() (раньше эвристика $column.'_id' промахивалась мимо camelCase relations типа cleaningTypecleaning_type_id). Для BelongsToManyrelation->exists(). Для JSON-подблоков — рекурсивный обход.

Реализация — без миграций: трейт WithJournalEditWindow упрощён до no-op (deprecated), вся логика теперь в AbstractJournalFormPage::modifyFormComponent через хелпер App\Support\JournalEditWindow. Рекурсивно обходит дерево полей формы (включая Json::object()->fields([...])). Скрытие карандаша — в AbstractJournalIndexPage::modifyEditButton через canSee callback с переопределяемым isRecordFullyLocked().

WasteDisposalEntryFormPage приведён к наследованию от AbstractJournalFormPage для единообразия.

Исправлено

«Обход кабинетов»: 404 после сохранения записи журнала

После сохранения формы пользователь, перешедший в форму через «Обход кабинетов», должен возвращаться на экран кабинета. URL возврата строился вручную с неправильным ключом конфига (moonshine.route.prefix вместо moonshine.prefix), и из-за дефолта получался захардкоженный префикс admin/page/.... Если реальный префикс MoonShine пустой — пользователь после сохранения попадал на 404. Теперь URL берётся из getUrl() страницы CabinetRoundPage.

Журнал действий: TypeError в индексной странице

Замыкания-форматтеры Preview::make / Text::make принимали string $value, тогда как MoonShine передаёт первым аргументом не значение колонки, а всю модель строки — сигнатура (mixed $original, int $index, static $ctx). Исправлено и в индексной, и в детальной странице AuditResource.

Миграции

Релиз требует выполнить php artisan migrate на dev/prod. Новые миграции:

  • 2026_05_07_165231_create_audits_table — основная таблица аудита.
  • 2026_05_07_165300_make_audits_auditable_nullable — делает auditable_* nullable, чтобы можно было логировать failed login и печать без конкретного объекта.
  • 2026_05_07_180000_make_general_cleaning_entries_author_nullable — делает author_id nullable, чтобы план можно было создавать без автора.

Действия для DevOps

После применения миграций на dev/prod выполнить один раз для гарантии append-only лога:

REVOKE UPDATE, DELETE ON audits FROM <db_user>;

Где <db_user> — учётка приложения, под которой ходит Laravel.