Co nowego:
- Podgląd logów w panelu admina (Admin > Logi) — pliki storage/logs/*.log
  bez potrzeby dostępu do kontenera, z filtrami poziomu/tekstu/liczby wpisów
  i auto-odświeżaniem.
- Filtr „Bez kategorii” w kolejce operatora — izoluje zgłoszenia bez
  przypisanej kategorii/podkategorii.
- Narzędzie importu z Heska: już nie tworzy automatycznie kont klientów dla
  nieznanych e-maili (pomija takie zgłoszenia zamiast zakładać konto),
  łączy odpowiedzi/właścicieli zgłoszeń z realnymi kontami operatorów po
  e-mailu, nowe flagi --assign-operators i --fix-closed-dates do
  donaprawiania wcześniejszych importów, dedykowany log
  storage/logs/hesk-import.log.
- Poprawka: pulpit statystyk operatora (rozkład wg kategorii/podkategorii i
  filtr kategorii) pomijał zgłoszenia przypisane do samej kategorii bez
  podkategorii (np. z poczty IMAP) — teraz liczone poprawnie.
- Poprawka: błąd JS i zawieszone w tle liczniki przy nawigacji z widoku z
  aktywnym licznikiem (najbardziej odczuwalne w liczniku czasu pracy
  operatora).
- Porządki w bazie: usunięte niewykorzystywane kolumny
  (users.remember_token, users.email_verified_at,
  email_templates.trigger_label); wartości pól dodatkowych, stan
  triage/podsumowania AI i powiązany sprzęt Snipe-IT przeniesione z tabeli
  tickets do osobnych tabel (ticket_field_values, ticket_ai_summaries,
  ticket_snipeit_assets) — bez zmiany zachowania, ale pola dodatkowe są
  teraz efektywnie przeszukiwalne; dodane brakujące indeksy na 4 tabelach
  pivot; tickets.source/ticket_messages.source walidowane względem znanego
  zestawu wartości.

Zaktualizowana dokumentacja: README, CLAUDE.md, ARCHITECTURE.md,
CHANGELOG.md, wiki/admin, wiki/operator.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:28:03 +02:00
parent 03c6ec7cae
commit 4b70b910a9
45 changed files with 2038 additions and 156 deletions

View File

@@ -36,6 +36,9 @@ Category ─< Subcategory ─< CustomField (per-subcategory custom fields
├──< TicketMessage (public replies + internal notes)
├──< TicketAttachment
├──< TicketHistory
├──< TicketFieldValue (queryable custom_fields values, kept in sync)
├── aiSummary → TicketAiSummary (1:1, triage+summary state)
├── snipeitAsset → TicketSnipeitAsset (1:1, linked asset)
├── customer/assignee → User
├── status → Status (fixed stages: new/open/closed)
├── priority → Priority → SlaRule (response/resolution minutes)
@@ -63,6 +66,38 @@ queue + unassigned + anything assigned to them, an admin sees everything), and
work-timer tracking (`timerElapsedSeconds()`). Keep ticket-shaped logic here
rather than spreading it across Livewire components.
**Virtual `ai_*`/`snipeit_*` attributes.** The AI triage/summary fields
(`ai_triaged_at`, `ai_summary`, `ai_suggested_action`, `ai_summary_generated_at`)
and the Snipe-IT link (`snipeit_asset_id`, `snipeit_asset_name`) are **not**
real columns on `tickets` — they live on the related `TicketAiSummary`/
`TicketSnipeitAsset` rows shown in the diagram above (each table's own columns
drop the prefix, e.g. `ticket_ai_summaries.summary`). `Ticket` overrides
`getAttribute()`/`setAttribute()` (see `AI_SUMMARY_FIELD_MAP`/
`SNIPEIT_FIELD_MAP`) so every existing `$ticket->ai_summary`/
`$ticket->update(['snipeit_asset_id' => ...])` call site keeps working
unchanged against the new tables — the same pattern `TicketMessage` already
uses for its own virtual `role`/`author_id`. A write is queued
(`$pendingVirtualAttributes`) and flushed into the related row's
`updateOrCreate()` on the model's `saved` event, since a brand-new ticket has
no id yet to key the related row on until that point. If you add a new
`ai_*`/`snipeit_*` field, add it to the relevant `FIELD_MAP` rather than to
`tickets` directly.
**Custom field values.** `tickets.custom_fields` (a JSON blob, `field.id =>
value`) stays the source of truth for reads/writes — `TicketFieldValue`
(`ticket_field_values`, one row per non-blank entry) is a queryable mirror
kept in sync automatically by `Ticket::syncFieldValues()` (called from the
same `saved` hook whenever `custom_fields` changes), so reporting can
filter/join on "tickets where custom field X = Y" without scanning JSON.
Nothing else needs to write to `ticket_field_values` directly.
**`source` validation.** `Ticket::SOURCES`/`TicketMessage::SOURCES` are the
only values ever allowed in `tickets.source`/`ticket_messages.source`
(`'web'`/`'email'`/`'hesk_import'`; `null` still means "web" for messages) —
enforced by a `saving` listener that throws `InvalidArgumentException` on
anything else, so a typo'd literal fails loudly instead of sticking silently.
Add new values to the constant before writing them anywhere.
## Ticket numbering & URLs
A ticket carries three distinct identifiers, each with a different job:
@@ -449,6 +484,26 @@ Requires the same external `schedule:run` cron entry as SLA/automation (see
[CLAUDE.md](CLAUDE.md)) — without it, only the manual "Pobierz teraz" button
does anything.
## Log channels & the admin log viewer
`config/logging.php` defines three dedicated channels alongside the app's
default one, each daily/14-day-retention and always `debug` level regardless
of `.env`'s `LOG_LEVEL` (so they stay useful even when the app itself runs at
`error`): `imap` (`storage/logs/imap-*.log` — see "IMAP e-mail intake"
above), `ai` (`storage/logs/ai.log` — every `ai:run-ticket-automation` run,
used by both `TicketAiTriageService` and `TicketAiSummaryService`, plus
`AiClient`'s own request/response/failure logging), and `hesk_import`
(`storage/logs/hesk-import.log` — every `hesk:import` run). `Admin\Logs`
(`app/Livewire/Admin/Logs.php`, Admin > Logi) is a read-only viewer over
`storage/logs/*.log` (any file, not just these three) — it reads only the
last 4 MB of a file to bound memory on large ones, splits raw log text back
into individual entries by the `[YYYY-MM-DD HH:MM:SS]` line prefix (so a
multi-line stack trace stays grouped with the line that started it), and
offers level/free-text/entry-count filters plus an optional `wire:poll.5s`
auto-refresh. `selectedFile` is validated against the real glob'd file list
on every read, not trusted as a path — a crafted value (e.g. `../../.env`)
is silently ignored rather than read.
## API
`routes/api.php` + `app/Http/Controllers/Api/` expose a small ability-scoped REST
@@ -592,9 +647,12 @@ Blade component renders that shape everywhere an asset list shows up
skips its own wrapping `<div class="card">` when embedded inside a
caller-provided one (the inventory-search box + its results share one card).
A linked ticket only stores `tickets.snipeit_asset_id` + a cached
`snipeit_asset_name` label (`TicketService::setSnipeitAsset()`, which also
writes a ticket-history line)no other Snipe-IT fields are persisted.
A linked ticket only stores an `asset_id` + a cached `asset_name` label on
the related `ticket_snipeit_assets` row (`TicketService::setSnipeitAsset()`,
which also writes a ticket-history line — see "Virtual `ai_*`/`snipeit_*`
attributes" above for how this reads/writes as `$ticket->snipeit_asset_id`
despite not being a `tickets` column) — no other Snipe-IT fields are
persisted.
Anywhere a linked asset's live detail is shown (the "Powiązany sprzęt" card),
it's re-fetched fresh via `SnipeItClient::asset($id)` rather than trusted
from the cache, so a status/reassignment change made directly in Snipe-IT is
@@ -617,7 +675,9 @@ live customer submitting a ticket:
state (no category/subcategory at all → assign both; category but no
subcategory → pick one within it; already has a subcategory → recheck and
possibly correct), independently of the subject/priority toggles. Every
scanned ticket gets `tickets.ai_triaged_at` stamped exactly once — this is
scanned ticket gets `ai_triaged_at` stamped exactly once (on the related
`ticket_ai_summaries` row, not `tickets` itself — see "Virtual
`ai_*`/`snipeit_*` attributes" above) — this is
a one-shot pass, not a continuous recheck, and there's deliberately no
manual per-ticket re-trigger. Resolution is fail-closed the same way as the
BookStack tagger: every value the model returns is matched against the
@@ -637,8 +697,8 @@ live customer submitting a ticket:
per pass.
- **`App\Services\TicketAiSummaryService`** — a summary + suggested next
action for **every** ticket (gated by a single `ai_summary_enabled`
toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/
`ai_summary_generated_at` and shown only in the operator ticket view (a
toggle), cached on the related `ticket_ai_summaries` row's `summary`/
`suggested_action`/`summary_generated_at` and shown only in the operator ticket view (a
"Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the
BookStack suggestions card next to it). `run()` (the scheduled sweep)
regenerates whenever a ticket's latest message postdates its last summary