Initial commit

This commit is contained in:
2026-09-03 12:21:17 +02:00
commit 6d40da0410
155 changed files with 21554 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4
+40
View File
@@ -0,0 +1,40 @@
APP_NAME=SoftSPM
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
APP_LOCALE=pl
APP_FALLBACK_LOCALE=pl
APP_TIMEZONE=Europe/Warsaw
LOG_CHANNEL=stack
LOG_LEVEL=debug
# Database-first: nie tworzymy tabel pomocniczych w bazach legacy,
# dlatego sesje/cache/kolejki lecą na pliki (NIE uruchamiamy migracji).
SESSION_DRIVER=file
SESSION_LIFETIME=120
CACHE_STORE=file
QUEUE_CONNECTION=sync
DB_CONNECTION=softspm
# Baza operacyjna (domyślna)
DB_HOST=10.42.1.210
DB_PORT=3306
DB_DATABASE=softspm
DB_USERNAME=przemek
DB_PASSWORD=
# Bazy raportowa i historyczna (ten sam host/user, inna nazwa bazy)
DB_REP_DATABASE=softspm_rep
DB_HIS_DATABASE=softspm_his
# Migracja haseł MD5->bcrypt - dopiero po zgodzie klienta.
SOFTSPM_REHASH=false
# Integracja SYNDIS (SOAP) - IP w LAN produkcyjnym.
SYNDIS_WSDL=http://172.21.11.101/SyndisSpm.wsdl
SYNDIS_LOCATION=http://172.21.11.101:3615/
VITE_APP_NAME="${APP_NAME}"
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+30
View File
@@ -0,0 +1,30 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
# SoftSPM: fonty i obrazy są pobierane/kopiowane przy starcie środowiska
# (patrz README_SOFTSPM.md → "Środowisko lokalne (Docker)"). Trzymamy je
# w repo świadomie - produkcja jest air-gap i nie pobierze ich sama.
!/public/fonts
!/public/images
+233
View File
@@ -0,0 +1,233 @@
# SoftSPM_2 - aplikacja Laravel (nakładka + szkielet)
Historycznie ten katalog trzymał **tylko pliki specyficzne dla SoftSPM** (modele,
kontrolery, widoki, konfiguracja, trasy), a czysty szkielet Laravela stał osobno
na UAT jako `softspm_pge`. Od 2026-09-03 katalog jest **pełnym, uruchamialnym
projektem Laravel** - szkielet został domieszany lokalnie, żeby dało się pracować
i testować na stacji roboczej.
Lista plików nakładki (to, co nakładamy na szkielet na UAT/produkcji) jest
zapisana w [`docker/overlay-files.txt`](docker/overlay-files.txt).
> Migracja legacy PHP 5.4/5.6 → Laravel.
> **Źródło migracji: `C:\Programy\xampp_old\htdocs\trzeciCloude_v1\`** - NIE `newPGE`
> ani inne kopie w `htdocs/`. Audyt legacy i mapa `siteNNN` → trasy: [docs/LEGACY.md](docs/LEGACY.md).
> Kontekst i decyzje: patrz pamięć
> projektu Claude. Charakterystyka: 3 bazy MariaDB, **produkcja bez internetu
> (air-gap, LAN)**.
> **Wersja frameworka: Laravel 12** (nie 11). Laravel 11 jest EOL - Composer
> odmawia instalacji każdego wydania 11.31+ z powodu niezałatanych advisory
> bezpieczeństwa, a nowszych 11.x już nie ma. **UAT/produkcję trzeba podnieść
> do 12.** Kod nakładki nie wymagał żadnych zmian przy tym przejściu.
> **Tailwind v4**, konfigurowany w CSS (`@theme` w `resources/css/app.css`).
> Pliku `tailwind.config.js` **nie ma i nie powinno być** - starsze fragmenty
> tego README, które go wymieniają, są nieaktualne.
### Środowiska
| Środowisko | OS | Ścieżka | Internet |
|---|---|---|---|
| **UAT / testowe** | Debian | `/var/www/html/softspm_pge` | jest (build assetów tutaj) |
| **Produkcja** | Windows Server | (web root → `public/`) | **BRAK (air-gap, LAN)** |
> **Debian = case-sensitive!** Nazwy plików/ścieżek (komponenty Blade, fonty,
> obrazki) muszą zgadzać się co do wielkości liter. Trzymamy wszystko lowercase.
## Środowisko lokalne (Docker)
Stacja robocza ma tylko PHP 5.6 i 7.4 (XAMPP), a Laravel 12 wymaga PHP ≥ 8.2 -
dlatego dev stoi w kontenerze. **Baza NIE jest kontenerem**: łączymy się do
istniejącej MariaDB `10.42.1.210` (podejście database-first).
Kontener: `docker/Dockerfile` (PHP 8.3 + Apache + Node 20, rozszerzenia
`pdo_mysql mysqli soap zip gd intl opcache`; `soap` jest wymagany przez
`SyndisService`). DocumentRoot → `public/`.
```bash
docker compose up -d app # aplikacja na http://localhost:8000
docker compose exec app php artisan ... # artisan
docker compose exec app composer ... # composer
docker compose exec app npm run build # produkcyjny build assetów
docker compose --profile dev up vite # opcjonalny hot-reload (port 5173)
```
Pierwsze uruchomienie na czystej maszynie:
```bash
cp .env.example .env # uzupełnić DB_PASSWORD
docker compose build app
docker compose up -d app
docker compose exec app composer install
docker compose exec app npm install
docker compose exec app npm run build
docker compose exec app php artisan key:generate
docker compose exec app chmod -R 777 storage bootstrap/cache
docker compose exec app php docker/check/db-check.php # smoke-test 3 połączeń
```
`php artisan migrate` **nie jest uruchamiane** - sesje i cache lecą na pliki,
schematu legacy nie dotykamy.
### Zasoby binarne (air-gap)
Nie da się ich pobrać na produkcji, więc leżą w repo (`.gitignore` ma na nie wyjątki):
| Plik | Skąd |
|---|---|
| `public/images/logo.png`, `dashboard_01.png`, `dashboard_02.png` | makiety `newPGE/uj_dashboard/` (jedyna rzecz, po jaką sięgamy do `newPGE` - kodu legacy szukamy w `trzeciCloude_v1`) |
| `public/fonts/inter/Inter-Variable.woff2` | fontsource (Inter VF, latin) |
| `public/fonts/jetbrains-mono/JetBrainsMono-Variable.woff2` | fontsource (JetBrains Mono VF, latin) |
| `public/fonts/material-symbols/MaterialSymbolsOutlined.woff2` | fontsource (Material Symbols Outlined VF) |
Vite przy buildzie ostrzega, że `/fonts/...` nie rozwiązuje się w czasie budowania -
to jest **oczekiwane**: ścieżki są absolutne i obsługuje je serwer z `public/`.
## Jak nałożyć na `softspm_pge` (UAT, Debian)
1. Skopiować pliki nakładki (lista: `docker/overlay-files.txt`) do
`/var/www/html/softspm_pge/`, nadpisując m.in. `config/database.php`,
`config/navigation.php`, `config/softspm.php`, `routes/web.php`,
`resources/`, `package.json`, `vite.config.js`. `vendor/` i `node_modules/`
zostają; z `public/` kopiujemy tylko `images/` i `fonts/`.
2. Uprawnienia (Debian): `chown -R www-data:www-data storage bootstrap/cache`.
3. Front-end (Tailwind v4 przez `@tailwindcss/vite`, konfiguracja w
`resources/css/app.css`; NIE ma `tailwind.config.js` ani `postcss.config.js`):
```
npm install
npm run build # produkcyjny build do public/build
```
4. `.env` (sekcja niżej) + `php artisan config:cache`. **NIE** uruchamiać
`php artisan migrate` - sesje/cache trzymamy na plikach (patrz `.env`), więc
nie tworzymy żadnych tabel; legacy schematu nie dotykamy.
## Zmienne `.env`
```dotenv
APP_NAME=SoftSPM
APP_LOCALE=pl
# Database-first + brak potrzeby tabel pomocniczych → sesje/cache na plikach.
# Dzięki temu NIE uruchamiamy migracji i nie dorzucamy tabel do baz legacy.
SESSION_DRIVER=file
CACHE_STORE=file
QUEUE_CONNECTION=sync
DB_CONNECTION=softspm
# Baza operacyjna (domyślna)
DB_HOST=10.42.1.210
DB_PORT=3306
DB_DATABASE=softspm
DB_USERNAME=przemek
DB_PASSWORD=visia2209
# Bazy raportowa i historyczna (ten sam host/user, inna nazwa bazy)
DB_REP_DATABASE=softspm_rep
DB_HIS_DATABASE=softspm_his
# Migracja haseł MD5->bcrypt - włączyć DOPIERO po rozszerzeniu
# kolumny sys_users.USR_PASSWORD do VARCHAR(255).
SOFTSPM_REHASH=false
```
## Air-gap (produkcja bez internetu) - zasady
- **Zero CDN w runtime.** Tailwind kompilujemy lokalnie (Vite). W makietach
(`uj_dashboard/`) Tailwind/fonty szły z CDN - to tylko podgląd.
- **Fonty self-hosted.** Pobrać i umieścić w `public/fonts/` (na maszynie z
netem), zgodnie z `@font-face` w `resources/css/app.css`:
- `public/fonts/inter/Inter-Variable.woff2`
- `public/fonts/jetbrains-mono/JetBrainsMono-Variable.woff2`
- `public/fonts/material-symbols/MaterialSymbolsOutlined.woff2`
- **Tło logowania:** `public/images/login-bg.jpg` (zamiast obrazka z Google
w makiecie - można użyć `trzeciCloude_v1/images/tlo.jpg`).
- **Build na UAT (Debian, ma internet), wdrożenie na Windows (air-gap):**
assety i zależności budujemy na UAT (`npm run build`, `composer install`),
a na produkcję Windows kopiujemy **gotowy artefakt** - pełny `vendor/` +
skompilowane `public/build/` + self-hostowane fonty/obrazki. Na produkcji
**bez** `composer install` / `npm install` (brak internetu i często Node).
- **Web root produkcji (Windows):** serwer (IIS/Apache) musi wskazywać podkatalog
`public/`, nie korzeń projektu.
## Co już jest (pierwszy pionowy slice)
| Plik | Rola |
|---|---|
| `config/database.php` | 3 połączenia MariaDB (database-first, bez zmiany schematu) |
| `config/softspm.php` | flaga migracji haseł MD5→bcrypt |
| `resources/css/app.css` | tokeny „Industrial Precision" (Tailwind v4, `@theme`) + fonty self-hosted |
| `resources/views/components/guest-layout.blade.php` | layout logowania |
| `resources/views/components/app-layout.blade.php` | layout aplikacji (topbar + sidebar) |
| `resources/views/auth/login.blade.php` | ekran logowania (wg makiety) |
| `resources/views/dashboard.blade.php` | pulpit (placeholder mapy) |
| `app/Models/User.php` | model na `sys_users` |
| `app/Http/Controllers/Auth/LoginController.php` | logowanie MD5/bcrypt + wybór oddziału |
| `app/Http/Controllers/WellController.php` | Studnie: lista (site401) + dodawanie/edycja (site301A/E/inf) |
| `app/Services/WellService.php` | transakcyjny zapis studni (lookupy + create + update) |
| `resources/views/wells/index.blade.php` | lista studni (wg makiety "Well Inventory") |
| `resources/views/wells/create.blade.php`, `edit.blade.php` | formularze dodania/edycji |
| `resources/views/components/well-field.blade.php`, `well-select.blade.php` | pola formularza |
| `routes/web.php` | trasy: login / logout / dashboard / wells (CRUD) |
## Weryfikacja parytetu: Studnie (site401 → /wells)
Nowa lista zastępuje **N+1** z legacy (podzapytania pompa/silnik/ON-OFF na każdy
wiersz) **jednym** zapytaniem z JOIN-ami. Przed zatwierdzeniem sprawdzić na
serwerze testowym, że zwraca ten sam zestaw studni co legacy, dla tego samego
oddziału i statusu. Szybki test liczności (MariaDB, podstaw `<DZIAL>`, `<STATUS>`):
```sql
-- Legacy: bazowa lista (przed N+1) - site401.php, zapytanie $query
SELECT COUNT(*) FROM seg_wells
LEFT JOIN seg_dev_resources ON DRS_DEV_ID=WLL_ID AND DRS_DEV_TYPE=12
LEFT JOIN seg_well_pump_eng_rel ON RWL_WLL_ID=WLL_ID
WHERE DRS_DEPARTMENT='<DZIAL>' AND DRS_STATUS_DIC_ID=<STATUS>;
```
Porównać z `->total()` na `/wells?status=<STATUS>` (ten sam oddział z sesji).
Dodatkowo wyrywkowo sprawdzić kilka studni: nazwa pompy/silnika, nr seryjny i
ostatni stan ON/OFF (DRD_RESOURCES_DETAIL=507, najnowszy DRD_ID) muszą się zgadzać.
### Świadome odstępstwa od legacy (do akceptacji)
- **Paginacja serwerowa** (`paginate(20)`) zamiast cache w sesji + limit 100.
Usuwa to limit 100 i checkbox „Pokaż wszystkie" (zbędny przy realnej paginacji).
- **Filtry przez GET** (zamiast POST+sesja) - czyste linki, komponują się z paginacją.
- **Stan ON/OFF jako chip** (ON/OFF) zamiast ikon power_on/off.png.
## Dodawanie / edycja studni (site301A/E/inf → /wells/create, /wells/{drs}/edit)
Zapis jest **wielotabelowy**. Dodanie studni wstawia do: `seg_wells`,
`seg_dev_resources` (typ 12), `seg_dev_resources_det` ×3 (średnica `DST`,
głębokość `LS`, zasilanie `LZ`), `seg_object_hole_cards`,
`seg_object_hole_cards_his`, `seg_dev_resources_his`. Edycja aktualizuje
`seg_wells`, `seg_dev_resources`, `seg_object_hole_cards` oraz (gdy istnieje)
datę zabudowy w `seg_wells_exchanged`. Cała logika w `WellService`.
### Ulepszenia względem legacy
- **Transakcja** (`DB::transaction`) - legacy robił te INSERT-y bez transakcji
(ryzyko danych częściowych przy błędzie). Teraz zapis jest atomowy.
- **Walidacja** wejścia + kontrola unikalności nazwy.
### Świadome odstępstwa / pominięcia (do akceptacji)
- **NIE przebudowujemy `rep_object_departments`** po każdym dodaniu (legacy robił
`truncate`+`insert` całej tabeli przez ajax `refresh_departments`). To wąskie
gardło wydajności; nowa lista `/wells` go nie używa. Do rewizji przy migracji
raportów, jeśli inne ekrany na nim polegają.
- **Słowniki w języku PL** (`DIC_POL_LANG`) - pełne i18n PL/EN/DE w osobnym etapie.
- **`build_time`** jako jedno pole `time` (HH:MM) zamiast dwóch selectów godz./min.
### Weryfikacja (na serwerze testowym)
Dodać testową studnię w wybranym oddziale → sprawdzić, że pojawia się na `/wells`
(status 3, ten oddział) oraz że powstały spójne wpisy w 6 tabelach. Edytować →
sprawdzić aktualizację `seg_wells`/`seg_dev_resources`/`seg_object_hole_cards`.
## Otwarte do ustalenia
- **Hasła MD5 → bcrypt** - `USR_PASSWORD` to `varchar(64)`, więc bcrypt się mieści
(rehash bezpieczny bez zmiany schematu). Czeka na zgodę klienta (`SOFTSPM_REHASH=true`).
- **Mapa offline** - strategia kafelków bez internetu (lokalny tile-server /
kafelki statyczne / własny podkład kopalni).
- **`rep_object_departments`** - czy odtworzyć przebudowę po zapisie studni
(na razie pominięta - patrz wyżej).
+8
View File
@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}
@@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SortsLists;
use App\Models\Engine;
use App\Models\Manufacturer;
use App\Models\Pump;
use App\Services\PumpService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Katalog agregatow (legacy site205 / site205E) - "agregat" = pompa + przypisane
* silniki (seg_pump_engine_rel). Uklad lista -> szczegoly (jak pozostale katalogi).
*/
class AggregateController extends Controller
{
use SortsLists;
private const MCF_ALL = 100;
/** Sortowalne kolumny listy: klucz nagłówka => kolumna/alias SQL. */
private const SORTABLE = [
'name' => 'PUP_NAME',
'flow' => 'PUP_Q_N',
'mcf' => 'm.MCF_NAME',
'engines' => 'engines_count',
];
public function __construct(private readonly PumpService $pumps) {}
public function index(Request $request)
{
$by = $request->query('by', 'name');
$q = trim((string) $request->query('q', ''));
$mcf = (int) $request->query('mcf', self::MCF_ALL);
[$sort, $dir] = $this->sortParams($request);
$aggregates = Pump::query()
->visible()->with('manufacturer')->where('PUP_Q_N', '>', 0)
->select('seg_pumps.*')
->selectSub(
DB::connection('softspm')->table('seg_pump_engine_rel')
->whereColumn('RPE_PUP_ID', 'seg_pumps.PUP_ID')->selectRaw('count(*)'),
'engines_count'
)
->when($q !== '' && $by === 'name', fn ($w) => $w->where('PUP_NAME', 'like', '%'.$q.'%'))
->when($q !== '' && $by === 'flow', function ($w) use ($q) {
$v = (float) str_replace(',', '.', $q);
$w->whereBetween('PUP_Q_N', [$v - $v * 0.1, $v + $v * 0.1]);
})
->when($mcf !== self::MCF_ALL, fn ($w) => $w->where('PUP_MCF_ID', $mcf))
// Sortowanie po producencie wymaga joinu (relacja nie jest w FROM).
->when($sort === 'mcf', fn ($w) => $w
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'seg_pumps.PUP_MCF_ID'))
->when($sort !== null, fn ($w) => $w->orderBy(self::SORTABLE[$sort], $dir))
->orderBy('PUP_Q_N')->orderBy('PUP_NAME')
->paginate(20)->withQueryString();
return view('catalog.aggregates.index', [
'aggregates' => $aggregates,
'manufacturers' => Manufacturer::orderBy('MCF_NAME')->pluck('MCF_NAME', 'MCF_ID'),
'by' => $by, 'q' => $q, 'mcf' => $mcf, 'mcfAll' => self::MCF_ALL,
]);
}
public function show(Request $request, Pump $pump)
{
$pump->load('manufacturer');
$h = $this->pumps->curve($pump, 'H');
$eta = $this->pumps->curve($pump, 'ETA');
$hDrawable = $this->pumps->curveDrawable($h, 0);
$etaDrawable = $this->pumps->curveDrawable($eta, 1);
$engines = $this->assignedEngines($pump->PUP_ID);
$engineId = (int) $request->query('engine') ?: (int) ($engines->first()->ENG_ID ?? 0);
$engine = $engineId && $engines->firstWhere('ENG_ID', $engineId)
? Engine::with('manufacturer')->find($engineId)
: null;
return view('catalog.aggregates.show', [
'pump' => $pump,
'engines' => $engines,
'engine' => $engine,
'h' => $h, 'eta' => $eta,
'hDrawable' => $hDrawable, 'etaDrawable' => $etaDrawable,
'hPlot' => $hDrawable ? $this->pumps->plot($h) : null,
'etaPlot' => $etaDrawable ? $this->pumps->plot($eta) : null,
]);
}
public function edit(Pump $pump)
{
$allEngines = Engine::query()->visible()->with('manufacturer')
->orderBy('ENG_POWER')->orderBy('ENG_NAME')->get();
$assigned = DB::connection('softspm')->table('seg_pump_engine_rel')
->where('RPE_PUP_ID', $pump->PUP_ID)->pluck('RPE_ENG_ID')->all();
return view('catalog.aggregates.edit', [
'pump' => $pump->load('manufacturer'),
'allEngines' => $allEngines,
'assigned' => array_map('intval', $assigned),
]);
}
public function update(Request $request, Pump $pump)
{
$engineIds = collect($request->input('engines', []))->map(fn ($v) => (int) $v)->filter()->unique();
DB::connection('softspm')->transaction(function () use ($pump, $engineIds) {
DB::connection('softspm')->table('seg_pump_engine_rel')->where('RPE_PUP_ID', $pump->PUP_ID)->delete();
foreach ($engineIds as $eid) {
DB::connection('softspm')->table('seg_pump_engine_rel')
->insert(['RPE_PUP_ID' => $pump->PUP_ID, 'RPE_ENG_ID' => $eid]);
}
});
return redirect()->route('aggregates.show', $pump)
->with('status', 'Zaktualizowano silniki dla pompy "'.$pump->PUP_NAME.'".');
}
private function assignedEngines(int $pumpId)
{
return DB::connection('softspm')->table('seg_pump_engine_rel as r')
->join('seg_engines as e', 'e.ENG_ID', '=', 'r.RPE_ENG_ID')
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'e.ENG_MCF_ID')
->where('r.RPE_PUP_ID', $pumpId)
->select('e.ENG_ID', 'e.ENG_NAME', 'e.ENG_VOLTAGE', 'e.ENG_POWER', 'e.ENG_ETA', 'm.MCF_NAME')
->orderBy('e.ENG_POWER')->orderBy('e.ENG_NAME')->get();
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers;
use App\Services\PumpService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Wybór agregatu pompowego (legacy site201 "Wysokość podnoszenia dla posiadanych
* typów pomp"). Narzędzie doboru: użytkownik podaje wydajność Q (i opcjonalnie
* wysokość podnoszenia H), system zwraca pompy obejmujące to Q wraz z H(Q)/η(Q),
* listę zestawów pompa+silnik posortowaną malejąco po sprawności oraz wykres
* nałożonych krzywych H. Klik wiersza -> szczegóły zestawu (aggregates.show).
*
* Szczegół zestawu = istniejący ekran agregatu (site201det był jego duplikatem),
* więc nie powielamy - kierujemy do aggregates.show?pump=X&engine=Y.
*/
class AggregateSelectionController extends Controller
{
public function __construct(private readonly PumpService $pumps) {}
public function index(Request $request)
{
$qIn = $request->query('q');
$hIn = $request->query('h');
$q = $this->toFloat($qIn);
$h = $this->toFloat($hIn);
$searched = $q !== null && $q > 0;
$matches = $searched ? $this->pumps->matchingAggregates($q, $h) : collect();
[$rows, $chart] = $matches->isNotEmpty()
? [$this->buildRows($matches), $this->buildChart($matches)]
: [collect(), null];
return view('catalog.aggregate-selection.index', [
'q' => $qIn, // surowe (do ponownego wyświetlenia w polu)
'h' => $hIn,
'qVal' => $q, // liczbowe (do nagłówka Q_sz)
'rows' => $rows,
'chart' => $chart,
'searched' => $searched,
]);
}
/**
* Rozwija dopasowane pompy na wiersze pompa+silnik. Silniki pompy wg mocy
* (ENG_POWER) - jak legacy. leftJoin: pompa bez silnika wciąż daje 1 wiersz.
*/
private function buildRows($matches)
{
$engines = DB::connection('softspm')->table('seg_pump_engine_rel as r')
->leftJoin('seg_engines as e', 'e.ENG_ID', '=', 'r.RPE_ENG_ID')
->whereIn('r.RPE_PUP_ID', $matches->pluck('pump.PUP_ID')->all())
->orderBy('e.ENG_POWER')
->select('r.RPE_PUP_ID', 'e.ENG_ID', 'e.ENG_NAME', 'e.ENG_POWER', 'e.ENG_VOLTAGE')
->get()
->groupBy('RPE_PUP_ID');
$rows = collect();
$lp = 1;
foreach ($matches as $m) {
$pumpEngines = $engines->get($m->pump->PUP_ID) ?: collect([null]);
foreach ($pumpEngines as $e) {
$rows->push((object) [
'lp' => $lp++,
'pump_id' => $m->pump->PUP_ID,
'pump_name' => $m->pump->PUP_NAME,
'engine_id' => $e->ENG_ID ?? null,
'engine_name' => $e->ENG_NAME ?? null,
'q_n' => $m->pump->PUP_Q_N,
'h_val' => $m->h_val,
'eta_val' => $m->eta_val,
'power' => $e->ENG_POWER ?? null,
'voltage' => $e->ENG_VOLTAGE ?? null,
]);
}
}
return $rows;
}
/**
* Dane wykresu nałożonych krzywych H (wspólne osie). xMax = najszersza
* krzywa, yMax = najwyższa (zaokrąglona w górę do 10, jak legacy).
*/
private function buildChart($matches): ?array
{
$curves = $matches->filter(fn ($m) => $m->plot)
->map(fn ($m) => ['name' => $m->pump->PUP_NAME, 'plot' => $m->plot])
->values();
if ($curves->isEmpty()) {
return null;
}
return [
'curves' => $curves,
'xMax' => (float) $curves->max(fn ($c) => $c['plot']['xMax']),
'yMax' => max(10.0, ceil($curves->max(fn ($c) => $c['plot']['yMax']) / 10) * 10),
];
}
/** Pole tekstowe -> float (przecinek dziesiętny), puste -> null. */
private function toFloat($v): ?float
{
if ($v === null || trim((string) $v) === '') {
return null;
}
return (float) str_replace(',', '.', (string) $v);
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* Logowanie SoftSPM_2 na istniejącej bazie `sys_users` (database-first).
*
* Strategia haseł (przejściowa):
* - Legacy trzyma USR_PASSWORD = md5(hasło).
* - Akceptujemy logowanie po MD5 (kompatybilność) ORAZ po bcrypt.
* - Po udanym logowaniu MD5, jeśli kolumna USR_PASSWORD pomieści bcrypt
* (>=60 znaków) i włączony jest flag SOFTSPM_REHASH - przepisujemy hash
* na bcrypt. Domyślnie WYŁĄCZONE, żeby nie uszkodzić zbyt krótkiej kolumny.
* Decyzja o rozszerzeniu kolumny / osobnej kolumnie do ustalenia.
*/
class LoginController extends Controller
{
/** Pokaż formularz logowania. */
public function show()
{
return view('auth.login');
}
/** Obsłuż próbę logowania. */
public function attempt(Request $request)
{
// Faza testów: oddział może być wymuszony w configu (pole ukryte w logowaniu).
$forcedDepartment = config('softspm.login_department');
$rules = [
'username' => ['required', 'string', 'max:100'],
'password' => ['required', 'string'],
'lang' => ['nullable', 'in:pl,en,de'],
];
if (! $forcedDepartment) {
$rules['department'] = ['required', 'string', Rule::in(config('softspm.departments'))];
}
$credentials = $request->validate($rules);
$department = $forcedDepartment ?: $credentials['department'];
$user = User::query()
->where('USR_USERNAME', $credentials['username'])
->where('USR_STATUS', '1')
->first();
if (! $user || ! $this->passwordMatches($credentials['password'], $user->USR_PASSWORD)) {
throw ValidationException::withMessages([
'username' => 'Nieprawidłowy login lub hasło.',
]);
}
// Opcjonalna migracja MD5 -> bcrypt (patrz docblock klasy).
$this->maybeRehash($user, $credentials['password']);
Auth::login($user);
$request->session()->regenerate();
// Oddział filtrujący listy obiektów (odpowiednik legacy 'syndis_eq_stock').
$request->session()->put('department', $department);
// Zapamiętaj wybrany język UI (i18n w kolejnym etapie).
if (! empty($credentials['lang'])) {
$request->session()->put('locale', $credentials['lang']);
}
return redirect()->intended(route('dashboard'));
}
/** Wyloguj. */
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
/**
* Weryfikacja hasła: bcrypt (nowe) lub md5 (legacy).
*/
private function passwordMatches(string $plain, ?string $stored): bool
{
if (empty($stored)) {
return false;
}
// Hash bcrypt zaczyna się od $2y$ / $2a$ - sprawdź najpierw.
if (str_starts_with($stored, '$2')) {
return Hash::check($plain, $stored);
}
// Legacy: md5 (32 znaki hex). hash_equals chroni przed timing-attack.
return hash_equals(strtolower($stored), md5($plain));
}
/**
* Po logowaniu MD5 przepisz hash na bcrypt - tylko gdy bezpiecznie.
*/
private function maybeRehash(User $user, string $plain): void
{
if (! config('softspm.rehash_passwords', false)) {
return;
}
if (str_starts_with((string) $user->USR_PASSWORD, '$2')) {
return; // już bcrypt
}
$user->USR_PASSWORD = Hash::make($plain);
$user->save();
}
}
+170
View File
@@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SortsLists;
use App\Http\Requests\StoreCableRequest;
use App\Http\Requests\UpdateCableRequest;
use App\Models\Equipment;
use App\Models\Manufacturer;
use App\Services\EquipmentFileService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Katalog kabli - pełny CRUD (legacy site206 / site206A / site206E / site206inf).
* Dane w seg_equipments (EQU_TYPE_ID=13) + załączniki BLOB w seg_equipments_files.
* Usuwanie logiczne (EQU_STATUS='0'); blokowane gdy kabel jest na stanie
* (seg_equipments_stock) - jak legacy. Newest methods: Eloquent + Form Requests.
*/
class CableController extends Controller
{
use SortsLists;
/** EQU_TYPE_ID kabli (sys_dictionaries). */
private const TYPE_CABLE = 13;
/** Producent „Wszyscy" (jak legacy: 100). */
private const MCF_ALL = 100;
/** Sortowalne kolumny listy: klucz nagłówka => kolumna SQL. */
private const SORTABLE = [
'name' => 'EQU_NAME',
'mcf' => 'm.MCF_NAME',
];
public function __construct(private readonly EquipmentFileService $files) {}
public function index(Request $request)
{
$q = trim((string) $request->query('q', ''));
$mcf = (int) $request->query('mcf', self::MCF_ALL);
[$sort, $dir] = $this->sortParams($request);
$cables = Equipment::query()
->visible()->ofType(self::TYPE_CABLE)->with('manufacturer')
->when($q !== '', fn ($w) => $w->where('EQU_NAME', 'like', '%'.$q.'%'))
->when($mcf !== self::MCF_ALL, fn ($w) => $w->where('EQU_MCF_ID', $mcf))
->when($sort === 'mcf', fn ($w) => $w
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'seg_equipments.EQU_MCF_ID')
->select('seg_equipments.*'))
->when($sort !== null, fn ($w) => $w->orderBy(self::SORTABLE[$sort], $dir))
->orderBy('EQU_NAME')
->paginate(20)
->withQueryString();
return view('catalog.cables.index', [
'cables' => $cables,
'manufacturers' => $this->manufacturers(),
'q' => $q,
'mcf' => $mcf,
'mcfAll' => self::MCF_ALL,
]);
}
public function create()
{
return view('catalog.cables.create', ['manufacturers' => $this->manufacturers()]);
}
public function store(StoreCableRequest $request)
{
$data = $request->validated();
$cable = Equipment::create([
'EQU_NAME' => $data['name'],
'EQU_MCF_ID' => $data['manufacturer_id'],
'EQU_DESCRIPTION' => $data['description'] ?? null,
'EQU_TYPE_ID' => self::TYPE_CABLE,
'EQU_STATUS' => '1',
]);
$this->saveFiles($request, $cable->EQU_ID);
return redirect()->route('cables.show', $cable)
->with('status', 'Dodano typ kabla „'.$cable->EQU_NAME.'".');
}
public function show(Equipment $cable)
{
abort_unless((int) $cable->EQU_TYPE_ID === self::TYPE_CABLE, 404);
$cable->load('manufacturer', 'files');
return view('catalog.cables.show', [
'cable' => $cable,
'document' => $cable->files->first(fn ($f) => ! $f->isImage()),
'image' => $cable->files->first(fn ($f) => $f->isImage()),
'inStock' => $this->inStock($cable->EQU_ID),
]);
}
public function edit(Equipment $cable)
{
abort_unless((int) $cable->EQU_TYPE_ID === self::TYPE_CABLE, 404);
$cable->load('manufacturer', 'files');
return view('catalog.cables.edit', [
'cable' => $cable,
'manufacturers' => $this->manufacturers(),
'document' => $cable->files->first(fn ($f) => ! $f->isImage()),
'image' => $cable->files->first(fn ($f) => $f->isImage()),
]);
}
public function update(UpdateCableRequest $request, Equipment $cable)
{
abort_unless((int) $cable->EQU_TYPE_ID === self::TYPE_CABLE, 404);
$data = $request->validated();
$cable->update([
'EQU_NAME' => $data['name'],
'EQU_MCF_ID' => $data['manufacturer_id'],
'EQU_DESCRIPTION' => $data['description'] ?? null,
]);
$this->saveFiles($request, $cable->EQU_ID);
return redirect()->route('cables.show', $cable)
->with('status', 'Zaktualizowano typ kabla „'.$cable->EQU_NAME.'".');
}
public function destroy(Equipment $cable)
{
abort_unless((int) $cable->EQU_TYPE_ID === self::TYPE_CABLE, 404);
// Legacy: nie usuwaj, gdy kabel jest na stanie (seg_equipments_stock).
if ($this->inStock($cable->EQU_ID)) {
return redirect()->route('cables.show', $cable)
->with('error', 'Nie można usunąć - kabel jest powiązany ze stanem magazynowym.');
}
$cable->update(['EQU_STATUS' => '0']); // usuwanie logiczne
return redirect()->route('cables.index')
->with('status', 'Usunięto typ kabla „'.$cable->EQU_NAME.'".');
}
/** Zapis przesłanych plików (dokumentacja + zdjęcie), jeśli są. */
private function saveFiles(Request $request, int $equId): void
{
$user = $request->user()->USR_USERNAME;
if ($request->hasFile('catalogue_file')) {
$this->files->store($equId, $request->file('catalogue_file'), $user);
}
if ($request->hasFile('image_file')) {
$this->files->store($equId, $request->file('image_file'), $user);
}
}
private function inStock(int $equId): bool
{
return DB::connection('softspm')->table('seg_equipments_stock')
->where('SES_EQU_ID', $equId)->exists();
}
private function manufacturers()
{
return Manufacturer::orderBy('MCF_NAME')->pluck('MCF_NAME', 'MCF_ID');
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Concerns;
use Illuminate\Http\Request;
/**
* Wspólne wyznaczanie parametrów sortowania list (nagłówki <x-sortable-th>).
* Kontroler udostępnia whitelistę kolumn przez stałą self::SORTABLE
* (klucz nagłówka => kolumna SQL). Zapobiega wstrzyknięciu dowolnej kolumny
* do ORDER BY - bierzemy tylko klucze z whitelisty.
*/
trait SortsLists
{
/**
* @return array{0: ?string, 1: string} [klucz sortowania lub null, kierunek 'asc'|'desc']
*/
protected function sortParams(Request $request): array
{
$sort = $request->query('sort');
$dir = $request->query('dir') === 'desc' ? 'desc' : 'asc';
if (! is_string($sort) || ! array_key_exists($sort, static::SORTABLE)) {
return [null, $dir];
}
return [$sort, $dir];
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
@@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Pulpit / Mapa Obiektów. Nanosi studnie jako kropki na płaszczyznę wg
* LOKALNYCH współrzędnych geodezyjnych (seg_wells.WLL_LATITUDE/WLL_LONGITUDE -
* siatka kopalniana w metrach, NIE WGS84). Normalizacja min-max do obszaru mapy
* daje dokładny względny układ pola studni (bez internetu - air-gap OK).
*
* Kolor kropki: zielony = pompa pracuje (ON), czerwony = nie pracuje (OFF).
*/
class DashboardController extends Controller
{
/** Margines obszaru mapy (%), żeby kropki nie dotykały krawędzi. */
private const PAD = 6;
public function index(Request $request)
{
$department = $request->session()->get('department');
$rows = DB::connection('softspm')->table('seg_wells as w')
->join('seg_dev_resources as drw', function ($j) {
$j->on('drw.DRS_DEV_ID', '=', 'w.WLL_ID')->where('drw.DRS_DEV_TYPE', '=', 12);
})
->where('drw.DRS_DEPARTMENT', $department)
->where('drw.DRS_STATUS_DIC_ID', 3) // w eksploatacji
->select('drw.DRS_ID as drs_id', 'w.WLL_NAME as name', 'w.WLL_LATITUDE as lat', 'w.WLL_LONGITUDE as lon')
->get();
// Parsowanie współrzędnych (przecinek lub kropka dziesiętna), odrzucenie błędnych.
$num = function ($v) {
$s = str_replace([',', ' '], ['.', ''], (string) $v);
return is_numeric($s) ? (float) $s : null;
};
$wells = $rows->map(function ($r) use ($num) {
$r->lat = $num($r->lat);
$r->lon = $num($r->lon);
return $r;
})->filter(fn ($r) => $r->lat !== null && $r->lon !== null && $r->lat > 0 && $r->lon > 0)->values();
// Stan ON/OFF dla naniesionych studni (DRD_RESOURCES_DETAIL=507).
$onoff = $this->onOff($wells->pluck('drs_id')->all());
// Normalizacja do obszaru mapy. Zakres liczony z PERCENTYLI (2-98%), żeby
// pojedyncze studnie ze skrajnymi współrzędnymi nie rozciągały skali i nie
// zwijały reszty w jeden punkt. Pozycje przycięte (clamp) do obszaru.
$minLat = $this->percentile($wells->pluck('lat')->all(), 2);
$maxLat = $this->percentile($wells->pluck('lat')->all(), 98);
$minLon = $this->percentile($wells->pluck('lon')->all(), 2);
$maxLon = $this->percentile($wells->pluck('lon')->all(), 98);
$latRange = max(1e-6, $maxLat - $minLat);
$lonRange = max(1e-6, $maxLon - $minLon);
$span = 100 - 2 * self::PAD;
$clamp = fn ($v) => max(self::PAD, min(100 - self::PAD, $v));
$points = $wells->map(function ($r) use ($minLat, $maxLat, $minLon, $lonRange, $latRange, $span, $clamp, $onoff) {
return (object) [
'drs_id' => $r->drs_id,
'name' => $r->name,
// X = długość (wschód), Y = szerokość (północ, odwrócona - północ u góry).
'left' => round($clamp(self::PAD + (($r->lon - $minLon) / $lonRange) * $span), 3),
'top' => round($clamp(self::PAD + (($maxLat - $r->lat) / $latRange) * $span), 3),
'on' => ($onoff[$r->drs_id] ?? null) === 'ON',
];
});
return view('dashboard', [
'points' => $points,
'onCount' => $points->where('on', true)->count(),
'offCount' => $points->where('on', false)->count(),
]);
}
/** Percentyl p (0-100) z tablicy liczb (interpolacja liniowa). */
private function percentile(array $values, float $p): float
{
if (empty($values)) {
return 0.0;
}
sort($values);
$i = ($p / 100) * (count($values) - 1);
$lo = (int) floor($i);
$hi = (int) ceil($i);
return $lo === $hi ? (float) $values[$lo] : $values[$lo] + ($values[$hi] - $values[$lo]) * ($i - $lo);
}
/** Ostatni stan ON/OFF (507) dla zbioru DRS_ID - jednym zapytaniem. */
private function onOff(array $ids): \Illuminate\Support\Collection
{
if (empty($ids)) {
return collect();
}
return DB::connection('softspm')->table('seg_dev_resources_det as d')
->joinSub(
DB::connection('softspm')->table('seg_dev_resources_det')
->select('DRD_DRS_ID', DB::raw('MAX(DRD_ID) as max_id'))
->where('DRD_RESOURCES_DETAIL', 507)
->whereIn('DRD_DRS_ID', $ids)
->groupBy('DRD_DRS_ID'),
'mx', 'mx.max_id', '=', 'd.DRD_ID'
)
->pluck('d.DRD_VALUE', 'd.DRD_DRS_ID');
}
}
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SortsLists;
use App\Http\Requests\StoreEngineRequest;
use App\Http\Requests\UpdateEngineRequest;
use App\Models\Engine;
use App\Models\Manufacturer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Katalog silników - pełny CRUD (legacy site204 / site204A / site204E / site204inf).
* Usuwanie logiczne (ENG_STATUS='0'). Newest methods: Eloquent + Form Requests.
*/
class EngineController extends Controller
{
use SortsLists;
private const MCF_ALL = 100;
/** Sortowalne kolumny listy: klucz nagłówka => kolumna SQL. */
private const SORTABLE = [
'name' => 'ENG_NAME',
'voltage' => 'ENG_VOLTAGE',
'power' => 'ENG_POWER',
'mcf' => 'm.MCF_NAME',
];
public function index(Request $request)
{
$by = $request->query('by', 'name'); // 'name' | 'power'
$q = trim((string) $request->query('q', ''));
$mcf = (int) $request->query('mcf', self::MCF_ALL);
[$sort, $dir] = $this->sortParams($request);
$engines = Engine::query()
->visible()
->with('manufacturer')
->when($q !== '' && $by === 'name', fn ($w) => $w->where('ENG_NAME', 'like', '%'.$q.'%'))
->when($q !== '' && $by === 'power', function ($w) use ($q) {
$v = (float) str_replace(',', '.', $q);
$w->whereBetween('ENG_POWER', [$v - $v * 0.1, $v + $v * 0.1]);
})
->when($mcf !== self::MCF_ALL, fn ($w) => $w->where('ENG_MCF_ID', $mcf))
// Sortowanie po producencie wymaga joinu (relacja nie jest w FROM).
->when($sort === 'mcf', fn ($w) => $w
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'seg_engines.ENG_MCF_ID')
->select('seg_engines.*'))
->when($sort !== null, fn ($w) => $w->orderBy(self::SORTABLE[$sort], $dir))
->orderBy('ENG_NAME')
->paginate(20)
->withQueryString();
return view('catalog.engines.index', [
'engines' => $engines,
'manufacturers' => $this->manufacturers(),
'by' => $by,
'q' => $q,
'mcf' => $mcf,
'mcfAll' => self::MCF_ALL,
]);
}
public function create()
{
return view('catalog.engines.create', ['manufacturers' => $this->manufacturers()]);
}
public function store(StoreEngineRequest $request)
{
$engine = Engine::create($this->toColumns($request->validated()) + ['ENG_STATUS' => '1']);
return redirect()->route('engines.show', $engine)
->with('status', 'Dodano silnik „'.$engine->ENG_NAME.'".');
}
public function show(Engine $engine)
{
$engine->load('manufacturer');
$relatedPumps = DB::connection('softspm')->table('seg_pump_engine_rel as r')
->leftJoin('seg_pumps as p', 'p.PUP_ID', '=', 'r.RPE_PUP_ID')
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'p.PUP_MCF_ID')
->where('r.RPE_ENG_ID', $engine->ENG_ID)
->select('p.PUP_NAME', DB::raw('p.PUP_Q_N as q_n'), 'p.PUP_H_P', 'm.MCF_NAME')
->get();
return view('catalog.engines.show', ['engine' => $engine, 'relatedPumps' => $relatedPumps]);
}
public function edit(Engine $engine)
{
return view('catalog.engines.edit', [
'engine' => $engine,
'manufacturers' => $this->manufacturers(),
]);
}
public function update(UpdateEngineRequest $request, Engine $engine)
{
$engine->update($this->toColumns($request->validated()));
return redirect()->route('engines.show', $engine)
->with('status', 'Zaktualizowano silnik „'.$engine->ENG_NAME.'".');
}
public function destroy(Engine $engine)
{
$engine->update(['ENG_STATUS' => '0']); // usuwanie logiczne
return redirect()->route('engines.index')
->with('status', 'Usunięto silnik „'.$engine->ENG_NAME.'".');
}
/** Mapowanie pól formularza → kolumny seg_engines. */
private function toColumns(array $data): array
{
return [
'ENG_NAME' => $data['name'],
'ENG_MCF_ID' => $data['manufacturer_id'],
'ENG_POWER' => $data['power'],
'ENG_VOLTAGE' => $data['voltage'],
'ENG_ELECTRICITY' => $data['current'] ?? null,
'ENG_ETA' => $data['eta'] ?? null,
'ENG_COS_FI' => $data['cos_fi'] ?? null,
'ENG_RPM' => $data['rpm'] ?? null,
'ENG_LENGTH' => $data['length'] ?? null,
'ENG_WEIGHT' => $data['weight'] ?? null,
'ENG_DIAMETER' => $data['diameter'] ?? null,
'ENG_WIRES' => $data['wires'] ?? null,
];
}
private function manufacturers()
{
return Manufacturer::orderBy('MCF_NAME')->pluck('MCF_NAME', 'MCF_ID');
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers;
use App\Models\EquipmentFile;
use Symfony\Component\HttpFoundation\Response;
/**
* Strumieniowanie załączników wyposażenia (BLOB z seg_equipments_files) -
* wspólne dla wszystkich katalogów na seg_equipments (kable/orurowanie/
* opomiarowanie). Podgląd/pobranie pliku po EQF_ID.
*/
class EquipmentFileController extends Controller
{
public function file(EquipmentFile $file): Response
{
return response($file->EQF_FILE ?? '', 200, [
'Content-Type' => $file->EQF_FILE_TYPE ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="'.($file->EQF_FILE_NAME ?: 'plik').'"',
]);
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SortsLists;
use App\Http\Requests\StoreMeteringRequest;
use App\Http\Requests\UpdateMeteringRequest;
use App\Models\Equipment;
use App\Models\Manufacturer;
use App\Services\EquipmentFileService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Katalog opomiarowania - pełny CRUD (legacy site208 / A / E / inf).
* seg_equipments (EQU_TYPE_ID=14). Pola jak w kablach (nazwa/producent/opis/
* pliki); przy dodawaniu kategoria EQU_PARAM_02 ustawiana automatycznie na
* słownik WELL_CATEGORY/SPM_FULL (jak legacy normalny formularz site208A).
* Usuwanie logiczne; blokada gdy urządzenie jest użyte jako zasób
* (seg_dev_resources DRS_DEV_TYPE=14) - inaczej niż kable (tam stan magazynowy).
*
* Wariant admina (site208Admin: ręczny wybór kategorii + język) NIE portowany -
* dotyczył wyłącznie usera 'admin'; obecny model nie ma kontroli admin per-ekran.
*/
class MeteringController extends Controller
{
use SortsLists;
/** EQU_TYPE_ID opomiarowania (sys_dictionaries). */
private const TYPE_METERING = 14;
private const MCF_ALL = 100;
/** Sortowalne kolumny listy. */
private const SORTABLE = [
'name' => 'EQU_NAME',
'mcf' => 'm.MCF_NAME',
];
public function __construct(private readonly EquipmentFileService $files) {}
public function index(Request $request)
{
$q = trim((string) $request->query('q', ''));
$mcf = (int) $request->query('mcf', self::MCF_ALL);
[$sort, $dir] = $this->sortParams($request);
$items = Equipment::query()
->visible()->ofType(self::TYPE_METERING)->with('manufacturer')
->when($q !== '', fn ($w) => $w->where('EQU_NAME', 'like', '%'.$q.'%'))
->when($mcf !== self::MCF_ALL, fn ($w) => $w->where('EQU_MCF_ID', $mcf))
->when($sort === 'mcf', fn ($w) => $w
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'seg_equipments.EQU_MCF_ID')
->select('seg_equipments.*'))
->when($sort !== null, fn ($w) => $w->orderBy(self::SORTABLE[$sort], $dir))
->orderBy('EQU_NAME')
->paginate(20)
->withQueryString();
return view('catalog.metering.index', [
'items' => $items,
'manufacturers' => $this->manufacturers(),
'q' => $q,
'mcf' => $mcf,
'mcfAll' => self::MCF_ALL,
]);
}
public function create()
{
return view('catalog.metering.create', ['manufacturers' => $this->manufacturers()]);
}
public function store(StoreMeteringRequest $request)
{
$data = $request->validated();
$item = Equipment::create([
'EQU_NAME' => $data['name'],
'EQU_MCF_ID' => $data['manufacturer_id'],
'EQU_DESCRIPTION' => $data['description'] ?? null,
'EQU_PARAM_02' => $this->defaultCategoryId(), // kategoria SPM_FULL (jak legacy)
'EQU_TYPE_ID' => self::TYPE_METERING,
'EQU_STATUS' => '1',
]);
$this->saveFiles($request, $item->EQU_ID);
return redirect()->route('metering.show', $item)
->with('status', 'Dodano urządzenie „'.$item->EQU_NAME.'".');
}
public function show(Equipment $metering)
{
abort_unless((int) $metering->EQU_TYPE_ID === self::TYPE_METERING, 404);
$metering->load('manufacturer', 'files');
return view('catalog.metering.show', [
'item' => $metering,
'document' => $metering->files->first(fn ($f) => ! $f->isImage()),
'image' => $metering->files->first(fn ($f) => $f->isImage()),
'inUse' => $this->inUse($metering->EQU_ID),
]);
}
public function edit(Equipment $metering)
{
abort_unless((int) $metering->EQU_TYPE_ID === self::TYPE_METERING, 404);
$metering->load('manufacturer', 'files');
return view('catalog.metering.edit', [
'item' => $metering,
'manufacturers' => $this->manufacturers(),
'document' => $metering->files->first(fn ($f) => ! $f->isImage()),
'image' => $metering->files->first(fn ($f) => $f->isImage()),
]);
}
public function update(UpdateMeteringRequest $request, Equipment $metering)
{
abort_unless((int) $metering->EQU_TYPE_ID === self::TYPE_METERING, 404);
$data = $request->validated();
// Legacy update aktualizuje tylko nazwę/producenta/opis (kategoria zostaje).
$metering->update([
'EQU_NAME' => $data['name'],
'EQU_MCF_ID' => $data['manufacturer_id'],
'EQU_DESCRIPTION' => $data['description'] ?? null,
]);
$this->saveFiles($request, $metering->EQU_ID);
return redirect()->route('metering.show', $metering)
->with('status', 'Zaktualizowano urządzenie „'.$metering->EQU_NAME.'".');
}
public function destroy(Equipment $metering)
{
abort_unless((int) $metering->EQU_TYPE_ID === self::TYPE_METERING, 404);
// Legacy: blokuj, gdy urządzenie jest użyte jako zasób (seg_dev_resources, typ 14).
if ($this->inUse($metering->EQU_ID)) {
return redirect()->route('metering.show', $metering)
->with('error', 'Nie można usunąć - urządzenie jest użyte w stanie opomiarowania.');
}
$metering->update(['EQU_STATUS' => '0']); // usuwanie logiczne
return redirect()->route('metering.index')
->with('status', 'Usunięto urządzenie „'.$metering->EQU_NAME.'".');
}
private function saveFiles(Request $request, int $equId): void
{
$user = $request->user()->USR_USERNAME;
if ($request->hasFile('catalogue_file')) {
$this->files->store($equId, $request->file('catalogue_file'), $user);
}
if ($request->hasFile('image_file')) {
$this->files->store($equId, $request->file('image_file'), $user);
}
}
/** Czy urządzenie jest użyte jako zasób opomiarowania (seg_dev_resources, typ 14). */
private function inUse(int $equId): bool
{
return DB::connection('softspm')->table('seg_dev_resources')
->where('DRS_DEV_TYPE', self::TYPE_METERING)
->where('DRS_DEV_ID', $equId)
->exists();
}
/** DIC_ID słownikowej kategorii WELL_CATEGORY/SPM_FULL (jak hidden w site208A). */
private function defaultCategoryId(): ?int
{
return DB::connection('softspm')->table('sys_dictionaries')
->where('DIC_TYPE', 'WELL_CATEGORY')
->where('DIC_NAME', 'SPM_FULL')
->value('DIC_ID');
}
private function manufacturers()
{
return Manufacturer::orderBy('MCF_NAME')->pluck('MCF_NAME', 'MCF_ID');
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SortsLists;
use App\Http\Requests\StorePipingRequest;
use App\Http\Requests\UpdatePipingRequest;
use App\Models\Equipment;
use App\Models\Manufacturer;
use App\Services\EquipmentFileService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Katalog orurowania i armatury - pełny CRUD (legacy site207 / A / E / inf).
* seg_equipments (EQU_TYPE_ID=15) + dwa parametry: EQU_PARAM_01=średnica [mm],
* EQU_PARAM_02=długość [m] / ciśnienie [bar]. Załączniki BLOB jak w kablach.
* Usuwanie logiczne; blokada gdy na stanie (seg_equipments_stock). Pliki przez
* wspólny endpoint equipment.file.
*/
class PipingController extends Controller
{
use SortsLists;
/** EQU_TYPE_ID orurowania/armatury (sys_dictionaries). */
private const TYPE_PIPING = 15;
private const MCF_ALL = 100;
/** Sortowalne kolumny listy. */
private const SORTABLE = [
'name' => 'EQU_NAME',
'diameter' => 'EQU_PARAM_01',
'length' => 'EQU_PARAM_02',
'mcf' => 'm.MCF_NAME',
];
public function __construct(private readonly EquipmentFileService $files) {}
public function index(Request $request)
{
$q = trim((string) $request->query('q', ''));
$mcf = (int) $request->query('mcf', self::MCF_ALL);
[$sort, $dir] = $this->sortParams($request);
$items = Equipment::query()
->visible()->ofType(self::TYPE_PIPING)->with('manufacturer')
->when($q !== '', fn ($w) => $w->where('EQU_NAME', 'like', '%'.$q.'%'))
->when($mcf !== self::MCF_ALL, fn ($w) => $w->where('EQU_MCF_ID', $mcf))
->when($sort === 'mcf', fn ($w) => $w
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'seg_equipments.EQU_MCF_ID')
->select('seg_equipments.*'))
->when($sort !== null, fn ($w) => $w->orderBy(self::SORTABLE[$sort], $dir))
->orderBy('EQU_NAME')->orderBy('EQU_PARAM_01')
->paginate(20)
->withQueryString();
return view('catalog.piping.index', [
'items' => $items,
'manufacturers' => $this->manufacturers(),
'q' => $q,
'mcf' => $mcf,
'mcfAll' => self::MCF_ALL,
]);
}
public function create()
{
return view('catalog.piping.create', ['manufacturers' => $this->manufacturers()]);
}
public function store(StorePipingRequest $request)
{
$data = $request->validated();
$item = Equipment::create([
'EQU_NAME' => $data['name'],
'EQU_MCF_ID' => $data['manufacturer_id'],
'EQU_PARAM_01' => $data['diameter'],
'EQU_PARAM_02' => $data['length'] ?? null,
'EQU_DESCRIPTION' => $data['description'] ?? null,
'EQU_TYPE_ID' => self::TYPE_PIPING,
'EQU_STATUS' => '1',
]);
$this->saveFiles($request, $item->EQU_ID);
return redirect()->route('piping.show', $item)
->with('status', 'Dodano pozycję „'.$item->EQU_NAME.'".');
}
public function show(Equipment $piping)
{
abort_unless((int) $piping->EQU_TYPE_ID === self::TYPE_PIPING, 404);
$piping->load('manufacturer', 'files');
return view('catalog.piping.show', [
'item' => $piping,
'document' => $piping->files->first(fn ($f) => ! $f->isImage()),
'image' => $piping->files->first(fn ($f) => $f->isImage()),
'inStock' => $this->inStock($piping->EQU_ID),
]);
}
public function edit(Equipment $piping)
{
abort_unless((int) $piping->EQU_TYPE_ID === self::TYPE_PIPING, 404);
$piping->load('manufacturer', 'files');
return view('catalog.piping.edit', [
'item' => $piping,
'manufacturers' => $this->manufacturers(),
'document' => $piping->files->first(fn ($f) => ! $f->isImage()),
'image' => $piping->files->first(fn ($f) => $f->isImage()),
]);
}
public function update(UpdatePipingRequest $request, Equipment $piping)
{
abort_unless((int) $piping->EQU_TYPE_ID === self::TYPE_PIPING, 404);
$data = $request->validated();
$piping->update([
'EQU_NAME' => $data['name'],
'EQU_MCF_ID' => $data['manufacturer_id'],
'EQU_PARAM_01' => $data['diameter'],
'EQU_PARAM_02' => $data['length'] ?? null,
'EQU_DESCRIPTION' => $data['description'] ?? null,
]);
$this->saveFiles($request, $piping->EQU_ID);
return redirect()->route('piping.show', $piping)
->with('status', 'Zaktualizowano pozycję „'.$piping->EQU_NAME.'".');
}
public function destroy(Equipment $piping)
{
abort_unless((int) $piping->EQU_TYPE_ID === self::TYPE_PIPING, 404);
if ($this->inStock($piping->EQU_ID)) {
return redirect()->route('piping.show', $piping)
->with('error', 'Nie można usunąć - pozycja jest powiązana ze stanem magazynowym.');
}
$piping->update(['EQU_STATUS' => '0']); // usuwanie logiczne
return redirect()->route('piping.index')
->with('status', 'Usunięto pozycję „'.$piping->EQU_NAME.'".');
}
private function saveFiles(Request $request, int $equId): void
{
$user = $request->user()->USR_USERNAME;
if ($request->hasFile('catalogue_file')) {
$this->files->store($equId, $request->file('catalogue_file'), $user);
}
if ($request->hasFile('image_file')) {
$this->files->store($equId, $request->file('image_file'), $user);
}
}
private function inStock(int $equId): bool
{
return DB::connection('softspm')->table('seg_equipments_stock')
->where('SES_EQU_ID', $equId)->exists();
}
private function manufacturers()
{
return Manufacturer::orderBy('MCF_NAME')->pluck('MCF_NAME', 'MCF_ID');
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SortsLists;
use App\Http\Requests\StorePumpRequest;
use App\Http\Requests\UpdatePumpRequest;
use App\Models\Manufacturer;
use App\Models\Pump;
use App\Services\PumpService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Katalog pomp - pełny CRUD (legacy site203 / site203A / site203E / site203inf).
* Newest methods: Eloquent, resource controller, Form Requests, serwis domenowy.
*/
class PumpController extends Controller
{
use SortsLists;
/** Producent „Wszyscy" (jak legacy: 100). */
private const MCF_ALL = 100;
/** Sortowalne kolumny listy: klucz nagłówka => kolumna SQL. */
private const SORTABLE = [
'name' => 'PUP_NAME',
'flow' => 'PUP_Q_N',
'hp' => 'PUP_H_P',
'mcf' => 'm.MCF_NAME',
];
public function __construct(private readonly PumpService $pumps) {}
public function index(Request $request)
{
$by = $request->query('by', 'name'); // 'name' | 'flow'
$q = trim((string) $request->query('q', ''));
$mcf = (int) $request->query('mcf', self::MCF_ALL);
[$sort, $dir] = $this->sortParams($request);
$pumps = Pump::query()
->visible()
->with('manufacturer')
->where('PUP_Q_N', '>', 0)
->when($q !== '' && $by === 'name', fn ($w) => $w->where('PUP_NAME', 'like', '%'.$q.'%'))
->when($q !== '' && $by === 'flow', function ($w) use ($q) {
$v = (float) str_replace(',', '.', $q);
$w->whereBetween('PUP_Q_N', [$v - $v * 0.1, $v + $v * 0.1]); // skala UAT bez ×60
})
->when($mcf !== self::MCF_ALL, fn ($w) => $w->where('PUP_MCF_ID', $mcf))
// Sortowanie po producencie wymaga joinu (relacja nie jest w FROM).
->when($sort === 'mcf', fn ($w) => $w
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'seg_pumps.PUP_MCF_ID')
->select('seg_pumps.*'))
->when($sort !== null, fn ($w) => $w->orderBy(self::SORTABLE[$sort], $dir))
->orderBy('PUP_Q_N')
->orderBy('PUP_NAME')
->paginate(20)
->withQueryString();
return view('catalog.pumps.index', [
'pumps' => $pumps,
'manufacturers' => $this->manufacturers(),
'by' => $by,
'q' => $q,
'mcf' => $mcf,
'mcfAll' => self::MCF_ALL,
]);
}
public function create()
{
return view('catalog.pumps.create', ['manufacturers' => $this->manufacturers()]);
}
public function store(StorePumpRequest $request)
{
$pump = $this->pumps->create($request->validated());
return redirect()
->route('pumps.show', $pump)
->with('status', 'Dodano pompę „'.$pump->PUP_NAME.'".');
}
public function show(Request $request, Pump $pump)
{
$pump->load('manufacturer');
$h = $this->pumps->curve($pump, 'H'); // x = m³/h (bez /60)
$eta = $this->pumps->curve($pump, 'ETA');
// Warunki rysowania jak legacy: H bez zer, η dopuszcza 1 zero (punkt Q=0).
$hDrawable = $this->pumps->curveDrawable($h, 0);
$etaDrawable = $this->pumps->curveDrawable($eta, 1);
// Przelicz dla zadanego Q [m³/h]. Domyślnie Q_n; clamp do zakresu.
$maxX = (float) ($h->max('x') ?? 0);
$qn = round((float) $pump->PUP_Q_N, 3);
$qIn = $request->query('q');
$q = $qIn === null ? $qn : (float) str_replace(',', '.', (string) $qIn);
$outOfRange = $q < 0 || $q > $maxX;
$q = max(0, min($q, $maxX));
$relatedEngines = DB::connection('softspm')->table('seg_pump_engine_rel as r')
->leftJoin('seg_engines as e', 'e.ENG_ID', '=', 'r.RPE_ENG_ID')
->leftJoin('seg_manufacturers as m', 'm.MCF_ID', '=', 'e.ENG_MCF_ID')
->where('r.RPE_PUP_ID', $pump->PUP_ID)
->select('e.ENG_NAME', 'e.ENG_VOLTAGE', 'e.ENG_POWER', 'm.MCF_NAME')
->get();
return view('catalog.pumps.show', [
'pump' => $pump,
'h' => $h,
'eta' => $eta,
'hDrawable' => $hDrawable,
'etaDrawable' => $etaDrawable,
'hPlot' => $hDrawable ? $this->pumps->plot($h) : null,
'etaPlot' => $etaDrawable ? $this->pumps->plot($eta) : null,
'relatedEngines' => $relatedEngines,
'q' => $q,
'hAtQ' => $hDrawable ? $this->pumps->valueAt($h, $q) : null,
'etaAtQ' => $etaDrawable ? $this->pumps->valueAt($eta, $q) : null,
'outOfRange' => $outOfRange,
]);
}
public function edit(Pump $pump)
{
$pump->load('manufacturer');
return view('catalog.pumps.edit', [
'pump' => $pump,
'manufacturers' => $this->manufacturers(),
'h' => $this->pumps->curve($pump, 'H'),
'eta' => $this->pumps->curve($pump, 'ETA'),
'qn' => round((float) $pump->PUP_Q_N, 3),
]);
}
public function update(UpdatePumpRequest $request, Pump $pump)
{
$this->pumps->update($pump, $request->validated());
return redirect()
->route('pumps.show', $pump)
->with('status', 'Zaktualizowano pompę „'.$pump->PUP_NAME.'".');
}
public function destroy(Pump $pump)
{
$this->pumps->delete($pump);
return redirect()
->route('pumps.index')
->with('status', 'Usunięto pompę „'.$pump->PUP_NAME.'".');
}
/** MCF_ID => MCF_NAME do selectów. */
private function manufacturers()
{
return Manufacturer::orderBy('MCF_NAME')->pluck('MCF_NAME', 'MCF_ID');
}
}
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SortsLists;
use App\Services\WellService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
/**
* Studnie - migracja sites/site401.php (część read-only: lista + wyszukiwanie
* + paginacja). Database-first na bazie `softspm`.
*
* Wydajność: legacy robił N+1 (osobne podzapytania o pompę, silnik i stan
* ON/OFF dla KAŻDEGO wiersza). Tu wszystko w JEDNYM zapytaniu z JOIN-ami
* (+ podzapytanie po ostatni ON/OFF). Parytet wyników do weryfikacji na
* serwerze testowym - patrz README_SOFTSPM.md.
*/
class WellController extends Controller
{
use SortsLists;
/** Status "Wszystkie" - wartość spoza słownika (jak legacy: 100). */
private const STATUS_ALL = 100;
/** Sortowalne kolumny listy: klucz nagłówka => kolumna SQL (z aliasów joinów). */
private const SORTABLE = [
'name' => 'w.WLL_NAME',
'bariera' => 'w.WLL_BARIERA',
'pump' => 'p.PUP_NAME',
'pump_no' => 'drp.DRS_SN_NUMBER',
'engine' => 'e.ENG_NAME',
'engine_no' => 'dre.DRS_SN_NUMBER',
'type' => 'w.WLL_SYS_TYPE',
];
public function index(Request $request)
{
$department = $request->session()->get('department');
$search = trim((string) $request->query('q', ''));
// Domyślny status jak w legacy: 3.
$status = (int) $request->query('status', 3);
[$sort, $dir] = $this->sortParams($request);
// Słownik statusów do filtra (legacy: DIC_ID in (3,4) + "Wszystkie").
$statusOptions = DB::connection('softspm')
->table('sys_dictionaries')
->whereIn('DIC_ID', [3, 4])
->orderBy('DIC_ID')
->pluck('DIC_POL_LANG', 'DIC_ID');
$wells = $this->buildQuery($department, $search, $status)
->when($sort !== null, fn ($q) => $q->orderBy(self::SORTABLE[$sort], $dir))
->orderBy('w.WLL_NAME')
->paginate(20)
->withQueryString();
// Stan ON/OFF dociągany tylko dla 20 widocznych studni (wydajność).
$this->attachOnOff($wells);
return view('wells.index', [
'wells' => $wells,
'search' => $search,
'status' => $status,
'statusOptions' => $statusOptions,
'statusAll' => self::STATUS_ALL,
'department' => $department,
]);
}
/** Formularz dodania studni (migracja site301A). */
public function create(WellService $wells)
{
return view('wells.create', $this->formLookups($wells));
}
/** Zapis nowej studni (migracja site301inf, dodawanie). */
public function store(Request $request, WellService $wells)
{
$data = $this->validateWell($request, $wells, isUpdate: false);
$drsId = $wells->create(
$data,
$request->session()->get('department'),
$request->user()->USR_USERNAME,
);
return redirect()
->route('wells.index')
->with('status', 'Dodano studnię „'.$data['name'].'".');
}
/** Formularz edycji studni (migracja site301E). */
public function edit(int $drs, WellService $wells)
{
$well = $wells->findForEdit($drs);
abort_if(! $well, 404);
return view('wells.edit', $this->formLookups($wells) + ['well' => $well]);
}
/** Aktualizacja studni (migracja site301inf, subWellUpdate). */
public function update(int $drs, Request $request, WellService $wells)
{
$data = $this->validateWell($request, $wells, isUpdate: true);
$wells->update($drs, $data, $request->user()->USR_USERNAME);
return redirect()
->route('wells.index')
->with('status', 'Zaktualizowano studnię „'.$data['name'].'".');
}
/** Wspólne słowniki do formularzy dodania/edycji. */
private function formLookups(WellService $wells): array
{
return [
'waterIntakes' => $wells->waterIntakes(),
'categories' => $wells->dictionaryById('WELL_CATEGORY'),
'statuses' => $wells->dictionaryById('WELL_STATUS_LEVEL_01'),
'sysTypes' => $wells->dictionaryDescriptions('WELL_SYSTEM_TYPE'),
'statusDescs' => $wells->dictionaryDescriptions('WELL_STATUS_LEVEL_03'),
];
}
/** Walidacja + normalizacja pól formularza studni. */
private function validateWell(Request $request, WellService $wells, bool $isUpdate): array
{
$rules = [
'name' => ['required', 'string', 'max:255'],
'water_intake_id' => ['required', 'integer', 'min:1'],
'category_id' => ['required', 'integer'],
'status_id' => ['required', 'integer'],
'latitude' => ['nullable', 'string', 'max:128'],
'longitude' => ['nullable', 'string', 'max:128'],
];
if ($isUpdate) {
$rules += [
'well_id' => ['required', 'integer'],
'pp_scope' => ['nullable', 'string', 'max:16'],
'hh_scope' => ['nullable', 'string', 'max:16'],
'sys_type' => ['nullable', 'string', 'max:128'],
'status_desc' => ['nullable', 'string', 'max:255'],
'available_date' => ['nullable', 'date'],
'build_time' => ['nullable', 'string', 'max:8'],
'wle_id' => ['nullable', 'integer'],
];
} else {
$rules += [
'diameter' => ['nullable', 'string', 'max:32'],
'depth' => ['nullable', 'string', 'max:32'],
'supply' => ['nullable', 'string', 'max:32'],
'available_date' => ['nullable', 'date'],
];
}
$data = $request->validate($rules);
// Unikalność nazwy tylko przy dodawaniu (legacy sprawdzał przed INSERT).
if (! $isUpdate && $wells->nameExists($data['name'])) {
throw ValidationException::withMessages(['name' => 'Studnia o tej nazwie już istnieje.']);
}
return $data;
}
/**
* Jedno zapytanie zastępujące listę + N+1 z legacy.
*
* Mapowanie tabel legacy:
* seg_wells (w) - studnia
* seg_dev_resources (drw,12) - zasób studni: DRS_ID, dział, status
* seg_well_pump_eng_rel (rel) - powiązanie studnia↔pompa/silnik
* seg_dev_resources (drp,10) - zasób pompy (nr seryjny)
* seg_pumps (p) - pompa (nazwa)
* seg_dev_resources (dre,11) - zasób silnika (nr seryjny)
* seg_engines (e) - silnik (nazwa)
* seg_dev_resources_det (507) - ostatni stan ON/OFF
*/
private function buildQuery(?string $department, string $search, int $status)
{
// UWAGA WYDAJNOŚĆ: NIE łączymy tu ostatniego ON/OFF z seg_dev_resources_det
// (tabela ma miliardy wierszy) - agregacja po całości była wąskim gardłem
// paginacji. Stan ON/OFF dociągamy osobno tylko dla bieżącej strony
// w attachOnOff().
return DB::connection('softspm')
->table('seg_wells as w')
->leftJoin('seg_dev_resources as drw', function ($j) {
$j->on('drw.DRS_DEV_ID', '=', 'w.WLL_ID')->where('drw.DRS_DEV_TYPE', '=', 12);
})
->leftJoin('seg_well_pump_eng_rel as rel', 'rel.RWL_WLL_ID', '=', 'w.WLL_ID')
// Pompa
->leftJoin('seg_dev_resources as drp', function ($j) {
$j->on('drp.DRS_ID', '=', 'rel.RWL_PUP_DRS_ID')->where('drp.DRS_DEV_TYPE', '=', 10);
})
->leftJoin('seg_pumps as p', 'p.PUP_ID', '=', 'drp.DRS_DEV_ID')
// Silnik
->leftJoin('seg_dev_resources as dre', function ($j) {
$j->on('dre.DRS_ID', '=', 'rel.RWL_ENG_DRS_ID')->where('dre.DRS_DEV_TYPE', '=', 11);
})
->leftJoin('seg_engines as e', 'e.ENG_ID', '=', 'dre.DRS_DEV_ID')
->where('drw.DRS_DEPARTMENT', $department)
->when($search !== '', fn ($q) => $q->where('w.WLL_NAME', 'like', '%'.$search.'%'))
->when($status !== self::STATUS_ALL, fn ($q) => $q->where('drw.DRS_STATUS_DIC_ID', $status))
->select([
'drw.DRS_ID as drs_id',
'w.WLL_NAME as well_name',
'w.WLL_BARIERA as bariera',
'p.PUP_NAME as pump_name',
'drp.DRS_SN_NUMBER as pump_no',
'e.ENG_NAME as engine_name',
'dre.DRS_SN_NUMBER as engine_no',
'w.WLL_SYS_TYPE as sys_type',
]);
}
/**
* Dociąga ostatni stan ON/OFF (DRD_RESOURCES_DETAIL=507) TYLKO dla studni
* z bieżącej strony - po ich DRS_ID (indeks INX_DRD_DRS_DETAIL_DATE),
* zamiast agregować całą tabelę seg_dev_resources_det. Każdy wiersz
* dostaje właściwość ->onoff.
*/
private function attachOnOff(\Illuminate\Contracts\Pagination\LengthAwarePaginator $wells): void
{
$ids = collect($wells->items())->pluck('drs_id')->filter()->values()->all();
$values = empty($ids) ? collect() : DB::connection('softspm')
->table('seg_dev_resources_det as d')
->joinSub(
DB::connection('softspm')
->table('seg_dev_resources_det')
->select('DRD_DRS_ID', DB::raw('MAX(DRD_ID) as max_id'))
->where('DRD_RESOURCES_DETAIL', 507)
->whereIn('DRD_DRS_ID', $ids)
->groupBy('DRD_DRS_ID'),
'mx',
'mx.max_id',
'=',
'd.DRD_ID'
)
->pluck('d.DRD_VALUE', 'd.DRD_DRS_ID');
foreach ($wells->items() as $row) {
$row->onoff = $values[$row->drs_id] ?? null;
}
}
}
@@ -0,0 +1,544 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\SortsLists;
use App\Services\PumpService;
use App\Services\SyndisService;
use App\Services\WellAssessmentService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Eksploatacja studni (legacy grupa site401). Osobny moduł pod sekcją
* Eksploatacja - NIE myli się z modułem Studnie (wells.*, sekcja Stan).
*
* Etap 1: lista eksploatacyjna (site401.php) + szkielet karty studni
* (site401mark). Dashboard ocen pracy układu, energochłonność, wykresy
* Hp/η, parametry pracy (site401inf) i podstrony (lift/mhis/offhis/demand)
* dokładamy w kolejnych etapach - opierają się na danych SYNDIS
* (rep_his_data_everyday, rep_soap_syndis_quality_data) i próbach pomp.
*/
class WellOperationController extends Controller
{
use SortsLists;
/** Status "Wszystkie" - wartość spoza słownika (jak legacy: 100). */
private const STATUS_ALL = 100;
/** Sortowalne kolumny listy (aliasy z joinów). */
private const SORTABLE = [
'name' => 'w.WLL_NAME',
'bariera' => 'w.WLL_BARIERA',
'pump' => 'p.PUP_NAME',
'pump_no' => 'drp.DRS_SN_NUMBER',
'engine' => 'e.ENG_NAME',
'engine_no' => 'dre.DRS_SN_NUMBER',
'type' => 'w.WLL_SYS_TYPE',
];
public function __construct(
private readonly PumpService $pumps,
private readonly WellAssessmentService $assess,
) {}
public function index(Request $request)
{
$department = $request->session()->get('department');
$search = trim((string) $request->query('q', ''));
$status = (int) $request->query('status', 3); // domyślnie jak legacy
[$sort, $dir] = $this->sortParams($request);
$statusOptions = DB::connection('softspm')->table('sys_dictionaries')
->whereIn('DIC_ID', [3, 4])
->orderBy('DIC_ID')
->pluck('DIC_POL_LANG', 'DIC_ID');
$wells = $this->buildQuery($department, $search, $status)
->when($sort !== null, fn ($q) => $q->orderBy(self::SORTABLE[$sort], $dir))
->orderBy('w.WLL_NAME')
->paginate(20)
->withQueryString();
$this->attachOnOff($wells);
return view('exploitation.wells.index', [
'wells' => $wells,
'search' => $search,
'status' => $status,
'statusOptions' => $statusOptions,
'statusAll' => self::STATUS_ALL,
'department' => $department,
]);
}
/** Karta eksploatacyjna studni (migracja site401mark). */
public function show(int $drs)
{
$well = $this->wellDetails($drs);
abort_if(! $well, 404);
$onoff = $this->lastOnOff($drs);
$m = $this->lastMeasurement($drs); // ostatni pomiar SYNDIS lub null
$lpDr = $this->lpDr($drs); // [Lp, Dr] lub [null, null]
$test = $this->pumpTest($well); // punkty próby pompy (kolekcja)
// Typ studni (WLL_CAT_DIC_ID): 18=BASE, 19=OPT, 20=inne (bez oceny), inaczej FULL.
$cat = (int) ($well->cat_dic_id ?? 0);
$grey = $cat === 20; // "SPM - inne" -> brak ocen (jak legacy)
// Krzywe z próby (skala BAZY = m³/h) - do obliczeń ocen/sprawności.
$hCurve = $test->map(fn ($p) => (object) ['x' => $p->q, 'y' => $p->h])->values();
$etaCurve = $test->map(fn ($p) => (object) ['x' => $p->q, 'y' => $p->n])->values();
$hasTest = $test->count() > 1;
$qn = (float) ($well->pump_qn ?? 0); // m³/h (obliczenia)
$q = $m?->q !== null ? (float) $m->q : null; // m³/h (obliczenia)
$hps = ($hasTest && $q !== null) ? $this->pumps->valueAt($hCurve, $q) : null; // do oceny H=f(Q)
$eta = $grey ? null : $this->assess->efficiency($q, $m?->hd, $m?->p2, $m?->pe);
$assessments = $grey ? null : [
'q' => $this->assess->q($qn, $q),
'hfq' => $this->assess->hfq($hps, $m?->pp, $m?->hh),
'throttle' => $this->assess->throttling($m?->hd, $m?->p1, $m?->p2),
'loss' => $this->assess->lineLoss($m?->p1, $m?->pp, $lpDr[0], $lpDr[1]),
];
// WYŚWIETLANIE: poza katalogami jednostka przepływu = m³/min (= m³/h / 60).
$toMin = fn (?float $v) => $v === null ? null : $v / 60;
$hCurveDisp = $hCurve->map(fn ($p) => (object) ['x' => $p->x / 60, 'y' => $p->y])->values();
$etaCurveDisp = $etaCurve->map(fn ($p) => (object) ['x' => $p->x / 60, 'y' => $p->y])->values();
$opQ = $toMin($q);
// Punkt pracy = wartości ZMIERZONE (nie rzutowane na krzywą próby):
// - wysokość podnoszenia Hp = Pp - Hh (manometryczna, z pomiaru),
// - sprawność = η obliczona (resETA). Odchyłka od krzywej = diagnostyka.
$opH = ($m && $m->pp !== null && $m->hh !== null) ? ((float) $m->pp - (float) $m->hh) : null;
$opEta = $eta;
$ry = $this->assess->redYellowLimit($qn);
$yg = $this->assess->yellowGreenLimit($qn);
return view('exploitation.wells.show', [
'well' => $well,
'onoff' => $onoff,
'measurement' => $m,
'qnMin' => $toMin($qn), // Qₙ w m³/min
'grey' => $grey,
'spmImage' => $this->spmImage($cat),
'diag' => $m?->dgn,
'assessments' => $assessments,
'energy' => $eta === null ? null : [
'eta' => $eta,
'ry' => $ry,
'yg' => $yg,
'zone' => $this->assess->energyZone($eta, $ry, $yg),
],
'hPlot' => $hasTest ? $this->pumps->plot($hCurveDisp) : null,
'etaPlot' => $hasTest ? $this->pumps->plot($etaCurveDisp) : null,
'opQ' => $opQ, // punkt pracy Q w m³/min
'opH' => $opH,
'opEta' => $opEta,
]);
}
/** Parametry pracy układu (migracja site401inf) - pełny zestaw pomiarów + dane techniczne. */
public function params(int $drs)
{
$well = $this->wellDetails($drs);
abort_if(! $well, 404);
$m = $this->lastMeasurement($drs);
// Dodatkowe parametry z detali (port getLastValue).
$extra = (object) [
'pb' => $this->lastDetailValue(225, $drs),
'throttle' => $this->lastDetailValue(730, $drs),
'energy_kwh' => $this->lastDetailValue(265, $drs),
'unit_energy' => $this->lastDetailValue(721, $drs),
];
// Parametry techniczne ze słownika WELL_TECHNICAL_PARAMETERS.
$techParams = DB::connection('softspm')->table('sys_dictionaries')
->where('DIC_TYPE', 'WELL_TECHNICAL_PARAMETERS')
->orderBy('DIC_ID')
->get(['DIC_ID', 'DIC_POL_LANG'])
->map(fn ($d) => (object) [
'label' => $d->DIC_POL_LANG,
'value' => $this->lastDetailValue((int) $d->DIC_ID, $drs),
]);
[$lp, $dr] = $this->lpDr($drs);
// Ostatnie zwierciadło statyczne >0 (jak site401bpow - osobne zapytanie).
$hst = DB::connection('softspm')->table('rep_his_data_everyday')
->where('RHD_DRS_ID', $drs)->where('RHD_HST_VAL', '>', 0)
->orderByDesc('RHD_ID')->value('RHD_HST_VAL');
return view('exploitation.wells.params', [
'well' => $well,
'measurement' => $m,
'extra' => $extra,
'techParams' => $techParams,
'lp' => $lp,
'dr' => $dr,
'mParam' => $this->lastDetailValue(251, $drs),
'category' => DB::connection('softspm')->table('sys_dictionaries')->where('DIC_ID', $well->cat_dic_id)->value('DIC_POL_LANG'),
'availableDate' => DB::connection('softspm')->table('seg_dev_resources')->where('DRS_ID', $drs)->value('DRS_AVAILABLE_DATE'),
'onoff' => $this->lastOnOff($drs),
'power' => $this->powerBalance($m, $lp, $dr, $hst === null ? null : (float) $hst),
'historyParams' => DB::connection('softspm')->table('sys_dictionaries')
->whereIn('DIC_ID', [200, 205, 210, 212, 215, 220, 245, 401, 402, 260, 270, 275, 280, 255])
->orderBy('DIC_POL_LANG')->pluck('DIC_POL_LANG', 'DIC_ID'),
]);
}
/** Ręczny zapis pomiaru (migracja edycji „Modyfikuj" z site401inf). */
public function paramsStore(int $drs, Request $request)
{
$well = $this->wellDetails($drs);
abort_if(! $well, 404);
$request->validate(['date' => ['required', 'date']]);
$num = fn ($k) => $request->filled($k) ? (float) str_replace(',', '.', (string) $request->input($k)) : null;
$dt = $request->input('date').' '.($request->filled('time') ? $request->input('time').':00' : now()->format('H:i:s'));
$user = $request->user()->USR_USERNAME;
$qH = $num('q') !== null ? $num('q') * 60 : null; // wpis w m³/min -> baza m³/h
DB::connection('softspm')->transaction(function () use ($drs, $num, $qH, $dt, $user) {
DB::connection('softspm')->table('rep_his_data_everyday')->insert([
'RHD_DRS_ID' => $drs,
'RHD_DATE' => $dt,
'RHD_Q_VAL' => $qH,
'RHD_HD_VAL' => $num('hd'),
'RHD_HST_VAL' => $num('hst'),
'RHD_HST0_VAL' => $num('hst0'),
'RHD_P1_VAL' => $num('p1'),
'RHD_P2_VAL' => $num('p2'),
'RHD_VM3_VAL' => $num('vm3'),
'RHD_WTEMP_VAL' => $num('wtemp'),
'RHD_PP_VAL' => $num('pp'),
'RHD_HH_VAL' => $num('hh'),
'RHD_PE_VAL' => $num('pe'),
'RHD_I_VAL' => $num('i'),
'RHD_U_VAL' => $num('u'),
'RHD_COS_VAL' => $num('cos'),
'RHD_WORK_TIME_VAL' => $num('work_time'),
'RHD_AUDIT_CD' => now()->toDateTimeString(),
]);
$details = [
200 => $qH, 205 => $num('hd'), 210 => $num('hst'), 212 => $num('hst0'),
215 => $num('p1'), 220 => $num('p2'), 225 => $num('pb'), 230 => $num('vm3'),
245 => $num('wtemp'), 401 => $num('pp'), 402 => $num('hh'), 730 => $num('throttle'),
260 => $num('pe'), 265 => $num('energy_kwh'), 270 => $num('i'), 275 => $num('u'),
280 => $num('cos'), 290 => $num('work_time'), 721 => $num('unit_energy'),
];
foreach ($details as $detail => $value) {
if ($value === null) {
continue;
}
DB::connection('softspm')->table('seg_dev_resources_det')->insert([
'DRD_DRS_ID' => $drs,
'DRD_RESOURCES_DETAIL' => $detail,
'DRD_VALUE' => (string) $value,
'DRD_AUDIT_USR' => $user,
'DRD_AUDIT_DATE' => $dt,
]);
}
});
return redirect()->route('well-operation.params', $drs)->with('status', 'Zapisano ręczny pomiar.');
}
/**
* Bilans mocy układu (port nowszego site401bpow.php). Dane z ostatniego
* pomiaru (rep_his_data_everyday) + Lp/Dr. Skala Q = m³/h (stała 0,002752
* ≈ moc hydrauliczna [kW] = Q·H/363). Zwraca składowe mocy lub null.
*/
private function powerBalance(?object $m, ?float $lp, ?float $dr, ?float $hst): ?array
{
if (! $m || $m->q === null || $m->pe === null) {
return null;
}
$q = (float) $m->q;
$pe = (float) $m->pe;
$pp = (float) ($m->pp ?? 0);
$hh = (float) ($m->hh ?? 0);
$p1 = (float) ($m->p1 ?? 0);
$p2 = (float) ($m->p2 ?? 0);
$hstV = (float) ($hst ?? 0);
$lpV = (float) ($lp ?? 0);
$drV = (float) ($dr ?? 0);
$k = 0.002752;
$vel = $drV > 0 ? $q / (2826 * $drV * $drV) : 0.0; // prędkość w rurociągu
$hct = ($vel * $vel) / 19.62; // wysokość prędkości
$pod = $k * $q * (($pp - $hh) + $hct);
$paz = $pe - $pod;
$psl = $k * $q * ($pp - ($lpV + $p1));
$psa = $k * $q * ($p1 - $p2);
$pp2 = $k * $q * ($p2 + $hct);
$pst = $k * $q * $hstV;
$pgh = $pod - ($pst + $pp2 + $psl + $psa);
return [
'pe' => $pe,
'pod' => $pod,
'paz' => $paz,
'psl' => $psl,
'psa' => $psa,
'pp2' => $pp2,
'pst' => $pst,
'pgh' => $pgh,
'sum' => $paz + $psl + $psa + $pp2 + $pst + $pgh,
];
}
/** Pobranie aktualnych danych z SYNDIS (migracja site401mark&data=refresh + ajax_getdata4well). */
public function fetchData(int $drs, Request $request, SyndisService $syndis)
{
$well = $this->wellDetails($drs);
abort_if(! $well, 404);
try {
$saved = $syndis->refreshWell($drs, $well->well_name, $request->user()->USR_USERNAME);
return redirect()->route('well-operation.show', $drs)->with(
$saved ? 'status' : 'error',
$saved
? 'Pobrano aktualne dane z SYNDIS dla studni „'.$well->well_name.'".'
: 'Studnia wyłączona lub brak aktualnych danych w SYNDIS.'
);
} catch (\Throwable $e) {
return redirect()->route('well-operation.show', $drs)
->with('error', 'Nie udało się pobrać danych z SYNDIS: '.$e->getMessage());
}
}
/**
* Lista eksploatacyjna - jedno zapytanie z joinami (pompa/silnik), stan
* ON/OFF dociągany osobno dla bieżącej strony. Wierne site401.php.
*/
private function buildQuery(?string $department, string $search, int $status)
{
return DB::connection('softspm')
->table('seg_wells as w')
->leftJoin('seg_dev_resources as drw', function ($j) {
$j->on('drw.DRS_DEV_ID', '=', 'w.WLL_ID')->where('drw.DRS_DEV_TYPE', '=', 12);
})
->leftJoin('seg_well_pump_eng_rel as rel', 'rel.RWL_WLL_ID', '=', 'w.WLL_ID')
->leftJoin('seg_dev_resources as drp', function ($j) {
$j->on('drp.DRS_ID', '=', 'rel.RWL_PUP_DRS_ID')->where('drp.DRS_DEV_TYPE', '=', 10);
})
->leftJoin('seg_pumps as p', 'p.PUP_ID', '=', 'drp.DRS_DEV_ID')
->leftJoin('seg_dev_resources as dre', function ($j) {
$j->on('dre.DRS_ID', '=', 'rel.RWL_ENG_DRS_ID')->where('dre.DRS_DEV_TYPE', '=', 11);
})
->leftJoin('seg_engines as e', 'e.ENG_ID', '=', 'dre.DRS_DEV_ID')
->where('drw.DRS_DEPARTMENT', $department)
->when($search !== '', fn ($q) => $q->where('w.WLL_NAME', 'like', '%'.$search.'%'))
->when($status !== self::STATUS_ALL, fn ($q) => $q->where('drw.DRS_STATUS_DIC_ID', $status))
->select([
'drw.DRS_ID as drs_id',
'w.WLL_NAME as well_name',
'w.WLL_BARIERA as bariera',
'p.PUP_NAME as pump_name',
'drp.DRS_SN_NUMBER as pump_no',
'e.ENG_NAME as engine_name',
'dre.DRS_SN_NUMBER as engine_no',
'w.WLL_SYS_TYPE as sys_type',
]);
}
/** Stan ON/OFF (DRD_RESOURCES_DETAIL=507) tylko dla studni z bieżącej strony. */
private function attachOnOff(LengthAwarePaginator $wells): void
{
$ids = collect($wells->items())->pluck('drs_id')->filter()->values()->all();
$values = empty($ids) ? collect() : DB::connection('softspm')
->table('seg_dev_resources_det as d')
->joinSub(
DB::connection('softspm')->table('seg_dev_resources_det')
->select('DRD_DRS_ID', DB::raw('MAX(DRD_ID) as max_id'))
->where('DRD_RESOURCES_DETAIL', 507)
->whereIn('DRD_DRS_ID', $ids)
->groupBy('DRD_DRS_ID'),
'mx', 'mx.max_id', '=', 'd.DRD_ID'
)
->pluck('d.DRD_VALUE', 'd.DRD_DRS_ID');
foreach ($wells->items() as $row) {
$row->onoff = $values[$row->drs_id] ?? null;
}
}
/** Podstawowe dane studni + układu pompowego (część site401mark). */
private function wellDetails(int $drs): ?object
{
return DB::connection('softspm')
->table('seg_dev_resources as drw')
->join('seg_wells as w', 'w.WLL_ID', '=', 'drw.DRS_DEV_ID')
->leftJoin('seg_well_pump_eng_rel as rel', 'rel.RWL_WLL_ID', '=', 'w.WLL_ID')
->leftJoin('seg_dev_resources as drp', function ($j) {
$j->on('drp.DRS_ID', '=', 'rel.RWL_PUP_DRS_ID')->where('drp.DRS_DEV_TYPE', '=', 10);
})
->leftJoin('seg_pumps as p', 'p.PUP_ID', '=', 'drp.DRS_DEV_ID')
->leftJoin('seg_dev_resources as dre', function ($j) {
$j->on('dre.DRS_ID', '=', 'rel.RWL_ENG_DRS_ID')->where('dre.DRS_DEV_TYPE', '=', 11);
})
->leftJoin('seg_engines as e', 'e.ENG_ID', '=', 'dre.DRS_DEV_ID')
->where('drw.DRS_ID', $drs)
->where('drw.DRS_DEV_TYPE', 12)
->select([
'drw.DRS_ID as drs_id',
'w.WLL_ID as well_id',
'w.WLL_NAME as well_name',
'w.WLL_BARIERA as bariera',
'w.WLL_SYS_TYPE as sys_type',
'w.WLL_CAT_DIC_ID as cat_dic_id',
'p.PUP_ID as pump_id',
'p.PUP_NAME as pump_name',
'drp.DRS_SN_NUMBER as pump_no',
'p.PUP_Q_N as pump_qn',
'e.ENG_NAME as engine_name',
'dre.DRS_SN_NUMBER as engine_no',
'e.ENG_VOLTAGE as engine_voltage',
])
->first();
}
/** Ostatni pomiar SYNDIS (rep_his_data_everyday) dla studni lub null. */
private function lastMeasurement(int $drs): ?object
{
$r = DB::connection('softspm')->table('rep_his_data_everyday')
->where('RHD_DRS_ID', $drs)
->orderByDesc('RHD_ID')
->first();
if (! $r) {
return null;
}
return (object) [
'q' => $r->RHD_Q_VAL,
'hd' => $r->RHD_HD_VAL,
'hst' => $r->RHD_HST_VAL,
'hst0' => $r->RHD_HST0_VAL,
'p1' => $r->RHD_P1_VAL,
'p2' => $r->RHD_P2_VAL,
'pp' => $r->RHD_PP_VAL,
'hh' => $r->RHD_HH_VAL,
'pe' => $r->RHD_PE_VAL,
'i' => $r->RHD_I_VAL,
'u' => $r->RHD_U_VAL,
'cos' => $r->RHD_COS_VAL,
'vm3' => $r->RHD_VM3_VAL,
'wtemp' => $r->RHD_WTEMP_VAL,
'work_time' => $r->RHD_WORK_TIME_VAL,
'dgn' => $r->RHD_DGN_VAL,
'date' => $r->RHD_DATE,
];
}
/**
* Lp (głębokość zabudowy pompy, detal 240) i Dr (średnica rurociągu,
* detal 235/1000). Zastępuje funkcję składowaną MySQL getLastValue
* własnym zapytaniem (optymalizacja - bez zależności od procedur w bazie).
*/
private function lpDr(int $drs): array
{
$lp = $this->lastDetailValue(240, $drs);
$dr = $this->lastDetailValue(235, $drs);
return [
$lp !== null ? (float) $lp : null,
$dr !== null ? (float) $dr / 1000 : null,
];
}
/**
* Ostatnia wartość detalu zasobu (port funkcji składowanej getLastValue):
* DRD_VALUE z seg_dev_resources_det dla danego detalu i zasobu, po
* najpóźniejszej dacie audytu (rozstrzyganie po DRD_ID).
*/
private function lastDetailValue(int $detail, int $drs): ?string
{
return DB::connection('softspm')->table('seg_dev_resources_det')
->where('DRD_RESOURCES_DETAIL', $detail)
->where('DRD_DRS_ID', $drs)
->orderByDesc('DRD_AUDIT_DATE')
->orderByDesc('DRD_ID')
->value('DRD_VALUE');
}
/**
* Punkty próby pompy ze stacji prób (tst_tests + tst_test_details):
* q=TSD_VAL_04, h=TSD_VAL_08, p=TSD_VAL_14, n=TSD_VAL_18 (skala m³/h, bez /60).
* Dobór próby: po PUP_ID + nr fabrycznych pompy/silnika (fallback bez silnika).
*/
private function pumpTest(object $well): Collection
{
if (empty($well->pump_id)) {
return collect();
}
$base = DB::connection('softspm')->table('tst_tests')
->where('TST_PUP_ID', $well->pump_id)
->where('TST_PUP_SN_NO', $well->pump_no);
$testId = (clone $base)->where('TST_ENG_SN_NO', $well->engine_no)->max('TST_ID')
?? $base->max('TST_ID');
if (! $testId) {
return collect();
}
return DB::connection('softspm')->table('tst_test_details')
->where('TSD_TST_ID', $testId)
->orderBy('TSD_MSR_STEP')
->get()
->map(fn ($d) => (object) [
'q' => (float) $d->TSD_VAL_04,
'h' => (float) $d->TSD_VAL_08,
'p' => (float) $d->TSD_VAL_14,
'n' => (float) $d->TSD_VAL_18,
])
->filter(fn ($p) => $p->q > 0)
->values();
}
/** Plik schematu SPM wg kategorii studni (18=BASE, 19=OPT, inaczej FULL). */
private function spmImage(int $cat): string
{
return match ($cat) {
18 => 'images/spm_base.png',
19 => 'images/spm_opt.png',
default => 'images/spm_full.png',
};
}
/** Ostatni wpis ON/OFF dla studni (wartość, data, powód wyłączenia). */
private function lastOnOff(int $drs): ?object
{
$row = DB::connection('softspm')->table('seg_dev_resources_det')
->where('DRD_RESOURCES_DETAIL', 507)
->where('DRD_DRS_ID', $drs)
->orderByDesc('DRD_ID')
->first();
if ($row && ! empty($row->DRD_DESCRIPTION)) {
$row->reason = DB::connection('softspm')->table('sys_dictionaries')
->where('DIC_ID', $row->DRD_DESCRIPTION)
->value('DIC_POL_LANG');
}
return $row;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Ustawia język aplikacji na podstawie cookie 'locale' (PL/EN/DE) - wybór
* z menu użytkownika. Tłumaczenia stringów to osobny krok (i18n legacy
* language/*.php -> lang/); tu wyłącznie ustawienie locale + atrybut lang.
*/
class SetLocale
{
private const SUPPORTED = ['pl', 'en', 'de'];
public function handle(Request $request, Closure $next): Response
{
$locale = $request->cookie('locale');
if (is_string($locale) && in_array($locale, self::SUPPORTED, true)) {
app()->setLocale($locale);
}
return $next($request);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Walidacja dodania/edycji typu kabla (site206A/E). Pliki opcjonalne:
* dokumentacja katalogowa (PDF/Office) oraz zdjęcie. Limit 8 MB (mediumblob 16 MB).
*/
class StoreCableRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'description' => ['nullable', 'string', 'max:255'],
'catalogue_file' => ['nullable', 'file', 'mimes:pdf,doc,docx,xls,xlsx,ppt,pptx', 'max:8192'],
'image_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,bmp', 'max:8192'],
];
}
public function attributes(): array
{
return [
'name' => 'typ kabla',
'manufacturer_id' => 'producent',
'description' => 'opis',
'catalogue_file' => 'dokumentacja katalogowa',
'image_file' => 'zdjęcie',
];
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreEngineRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
foreach (['power', 'voltage', 'current', 'eta', 'cos_fi', 'rpm', 'length', 'weight', 'diameter'] as $f) {
$this->merge([$f => $this->dec($this->$f)]);
}
}
public function rules(): array
{
return [
// Unikalność: nazwa + napięcie (jak legacy).
'name' => ['required', 'string', 'max:128', Rule::unique('seg_engines', 'ENG_NAME')->where(fn ($q) => $q->where('ENG_VOLTAGE', $this->voltage))],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'power' => ['required', 'numeric'],
'voltage' => ['required', 'numeric'],
'current' => ['nullable', 'numeric'],
'eta' => ['nullable', 'numeric'],
'cos_fi' => ['nullable', 'numeric'],
'rpm' => ['nullable', 'numeric'],
'length' => ['nullable', 'numeric'],
'weight' => ['nullable', 'numeric'],
'diameter' => ['nullable', 'numeric'],
'wires' => ['nullable', 'string', 'max:32'],
];
}
private function dec(mixed $v): ?string
{
return $v === null || $v === '' ? null : str_replace(',', '.', (string) $v);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Walidacja dodania/edycji urządzenia opomiarowania (site208A/E). Pola jak
* w kablach (nazwa, producent, opis, pliki). Kategoria (EQU_PARAM_02=SPM_FULL)
* ustawiana automatycznie w serwisie/kontrolerze - jak legacy normalny dodawania.
*/
class StoreMeteringRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'description' => ['nullable', 'string', 'max:255'],
'catalogue_file' => ['nullable', 'file', 'mimes:pdf,doc,docx,xls,xlsx,ppt,pptx', 'max:8192'],
'image_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,bmp', 'max:8192'],
];
}
public function attributes(): array
{
return [
'name' => 'nazwa',
'manufacturer_id' => 'producent',
'description' => 'opis',
'catalogue_file' => 'dokumentacja katalogowa',
'image_file' => 'zdjęcie',
];
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Walidacja dodania/edycji orurowania i armatury (site207A/E). Średnica
* wymagana (legacy float_req), długość/ciśnienie opcjonalne. Pliki jak w kablach.
*/
class StorePipingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'diameter' => ['required', 'string', 'max:128'],
'length' => ['nullable', 'string', 'max:128'],
'description' => ['nullable', 'string', 'max:255'],
'catalogue_file' => ['nullable', 'file', 'mimes:pdf,doc,docx,xls,xlsx,ppt,pptx', 'max:8192'],
'image_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,bmp', 'max:8192'],
];
}
public function attributes(): array
{
return [
'name' => 'nazwa',
'manufacturer_id' => 'producent',
'diameter' => 'średnica',
'length' => 'długość / ciśnienie',
'description' => 'opis',
'catalogue_file' => 'dokumentacja katalogowa',
'image_file' => 'zdjęcie',
];
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePumpRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'q_n' => $this->dec($this->q_n),
'length' => $this->dec($this->length),
'diameter' => $this->dec($this->diameter),
'weight' => $this->dec($this->weight),
'h' => collect($this->h ?? [])->map(fn ($v) => $this->dec($v))->all(),
'eta' => collect($this->eta ?? [])->map(fn ($v) => $this->dec($v))->all(),
]);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:128', 'unique:seg_pumps,PUP_NAME'],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'q_n' => ['required', 'numeric', 'gt:0'],
// 8 punktów krzywych - H wymagane (>0), η wymagane (0 dozwolone przy Q=0).
'h' => ['required', 'array', 'size:8'],
'h.*' => ['required', 'numeric'],
'eta' => ['required', 'array', 'size:8'],
'eta.*' => ['required', 'numeric'],
'length' => ['nullable', 'numeric'],
'diameter' => ['nullable', 'numeric'],
'weight' => ['nullable', 'numeric'],
];
}
public function messages(): array
{
return [
'h.*.required' => 'Podaj wartość H dla każdego punktu Q.',
'eta.*.required' => 'Podaj wartość η dla każdego punktu Q.',
];
}
private function dec(mixed $v): ?string
{
return $v === null || $v === '' ? null : str_replace(',', '.', (string) $v);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Walidacja edycji typu kabla (site206E). Reguły jak przy dodawaniu - wgranie
* nowego pliku zastępuje dotychczasowy tej samej roli; brak pliku = bez zmian.
*/
class UpdateCableRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'description' => ['nullable', 'string', 'max:255'],
'catalogue_file' => ['nullable', 'file', 'mimes:pdf,doc,docx,xls,xlsx,ppt,pptx', 'max:8192'],
'image_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,bmp', 'max:8192'],
];
}
public function attributes(): array
{
return [
'name' => 'typ kabla',
'manufacturer_id' => 'producent',
'description' => 'opis',
'catalogue_file' => 'dokumentacja katalogowa',
'image_file' => 'zdjęcie',
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEngineRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
foreach (['power', 'voltage', 'current', 'eta', 'cos_fi', 'rpm', 'length', 'weight', 'diameter'] as $f) {
$this->merge([$f => $this->dec($this->$f)]);
}
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:128', Rule::unique('seg_engines', 'ENG_NAME')->where(fn ($q) => $q->where('ENG_VOLTAGE', $this->voltage))->ignore($this->route('engine'))],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'power' => ['required', 'numeric'],
'voltage' => ['required', 'numeric'],
'current' => ['nullable', 'numeric'],
'eta' => ['nullable', 'numeric'],
'cos_fi' => ['nullable', 'numeric'],
'rpm' => ['nullable', 'numeric'],
'length' => ['nullable', 'numeric'],
'weight' => ['nullable', 'numeric'],
'diameter' => ['nullable', 'numeric'],
'wires' => ['nullable', 'string', 'max:32'],
];
}
private function dec(mixed $v): ?string
{
return $v === null || $v === '' ? null : str_replace(',', '.', (string) $v);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Walidacja edycji urządzenia opomiarowania (site208E). Reguły jak przy
* dodawaniu; wgranie nowego pliku zastępuje dotychczasowy tej samej roli.
*/
class UpdateMeteringRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'description' => ['nullable', 'string', 'max:255'],
'catalogue_file' => ['nullable', 'file', 'mimes:pdf,doc,docx,xls,xlsx,ppt,pptx', 'max:8192'],
'image_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,bmp', 'max:8192'],
];
}
public function attributes(): array
{
return [
'name' => 'nazwa',
'manufacturer_id' => 'producent',
'description' => 'opis',
'catalogue_file' => 'dokumentacja katalogowa',
'image_file' => 'zdjęcie',
];
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Walidacja edycji orurowania i armatury (site207E). Reguły jak przy dodawaniu;
* wgranie nowego pliku zastępuje dotychczasowy tej samej roli.
*/
class UpdatePipingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'diameter' => ['required', 'string', 'max:128'],
'length' => ['nullable', 'string', 'max:128'],
'description' => ['nullable', 'string', 'max:255'],
'catalogue_file' => ['nullable', 'file', 'mimes:pdf,doc,docx,xls,xlsx,ppt,pptx', 'max:8192'],
'image_file' => ['nullable', 'image', 'mimes:jpg,jpeg,png,gif,bmp', 'max:8192'],
];
}
public function attributes(): array
{
return [
'name' => 'nazwa',
'manufacturer_id' => 'producent',
'diameter' => 'średnica',
'length' => 'długość / ciśnienie',
'description' => 'opis',
'catalogue_file' => 'dokumentacja katalogowa',
'image_file' => 'zdjęcie',
];
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePumpRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->merge([
'length' => $this->dec($this->length),
'diameter' => $this->dec($this->diameter),
'weight' => $this->dec($this->weight),
'h' => collect($this->h ?? [])->map(fn ($v) => $this->dec($v))->all(),
'eta' => collect($this->eta ?? [])->map(fn ($v) => $this->dec($v))->all(),
]);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:128', Rule::unique('seg_pumps', 'PUP_NAME')->ignore($this->route('pump'))],
'manufacturer_id' => ['required', 'integer', 'exists:seg_manufacturers,MCF_ID'],
'length' => ['nullable', 'numeric'],
'diameter' => ['nullable', 'numeric'],
'weight' => ['nullable', 'numeric'],
'h' => ['nullable', 'array'],
'h.*' => ['nullable', 'numeric'],
'eta' => ['nullable', 'array'],
'eta.*' => ['nullable', 'numeric'],
];
}
private function dec(mixed $v): ?string
{
return $v === null || $v === '' ? null : str_replace(',', '.', (string) $v);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** Silnik - katalog (legacy site204). Tabela seg_engines (database-first). */
class Engine extends Model
{
protected $connection = 'softspm';
protected $table = 'seg_engines';
protected $primaryKey = 'ENG_ID';
public $timestamps = false;
protected $guarded = [];
public function manufacturer(): BelongsTo
{
return $this->belongsTo(Manufacturer::class, 'ENG_MCF_ID', 'MCF_ID');
}
/** Tylko widoczne (ENG_STATUS='1'). */
public function scopeVisible(Builder $q): Builder
{
return $q->where('ENG_STATUS', '1');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Wyposażenie z katalogów (legacy seg_equipments). Typ rozróżniany przez
* EQU_TYPE_ID (sys_dictionaries): 13=kable (site206); 207/208 dołożą kolejne.
* Database-first na bazie `softspm`.
*/
class Equipment extends Model
{
protected $connection = 'softspm';
protected $table = 'seg_equipments';
protected $primaryKey = 'EQU_ID';
public $timestamps = false;
protected $guarded = [];
public function manufacturer(): BelongsTo
{
return $this->belongsTo(Manufacturer::class, 'EQU_MCF_ID', 'MCF_ID');
}
public function files(): HasMany
{
return $this->hasMany(EquipmentFile::class, 'EQF_EQU_ID', 'EQU_ID');
}
/** Tylko widoczne (EQU_STATUS='1', nieusunięte logicznie). */
public function scopeVisible(Builder $q): Builder
{
return $q->where('EQU_STATUS', '1');
}
/** Filtr po rodzaju wyposażenia (EQU_TYPE_ID). */
public function scopeOfType(Builder $q, int $typeId): Builder
{
return $q->where('EQU_TYPE_ID', $typeId);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Załącznik wyposażenia (legacy seg_equipments_files) - plik trzymany jako BLOB
* w bazie (EQF_FILE mediumblob). Rola pliku wynika z prefiksu MIME:
* 'image/*' = zdjęcie, pozostałe (np. 'application/pdf') = dokumentacja.
*/
class EquipmentFile extends Model
{
protected $connection = 'softspm';
protected $table = 'seg_equipments_files';
protected $primaryKey = 'EQF_ID';
public $timestamps = false;
protected $guarded = [];
/** Czy to plik graficzny (zdjęcie). */
public function isImage(): bool
{
return str_starts_with(strtolower((string) $this->EQF_FILE_TYPE), 'image/');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/** Producent - tabela seg_manufacturers. */
class Manufacturer extends Model
{
protected $connection = 'softspm';
protected $table = 'seg_manufacturers';
protected $primaryKey = 'MCF_ID';
public $timestamps = false;
protected $guarded = [];
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Pompa - katalog (legacy site203). Tabela seg_pumps (database-first).
* Uwaga: PUP_Q_N i krzywe PEP_X_PARAM trzymane ×60; w UI dzielimy /60 (m³/min).
*/
class Pump extends Model
{
protected $connection = 'softspm';
protected $table = 'seg_pumps';
protected $primaryKey = 'PUP_ID';
public $timestamps = false;
protected $guarded = [];
public function manufacturer(): BelongsTo
{
return $this->belongsTo(Manufacturer::class, 'PUP_MCF_ID', 'MCF_ID');
}
public function curvePoints(): HasMany
{
return $this->hasMany(PumpCurvePoint::class, 'PEP_PUP_ID', 'PUP_ID');
}
/** Tylko widoczne (PUP_STATUS='1', nieusunięte logicznie). */
public function scopeVisible(Builder $q): Builder
{
return $q->where('PUP_STATUS', '1');
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Punkt krzywej pompy (Q-H lub Q-ETA) - tabela seg_pump_eng_param.
* PEP_TYPE: 'H' | 'ETA'. PEP_X_PARAM trzymane ×60 (m³/min po /60).
*/
class PumpCurvePoint extends Model
{
protected $connection = 'softspm';
protected $table = 'seg_pump_eng_param';
protected $primaryKey = 'PEP_ID';
public $timestamps = false;
protected $guarded = [];
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/**
* Model użytkownika - DATABASE-FIRST na istniejącej tabeli `sys_users`
* (baza operacyjna `softspm`). NIE zmieniamy schematu.
*
* Kolumny legacy (z func.php / index.php):
* USR_ID - PK
* USR_USERNAME - login
* USR_PASSWORD - hash hasła (legacy: MD5, docelowo bcrypt - patrz LoginController)
* USR_STATUS - '1' = aktywny
* USR_PRT_ID - uczestnik/system (sys_participants)
*/
class User extends Authenticatable implements AuthenticatableContract
{
use Notifiable;
protected $connection = 'softspm';
protected $table = 'sys_users';
protected $primaryKey = 'USR_ID';
/** Tabela legacy nie ma created_at/updated_at. */
public $timestamps = false;
/** Tabela legacy nie ma kolumny remember_token. */
protected $rememberTokenName = '';
protected $hidden = [
'USR_PASSWORD',
];
protected $guarded = [];
/**
* Laravel Auth: która kolumna trzyma hash hasła.
*/
public function getAuthPassword()
{
return $this->USR_PASSWORD;
}
/**
* Wyłączamy mechanizm "remember me" - brak kolumny w schemacie legacy.
*/
public function getRememberToken()
{
return null;
}
public function setRememberToken($value)
{
// no-op
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Services;
use App\Models\EquipmentFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
/**
* Zapis załączników wyposażenia jako BLOB w seg_equipments_files (legacy site206).
* Na jedno wyposażenie przypada max jeden plik danej roli (pierwszy człon MIME):
* 'application' = dokumentacja katalogowa, 'image' = zdjęcie. Wgranie nowego
* pliku tej samej roli zastępuje poprzedni - jak legacy.
*/
class EquipmentFileService
{
/** Zapisuje (zastępując plik tej samej roli) załącznik wyposażenia. */
public function store(int $equId, UploadedFile $file, string $user): void
{
$mime = $file->getMimeType() ?: ($file->getClientMimeType() ?: 'application/octet-stream');
$role = strtolower(strtok($mime, '/')); // 'application' | 'image' | ...
// Usuń dotychczasowy plik tej roli (legacy trzymał 1 kartę + 1 zdjęcie).
EquipmentFile::on('softspm')
->where('EQF_EQU_ID', $equId)
->whereRaw("lower(substring_index(EQF_FILE_TYPE, '/', 1)) = ?", [$role])
->delete();
EquipmentFile::create([
'EQF_EQU_ID' => $equId,
'EQF_FILE' => $file->getContent(),
'EQF_FILE_TYPE' => $mime,
'EQF_FILE_SIZE' => (string) $file->getSize(),
'EQF_FILE_NAME' => $this->sanitize($file->getClientOriginalName()),
'EQF_STATUS' => '1',
'EQF_AUDIT_CU' => $user,
'EQF_AUDIT_CD' => Carbon::now()->toDateTimeString(),
]);
}
/**
* Czyszczenie nazwy pliku jak legacy delPolishAndBlankCharactersFromFileName:
* polskie znaki -> ASCII, spacje/myślniki -> '_', usunięcie znaków
* specjalnych, małe litery. Zachowuje kropkę (rozszerzenie). Limit 128 zn.
*/
public function sanitize(string $name): string
{
$map = [
'ą'=>'a','ż'=>'z','ź'=>'z','ć'=>'c','ś'=>'s','ę'=>'e','ó'=>'o','ł'=>'l','ń'=>'n',
'Ą'=>'a','Ż'=>'z','Ź'=>'z','Ć'=>'c','Ś'=>'s','Ę'=>'e','Ó'=>'o','Ł'=>'l','Ń'=>'n',
];
$val = strtr($name, $map);
$val = str_replace([' ', '-'], '_', $val);
$val = preg_replace('/[^A-Za-z0-9._]/', '', $val);
return substr(strtolower($val), 0, 128);
}
}
+276
View File
@@ -0,0 +1,276 @@
<?php
namespace App\Services;
use App\Models\Pump;
use App\Models\PumpCurvePoint;
use App\Support\PolynomialRegression;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Logika domenowa pomp (legacy site203 / site203inf) - funkcjonalność 1:1.
*
* SKALA: baza UAT trzyma Q/krzywe BEZ ×60 (PUP_Q_N, PEP_X_PARAM w m³/h) -
* nie dzielimy/mnożymy przez 60.
*
* Krzywe Q-H i Q-η: 8 punktów przy stałych ułamkach Q_n (indeks 4 = nominał).
*/
class PumpService
{
private const Q_FACTORS = [0.0, 0.25, 0.55, 0.7, 1.0, 1.22, 1.35, 1.42];
private const NOMINAL_INDEX = 4;
/** Kolory segmentów krzywej (wg algorytmu legacy). */
private const BAND_COLORS = ['red' => '#c62828', 'amber' => '#f9a825', 'green' => '#2e7d32'];
private function db()
{
return DB::connection('softspm');
}
// ===================== LOOKUPY / ZAPIS =====================
/**
* Dodanie pompy. Użytkownik podaje H i η dla każdego z 8 punktów Q
* (Q generowane z Q_n). Wszystko transakcyjnie.
*
* @param array $data name, manufacturer_id, q_n, length, diameter, weight,
* h: float[8], eta: float[8] (w kolejności rosnącego Q)
*/
public function create(array $data): Pump
{
return $this->db()->transaction(function () use ($data) {
$qn = (float) $data['q_n'];
$h = array_values($data['h']);
$eta = array_values($data['eta']);
$pump = Pump::create([
'PUP_MCF_ID' => $data['manufacturer_id'],
'PUP_NAME' => $data['name'],
'PUP_Q_N' => $qn,
'PUP_Q_MIN' => 0.55 * $qn,
'PUP_Q_MAX' => 1.35 * $qn,
'PUP_H_P' => $h[self::NOMINAL_INDEX] ?? null,
'PUP_H_MAX' => $h[2] ?? null,
'PUP_H_MIN' => $h[6] ?? null,
'PUP_ETA_MAX' => $eta[self::NOMINAL_INDEX] ?? null,
'PUP_PUMP_LENGTH' => $data['length'] ?? null,
'PUP_WEIGHT' => $data['weight'] ?? null,
'PUP_DIAMETER' => $data['diameter'] ?? null,
'PUP_STATUS' => '1',
]);
$rows = [];
foreach (self::Q_FACTORS as $i => $f) {
$x = round($f * $qn, 4);
$rows[] = ['PEP_PUP_ID' => $pump->PUP_ID, 'PEP_X_PARAM' => $x, 'PEP_Y_PARAM' => (float) ($h[$i] ?? 0), 'PEP_TYPE' => 'H'];
$rows[] = ['PEP_PUP_ID' => $pump->PUP_ID, 'PEP_X_PARAM' => $x, 'PEP_Y_PARAM' => (float) ($eta[$i] ?? 0), 'PEP_TYPE' => 'ETA'];
}
PumpCurvePoint::insert($rows);
return $pump;
});
}
/**
* Aktualizacja pompy: dane + wartości Y krzywych (po PEP_ID) + pochodne
* (3. punkt H=H_MAX, 5.=H_P, 7.=H_MIN; 5. ETA=ETA_MAX) - jak legacy.
*/
public function update(Pump $pump, array $data): void
{
$this->db()->transaction(function () use ($pump, $data) {
$pump->update([
'PUP_MCF_ID' => $data['manufacturer_id'],
'PUP_NAME' => $data['name'],
'PUP_PUMP_LENGTH' => $data['length'] ?? null,
'PUP_WEIGHT' => $data['weight'] ?? null,
'PUP_DIAMETER' => $data['diameter'] ?? null,
]);
$hPoints = $pump->curvePoints()->where('PEP_TYPE', 'H')->orderBy('PEP_X_PARAM')->get();
foreach ($hPoints->values() as $idx => $point) {
$y = $this->num($data['h'][$point->PEP_ID] ?? $point->PEP_Y_PARAM);
$point->update(['PEP_Y_PARAM' => $y]);
match ($idx + 1) {
3 => $pump->update(['PUP_H_MAX' => $y]),
5 => $pump->update(['PUP_H_P' => $y]),
7 => $pump->update(['PUP_H_MIN' => $y]),
default => null,
};
}
$etaPoints = $pump->curvePoints()->where('PEP_TYPE', 'ETA')->orderBy('PEP_X_PARAM')->get();
foreach ($etaPoints->values() as $idx => $point) {
$y = $this->num($data['eta'][$point->PEP_ID] ?? $point->PEP_Y_PARAM);
$point->update(['PEP_Y_PARAM' => $y]);
if ($idx + 1 === 5) {
$pump->update(['PUP_ETA_MAX' => $y]);
}
}
});
}
/** Usunięcie logiczne (PUP_STATUS='0'). */
public function delete(Pump $pump): void
{
$pump->update(['PUP_STATUS' => '0']);
}
// ===================== ODCZYT KRZYWYCH =====================
/** Punkty krzywej danego typu: kolekcja {id,x,y} (x = PEP_X_PARAM, m³/h). */
public function curve(Pump $pump, string $type): Collection
{
return $pump->curvePoints()
->where('PEP_TYPE', $type)
->orderBy('PEP_X_PARAM')
->get()
->map(fn ($p) => (object) [
'id' => $p->PEP_ID,
'x' => round((float) $p->PEP_X_PARAM, 3),
'y' => (float) $p->PEP_Y_PARAM,
])
->values();
}
/**
* Czy krzywą można narysować - jak legacy:
* H → 0 wartości Y=0,
* ETA → <2 wartości Y=0 (punkt przy Q=0 legalnie =0).
*/
public function curveDrawable(Collection $points, int $allowedZeros): bool
{
if ($points->isEmpty()) {
return false;
}
$zeros = $points->filter(fn ($p) => (float) $p->y == 0.0)->count();
return $zeros <= $allowedZeros;
}
/** Wartość z aproksymacji wielomianowej (4 współcz.) dla zadanego Q. */
public function valueAt(Collection $points, float $q): ?float
{
if ($points->isEmpty()) {
return null;
}
$reg = new PolynomialRegression(4);
foreach ($points as $p) {
$reg->addData((float) $p->x, (float) $p->y);
}
return $reg->interpolate($reg->getCoefficients(), $q);
}
/**
* Model wykresu: aproksymacja wielomianowa próbkowana i pocięta na
* segmenty kolorowane wg stref Q (scope[0..6]) - wiernie wg legacy:
* [0→scope2] red, [scope2→scope3] amber, [scope3→scope5] green,
* [scope5→scope6] amber, [scope6→max] red. Plus surowe punkty (niebieskie).
*
* @return array{segments:array<int,array{color:string,points:array<int,array{x:float,y:float}>}>,raw:Collection,xMax:float,yMax:float}
*/
public function plot(Collection $points): array
{
$scope = $points->pluck('x')->map(fn ($v) => (float) $v)->values()->all();
$xMax = max($points->max('x'), 0.0001);
$reg = new PolynomialRegression(4);
foreach ($points as $p) {
$reg->addData((float) $p->x, (float) $p->y);
}
$coeffs = $reg->getCoefficients();
$band = function (float $x) use ($scope): string {
if (count($scope) < 7) {
return 'green';
}
if ($x < $scope[2]) return 'red';
if ($x < $scope[3]) return 'amber';
if ($x < $scope[5]) return 'green';
if ($x < $scope[6]) return 'amber';
return 'red';
};
$n = 120;
$segments = [];
$current = null;
$yMax = (float) $points->max('y');
$prev = null;
for ($i = 0; $i <= $n; $i++) {
$x = $xMax * $i / $n;
$y = max(0.0, $reg->interpolate($coeffs, $x));
$yMax = max($yMax, $y);
$color = self::BAND_COLORS[$band($x)];
$pt = ['x' => $x, 'y' => $y];
if ($current === null || $current['color'] !== $color) {
if ($current !== null) {
$current['points'][] = $pt; // domknij łączeniem
$segments[] = $current;
}
$current = ['color' => $color, 'points' => $prev !== null ? [$prev, $pt] : [$pt]];
} else {
$current['points'][] = $pt;
}
$prev = $pt;
}
if ($current !== null) {
$segments[] = $current;
}
return [
'segments' => $segments,
'raw' => $points,
'xMax' => $xMax,
'yMax' => max($yMax, 0.0001) * 1.08,
'coeffs' => array_values($coeffs), // [c0,c1,c2,c3] do interaktywnego markera
];
}
private function num(mixed $v): float
{
return (float) str_replace(',', '.', (string) $v);
}
// ===================== DOBÓR AGREGATU (site201) =====================
/**
* Dobór agregatu pompowego (legacy site201): pompy, których zakres pracy
* obejmuje zadane Q (PUP_Q_MIN < Q < PUP_Q_MAX; skala UAT bez ×60). Dla
* każdej liczy H(Q) i η(Q) z aproksymacji wielomianowej (st.4). Gdy podano
* H, filtruje do pomp, dla których zadane H mieści się w tolerancji legacy
* [H(Q)-5, H(Q)+8]. Wynik sortowany malejąco po sprawności η(Q).
*
* @return Collection<int,object> każdy: {pump, h_val, eta_val, plot|null}
*/
public function matchingAggregates(float $q, ?float $h): Collection
{
return Pump::query()->visible()->with('manufacturer')
->where('PUP_Q_MIN', '<', $q)
->where('PUP_Q_MAX', '>', $q)
->orderBy('PUP_NAME')
->get()
->map(function (Pump $pump) use ($q) {
$hCurve = $this->curve($pump, 'H');
$hVal = $this->valueAt($hCurve, $q);
if ($hVal === null) {
return null; // brak krzywej H - pompy nie da się ocenić
}
return (object) [
'pump' => $pump,
'h_val' => $hVal,
'eta_val' => $this->valueAt($this->curve($pump, 'ETA'), $q),
'plot' => $this->curveDrawable($hCurve, 0) ? $this->plot($hCurve) : null,
];
})
->filter()
->when($h !== null, fn (Collection $c) => $c->filter(
fn ($m) => $h >= $m->h_val - 5 && $h <= $m->h_val + 8
))
->sortByDesc('eta_val')
->values();
}
}
+153
View File
@@ -0,0 +1,153 @@
<?php
namespace App\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use SoapClient;
/**
* Integracja z SYNDIS (SOAP) - pobór aktualnego pomiaru studni i zapis do bazy.
* Port legacy soap.php (SyndisConnector) + ajax_getdata4well.php.
* Funkcje składowane MySQL zastąpione własnym kodem ([[port-db-functions-to-php]]).
*
* SKALA: SYNDIS zwraca wydajność w m³/min; do bazy zapisujemy m³/h (×60) -
* baza zawsze m³/h ([[flow-unit-display]]).
*/
class SyndisService
{
/** Detale seg_dev_resources_det aktualizowane przy pobraniu (DRD_RESOURCES_DETAIL => klucz danych SYNDIS / wyliczenie). */
private function db()
{
return DB::connection('softspm');
}
/**
* Pobiera aktualne dane studni z SYNDIS i zapisuje pomiar. Zwraca true gdy
* zapisano (pompa załączona), false gdy pompa wyłączona / brak danych.
*
* @throws \Throwable gdy SOAP/SYNDIS niedostępny (obsługiwane w kontrolerze)
*/
public function refreshWell(int $drs, string $wellName, string $user): bool
{
$data = $this->getWellData($wellName);
if ((int) $this->raw($data, 'pumpOnOff') !== 1) {
return false; // legacy: zapis tylko gdy pompa pracuje
}
$v = fn (string $k) => $this->num($this->raw($data, $k));
// Przeliczenia jak legacy:
$pp = $v('pressureInPistonBody') - (($v('barometricPressuremA') * 10) / 98);
$lp = (float) ($this->lastDetailValue(240, $drs) ?? 0);
$hh = $lp - $v('dynamicWaterTable');
$qH = $v('pumpEfficiency') * 60; // m³/min -> m³/h
$now = Carbon::now()->toDateTimeString();
$meas = [
'RHD_DRS_ID' => $drs,
'RHD_DATE' => $now,
'RHD_HD_VAL' => $v('dynamicWaterTable'),
'RHD_Q_VAL' => $qH,
'RHD_P1_VAL' => $v('pressureBeforeThrottle'),
'RHD_P2_VAL' => $v('pressureAfterThrottle'),
'RHD_I_VAL' => $v('currentL1'),
'RHD_U_VAL' => $v('voltageL12'),
'RHD_PE_VAL' => $v('activePower'),
'RHD_COS_VAL' => $v('cos'),
'RHD_PP_VAL' => $pp,
'RHD_HH_VAL' => $hh,
'RHD_WTEMP_VAL' => $v('waterTemperature'),
'RHD_DGN_VAL' => $v('currentDiagnosticsOperationState'),
'RHD_AUDIT_CD' => $now,
];
// Detale do podmiany w seg_dev_resources_det (jak legacy delete+insert).
$details = [
200 => $qH,
205 => $v('dynamicWaterTable'),
215 => $v('pressureBeforeThrottle'),
220 => $v('pressureAfterThrottle'),
245 => $v('waterTemperature'),
260 => $v('activePower'),
265 => $v('activeEnergy'),
270 => $v('currentL1'),
275 => $v('voltageL12'),
280 => $v('cos'),
401 => $pp,
402 => $hh,
];
$this->db()->transaction(function () use ($drs, $meas, $details, $user, $now) {
$this->db()->table('rep_soap_syndis_quality_data')
->where('SQD_DRS_ID', $drs)->update(['SQD_AUDIT_CD' => $now]);
$this->db()->table('rep_his_data_everyday')->insert($meas);
$this->db()->table('rep_his_data_demand')->insert($meas);
foreach ($details as $detail => $value) {
$this->db()->table('seg_dev_resources_det')
->where('DRD_RESOURCES_DETAIL', $detail)
->where('DRD_DRS_ID', $drs)
->delete();
$this->db()->table('seg_dev_resources_det')->insert([
'DRD_DRS_ID' => $drs,
'DRD_RESOURCES_DETAIL'=> $detail,
'DRD_VALUE' => (string) $value,
'DRD_EXPORT_SEGAP' => '1',
'DRD_AUDIT_USR' => $user,
'DRD_AUDIT_DATE' => $now,
]);
}
});
return true;
}
/** Wywołanie SOAP GetWellData -> tablica (port SyndisConnector::GetWellDataAsArray). */
private function getWellData(string $wellName): array
{
if (! class_exists(SoapClient::class)) {
throw new \RuntimeException('Rozszerzenie PHP soap nie jest dostępne na serwerze.');
}
$cfg = config('softspm.syndis');
$client = new SoapClient($cfg['wsdl'], ['trace' => 1, 'cache_wsdl' => WSDL_CACHE_NONE]);
if (! empty($cfg['location'])) {
$client->__setLocation($cfg['location']);
}
return $this->objectToArray($client->GetWellData(['id' => $wellName]));
}
/** Ostatnia wartość detalu (port getLastValue) - dla Lp przy przeliczeniu Hh. */
private function lastDetailValue(int $detail, int $drs): ?string
{
return $this->db()->table('seg_dev_resources_det')
->where('DRD_RESOURCES_DETAIL', $detail)
->where('DRD_DRS_ID', $drs)
->orderByDesc('DRD_AUDIT_DATE')->orderByDesc('DRD_ID')
->value('DRD_VALUE');
}
/** SYNDIS zwraca pola jako {'value' => x}; wyciąga surową wartość. */
private function raw(array $data, string $key): mixed
{
return $data[$key]['value'] ?? null;
}
private function num(mixed $v): float
{
return (float) str_replace(',', '.', (string) ($v ?? 0));
}
private function objectToArray(mixed $d): mixed
{
if (is_object($d)) {
$d = get_object_vars($d);
}
return is_array($d) ? array_map([$this, 'objectToArray'], $d) : $d;
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
namespace App\Services;
/**
* Oceny pracy układu pompowego (port func.php: wellEvaluation4_q / _HfQ_new /
* _Throt / _LineLoss + setRedYellowLimes / setYellowGreenLimes + sprawność).
* Skala m³/h (baza UAT bez ×60 - jak [[use-newest-methods]] / katalog pomp).
*
* Każda ocena zwraca ['zone' => 'green'|'yellow'|'red', 'text' => string]
* albo null gdy nie da się ocenić (brak danych) - jak legacy.
*/
class WellAssessmentService
{
private const T_OPT = 'Praca optymalna';
private const T_Q_YELLOW = 'Praca na granicy przedziału stosowalności';
private const T_Q_RED = 'Praca poza przedziałem stosowalności';
private const T_Q_NODATA = 'Brak danych w systemie';
private const T_HFQ_YELLOW = 'Charakterystyka pompy ulega odkształcaniu, możliwe przecieki na złączach rurociągu';
private const T_HFQ_RED = 'Odkształcona charakterystyka pompy, możliwa utrata szczelności rurociągu';
private const T_THROT_YELLOW = 'Wyraźne dławienie w układzie pompowym';
private const T_THROT_RED = 'Silne dławienie w układzie pompowym';
private const T_LOSS_YELLOW = 'Wyraźne straty liniowe w rurociągu';
private const T_LOSS_RED = 'Wysokie straty liniowe w rurociągu';
/** Przedział Q: porównanie wydajności do nominału. */
public function q(float $qn, ?float $q): ?array
{
if ($q === null || $q <= 0) {
return ['zone' => 'red', 'text' => self::T_Q_NODATA];
}
if (0.7 * $qn <= $q && $q <= 1.22 * $qn) {
return ['zone' => 'green', 'text' => self::T_OPT];
}
if ((0.55 * $qn <= $q && $q < 0.7 * $qn) || (1.22 * $qn < $q && $q <= 1.35 * $qn)) {
return ['zone' => 'yellow', 'text' => self::T_Q_YELLOW];
}
return ['zone' => 'red', 'text' => self::T_Q_RED];
}
/** H=f(Q): odchyłka wysokości podnoszenia ze stacji prób (HPS) od (Pp-Hh). */
public function hfq(?float $hps, ?float $pp, ?float $hh): ?array
{
if (! $pp || $hps === null || $hps == 0.0) {
return null;
}
$dev = ($hps - ($pp - $hh)) / $hps;
if ($dev >= -0.05 && $dev <= 0.05) {
return ['zone' => 'green', 'text' => self::T_OPT];
}
if (($dev < -0.05 && $dev >= -0.07) || ($dev > 0.05 && $dev <= 0.07)) {
return ['zone' => 'yellow', 'text' => self::T_HFQ_YELLOW];
}
return ['zone' => 'red', 'text' => self::T_HFQ_RED];
}
/** Dławienie: spadek P1-P2 na zasuwie dławiącej, progi wg zwierciadła HD. */
public function throttling(?float $hd, ?float $p1, ?float $p2): ?array
{
if ($hd === null || $p1 === null || $p2 === null) {
return null;
}
$hsa = $p1 - $p2;
$green = $this->pick($hd, [[50, 6], [100, 8], [200, 10], [350, 13], [450, 16]]);
$yellow = $this->pick($hd, [[50, 15], [100, 20], [200, 28], [350, 35], [450, 39]]);
if ($green === null) {
return null; // HD poza zakresem - brak oceny (jak legacy)
}
if ($hsa <= $green) {
return ['zone' => 'green', 'text' => self::T_OPT];
}
if ($hsa <= $yellow) {
return ['zone' => 'yellow', 'text' => self::T_THROT_YELLOW];
}
return ['zone' => 'red', 'text' => self::T_THROT_RED];
}
/** Straty liniowe: HSL = Pp - Lp - P1, progi wg średnicy rurociągu DR i Lp. */
public function lineLoss(?float $p1, ?float $pp, ?float $lp, ?float $dr): ?array
{
if (! $pp || $p1 === null || $lp === null || $dr === null) {
return null;
}
$hsl = $pp - $lp - $p1;
// Progi [green, yellow] zależne od DR. Każdy próg to pick() po Lp.
if ($dr > 0.04 && $dr < 0.145) {
$green = $this->pick($lp, [[50, 3.5], [100, 6.4], [200, 10], [300, 18], [450, 25]]);
$yellow = $this->pick($lp, [[50, 4.9], [100, 9.5], [200, 16], [300, 24], [450, 35]]);
// legacy: powyżej "yellow" -> wciąż YELLOW + wymóg weryfikacji p1/p2
$overText = self::T_LOSS_RED.'. Wymagana weryfikacja p1 i p2';
$overZone = 'yellow';
} elseif ($dr > 0.145 && $dr <= 0.160) {
$green = $this->pick($lp, [[50, 2.3], [100, 4.3], [200, 8.6], [300, 12], [450, 21]]);
$yellow = $this->pick($lp, [[50, 4.5], [100, 7.2], [200, 14], [300, 18], [450, 27]]);
$overText = self::T_LOSS_RED;
$overZone = 'red';
} elseif ($dr > 0.195 && $dr <= 0.215) {
$green = $this->pick($lp, [[50, 2], [100, 3], [200, 6.5], [300, 9.5], [450, 12]]);
$yellow = $this->pick($lp, [[50, 3], [100, 5.5], [200, 11], [300, 15], [450, 18]]);
$overText = self::T_LOSS_RED;
$overZone = 'red';
} else {
return null; // DR poza obsługiwanymi zakresami - brak oceny (jak legacy)
}
if ($green === null) {
return null;
}
if ($hsl <= $green) {
return ['zone' => 'green', 'text' => self::T_OPT];
}
if ($hsl <= $yellow) {
return ['zone' => 'yellow', 'text' => self::T_LOSS_YELLOW];
}
return ['zone' => $overZone, 'text' => $overText];
}
/** Granica czerwony/żółty energochłonności w funkcji Q (próg sprawności). */
public function redYellowLimit(float $q): float
{
return match (true) {
$q > 0 && $q <= 25 => 0.00210526315789473 * $q + 0.267368421052632,
$q > 25 && $q <= 40 => 0.00133333333333334 * $q + 0.286666666666667,
$q > 40 && $q <= 60 => 0.000749999999999997 * $q + 0.31,
$q > 60 && $q <= 260 => 0.000225000000000001 * $q + 0.3415,
$q > 260 && $q <= 500 => 4.16666666666662e-5 * $q + 0.389166666666667,
$q > 500 => 1.99999999999997e-5 * $q + 0.4,
default => 0.0,
};
}
/** Granica żółty/zielony energochłonności w funkcji Q. */
public function yellowGreenLimit(float $q): float
{
return match (true) {
$q > 0 && $q <= 8 => 0.00999999999999998 * $q + 0.3,
$q > 8 && $q <= 19 => 0.00545454545454544 * $q + 0.336363636363637,
$q > 19 && $q <= 38 => 0.00210526315789474 * $q + 0.4,
$q > 38 && $q <= 60 => 0.000454545454545451 * $q + 0.462727272727273,
$q > 60 && $q <= 500 => 0.000170454545454545 * $q + 0.479772727272727,
$q > 500 => 9.99999999999959e-6 * $q + 0.560000000000001,
default => 0.0,
};
}
/** Sprawność układu η = Q·(HD+P2) / (367·Pe). Pe<=0 traktujemy jak 1 (jak legacy). */
public function efficiency(?float $q, ?float $hd, ?float $p2, ?float $pe): ?float
{
if ($q === null || $hd === null || $p2 === null) {
return null;
}
$pe = ($pe === null || $pe <= 0) ? 1.0 : $pe;
return ($q * ($hd + $p2)) / (367 * $pe);
}
/** Strefa energochłonności: η>=yg zielona, >=ry żółta, inaczej czerwona. */
public function energyZone(float $eta, float $ry, float $yg): string
{
if ($eta >= $yg) {
return 'green';
}
return $eta >= $ry ? 'yellow' : 'red';
}
/** Wybór progu po przedziałach: [[górna granica wykluczna, wartość], ...]. */
private function pick(float $x, array $bands): ?float
{
foreach ($bands as [$limit, $value]) {
if ($x < $limit) {
return (float) $value;
}
}
return null;
}
}
+276
View File
@@ -0,0 +1,276 @@
<?php
namespace App\Services;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Logika domenowa studni (migracja site301A/site301E/site301inf).
*
* Zapisy są wielotabelowe i w legacy szły BEZ transakcji (ryzyko danych
* częściowych). Tu każdy zapis jest opakowany w DB::transaction() na
* połączeniu `softspm`.
*
* Lokalizacja słowników: kolumna języka wybierana wg locale (pl→POL/en→ENG/
* de→GER), domyślnie POL.
*/
class WellService
{
/** DRS_DEV_TYPE dla studni. */
private const DEV_TYPE_WELL = 12;
/** Domyślny status zasobu nowej studni (legacy: DRS_STATUS_DIC_ID=3). */
private const DEFAULT_STATUS_DIC_ID = 3;
private function db()
{
return DB::connection('softspm');
}
/** Kolumna języka w sys_dictionaries wg aktualnego locale. */
private function langColumn(): string
{
return match (app()->getLocale()) {
'en' => 'DIC_ENG_LANG',
'de' => 'DIC_GER_LANG',
default => 'DIC_POL_LANG',
};
}
// ===================== LOOKUPY (do formularzy) =====================
/** Ujęcia wody (bariera): SWI_ID => SWI_NAME. */
public function waterIntakes()
{
return $this->db()->table('seg_water_intakes')
->orderBy('SWI_NAME')
->pluck('SWI_NAME', 'SWI_ID');
}
/** Słownik po ID: DIC_ID => opis (dla WELL_CATEGORY, WELL_STATUS_LEVEL_01). */
public function dictionaryById(string $type)
{
$lang = $this->langColumn();
return $this->db()->table('sys_dictionaries')
->where('DIC_TYPE', $type)
->orderBy($lang)
->pluck($lang, 'DIC_ID');
}
/** Słownik po opisie: lista wartości tekstowych (dla WELL_SYSTEM_TYPE,
* WELL_STATUS_LEVEL_03 - legacy zapisuje sam opis, nie DIC_ID). */
public function dictionaryDescriptions(string $type)
{
$lang = $this->langColumn();
return $this->db()->table('sys_dictionaries')
->where('DIC_TYPE', $type)
->orderBy($lang)
->pluck($lang);
}
/** Dane studni do formularza edycji (wg zapytania z site301inf). */
public function findForEdit(int $drsId): ?object
{
$well = $this->db()->table('seg_wells as w')
->join('seg_dev_resources as drw', function ($j) {
$j->on('drw.DRS_DEV_ID', '=', 'w.WLL_ID')->where('drw.DRS_DEV_TYPE', '=', self::DEV_TYPE_WELL);
})
->leftJoin('seg_object_hole_cards as ohc', 'ohc.OHC_DRS_ID', '=', 'drw.DRS_ID')
->leftJoin('seg_well_pump_eng_rel as rel', 'rel.RWL_WLL_ID', '=', 'w.WLL_ID')
->leftJoin('seg_dev_resources as drp', function ($j) {
$j->on('drp.DRS_ID', '=', 'rel.RWL_PUP_DRS_ID')->where('drp.DRS_DEV_TYPE', '=', 10);
})
->leftJoin('seg_pumps as p', 'p.PUP_ID', '=', 'drp.DRS_DEV_ID')
->leftJoin('seg_dev_resources as dre', function ($j) {
$j->on('dre.DRS_ID', '=', 'rel.RWL_ENG_DRS_ID')->where('dre.DRS_DEV_TYPE', '=', 11);
})
->leftJoin('seg_engines as e', 'e.ENG_ID', '=', 'dre.DRS_DEV_ID')
->where('drw.DRS_ID', $drsId)
->select([
'drw.DRS_ID as drs_id',
'w.WLL_ID as well_id',
'w.WLL_NAME as name',
'w.WLL_SWI_ID as water_intake_id',
'w.WLL_CAT_DIC_ID as category_id',
'w.WLL_PP_SCOPE as pp_scope',
'w.WLL_HH_SCOPE as hh_scope',
'w.WLL_SYS_TYPE as sys_type',
'w.WLL_STAT_DESC as status_desc',
'drw.DRS_RESOURCES_DIC_ID as status_id',
'drw.DRS_AVAILABLE_DATE as available_date',
'ohc.OHC_DL_GEOGR as latitude',
'ohc.OHC_SZER_GEOGR as longitude',
'p.PUP_NAME as pump_name',
'drp.DRS_SN_NUMBER as pump_no',
'e.ENG_NAME as engine_name',
'dre.DRS_SN_NUMBER as engine_no',
])
->first();
if (! $well) {
return null;
}
// Ostatnia zabudowa (do edycji daty zabudowy WLE_DATE_FROM).
$exchange = $this->db()->table('seg_wells_exchanged')
->where('WLE_WLL_ID', $well->well_id)
->orderByDesc('WLE_NR_EXCHANGE')
->first();
$well->wle_id = $exchange->WLE_ID ?? null;
$well->wle_date_from = $exchange->WLE_DATE_FROM ?? null;
return $well;
}
/** Czy istnieje studnia o danej nazwie (kontrola unikalności). */
public function nameExists(string $name): bool
{
return $this->db()->table('seg_wells')->where('WLL_NAME', $name)->exists();
}
// ===================== ZAPIS =====================
/**
* Dodanie studni - transakcyjny odpowiednik site301inf (linie 27-66).
* Zapisuje do: seg_wells, seg_dev_resources, seg_dev_resources_det (×3),
* seg_object_hole_cards, seg_object_hole_cards_his, seg_dev_resources_his.
*
* @return int DRS_ID utworzonego zasobu studni.
*/
public function create(array $data, string $department, string $user): int
{
return $this->db()->transaction(function () use ($data, $department, $user) {
$now = Carbon::now();
$intakeName = (string) $this->db()->table('seg_water_intakes')
->where('SWI_ID', $data['water_intake_id'])
->value('SWI_NAME');
$wellId = $this->db()->table('seg_wells')->insertGetId([
'WLL_NAME' => $data['name'],
'WLL_SWI_ID' => $data['water_intake_id'],
'WLL_BARIERA' => $intakeName,
'WLL_CAT_DIC_ID' => $data['category_id'],
'WLL_LATITUDE' => $this->decimal($data['latitude']),
'WLL_LONGITUDE' => $this->decimal($data['longitude']),
], 'WLL_ID');
$drsId = $this->db()->table('seg_dev_resources')->insertGetId([
'DRS_DEV_ID' => $wellId,
'DRS_DEV_TYPE' => self::DEV_TYPE_WELL,
'DRS_AVAILABLE_DATE' => $now->toDateString(),
'DRS_BUY_DATE' => $now->toDateString(),
'DRS_STATUS_DIC_ID' => self::DEFAULT_STATUS_DIC_ID,
'DRS_RESOURCES_DIC_ID' => $data['status_id'],
'DRS_DEPARTMENT' => $department,
'DRS_AUDIT_USR' => $user,
'DRS_AUDIT_DATE' => $now->toDateTimeString(),
], 'DRS_ID');
// Parametry techniczne: średnica (DST), głębokość (LS), zasilanie (LZ).
$this->insertDetail($drsId, 'DST', $this->decimal($data['diameter']), $now, $user);
$this->insertDetail($drsId, 'LS', $this->decimal($data['depth']), $now, $user);
$this->insertDetail($drsId, 'LZ', $this->decimal($data['supply']), $now, $user);
// Karta otworu.
$this->db()->table('seg_object_hole_cards')->insert([
'OHC_DRS_ID' => $drsId,
'OHC_UJ' => $intakeName,
'OHC_NAME' => $data['name'],
'OHC_DATA_ODDANIA' => $data['available_date'] ?? null,
'OHC_GLEB_ODWIER' => $data['depth'],
'OHC_GLEB_OSTAT' => $data['depth'],
'OHC_DL_GEOGR' => $this->decimal($data['latitude']),
'OHC_SZER_GEOGR' => $this->decimal($data['longitude']),
'OHC_PLC' => $department,
]);
$this->db()->table('seg_object_hole_cards_his')->insert([
'OHH_DRS_ID' => $drsId,
'OHH_UJ' => $intakeName,
'OHH_NAME' => $data['name'],
'OHH_ZASYP' => $data['supply'],
'OHH_PLC' => $department,
]);
// Kopia historyczna zasobu (kolumny _his identyczne z seg_dev_resources).
$this->db()->statement(
'INSERT INTO seg_dev_resources_his SELECT * FROM seg_dev_resources WHERE DRS_ID = ?',
[$drsId]
);
return $drsId;
});
}
/**
* Aktualizacja studni - transakcyjny odpowiednik site301inf (linie 138-155).
*/
public function update(int $drsId, array $data, string $user): void
{
$this->db()->transaction(function () use ($drsId, $data) {
$intakeName = (string) $this->db()->table('seg_water_intakes')
->where('SWI_ID', $data['water_intake_id'])
->value('SWI_NAME');
$this->db()->table('seg_wells')->where('WLL_ID', $data['well_id'])->update([
'WLL_NAME' => $data['name'],
'WLL_SWI_ID' => $data['water_intake_id'],
'WLL_BARIERA' => $intakeName,
'WLL_CAT_DIC_ID' => $data['category_id'],
'WLL_PP_SCOPE' => $data['pp_scope'],
'WLL_HH_SCOPE' => $data['hh_scope'],
'WLL_SYS_TYPE' => $data['sys_type'],
'WLL_STAT_DESC' => $data['status_desc'],
]);
$this->db()->table('seg_dev_resources')->where('DRS_ID', $drsId)->update([
'DRS_AVAILABLE_DATE' => $data['available_date'] ?: null,
'DRS_RESOURCES_DIC_ID' => $data['status_id'],
]);
$this->db()->table('seg_object_hole_cards')->where('OHC_DRS_ID', $drsId)->update([
'OHC_NAME' => $data['name'],
'OHC_UJ' => $intakeName,
'OHC_DL_GEOGR' => $data['latitude'],
'OHC_SZER_GEOGR' => $data['longitude'],
]);
// Data zabudowy ostatniego agregatu (tylko gdy istnieje wpis wymiany).
if (! empty($data['wle_id'])) {
$dateFrom = trim(($data['available_date'] ?? '').' '.($data['build_time'] ?? ''));
$this->db()->table('seg_wells_exchanged')->where('WLE_ID', $data['wle_id'])->update([
'WLE_DATE_FROM' => $dateFrom ?: null,
]);
}
});
}
// ===================== POMOCNICZE =====================
/** Wstawia parametr techniczny do seg_dev_resources_det (DIC po nazwie). */
private function insertDetail(int $drsId, string $dicName, string $value, Carbon $now, string $user): void
{
$detailId = $this->db()->table('sys_dictionaries')
->where('DIC_TYPE', 'WELL_TECHNICAL_PARAMETERS')
->where('DIC_NAME', $dicName)
->value('DIC_ID');
$this->db()->table('seg_dev_resources_det')->insert([
'DRD_DRS_ID' => $drsId,
'DRD_RESOURCES_DETAIL' => $detailId,
'DRD_VALUE' => $value,
'DRD_AUDIT_DATE' => $now->toDateTimeString(),
'DRD_AUDIT_USR' => $user,
]);
}
/** Normalizacja liczby (przecinek → kropka), jak getFloatValue w legacy. */
private function decimal(?string $value): string
{
return str_replace(',', '.', trim((string) $value));
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace App\Support;
/**
* Regresja wielomianowa - wierny port legacy polynomial/PolynomialRegression.php
* (Andrew Que, GPL). Ten sam algorytm (równania normalne + eliminacja
* wierszowa), arytmetyka natywna (float) zamiast bcmath - wynik identyczny dla
* zakresów danych pomp, bez zależności od ext-bcmath.
*
* Użycie jak w legacy: new PolynomialRegression(4) → 4 współczynniki (wielomian
* stopnia 3); addData(x,y) ×N; getCoefficients(); interpolate(coeffs, x).
*/
class PolynomialRegression
{
/** @var float[] */
private array $xPowers = [];
/** @var float[] */
private array $xyPowers = [];
public function __construct(private int $degree = 3)
{
$this->reset();
}
public function reset(): void
{
$squares = ($this->degree - 1) * 2;
for ($i = 0; $i <= $squares; $i++) {
$this->xPowers[$i] = 0.0;
$this->xyPowers[$i] = 0.0;
}
}
public function addData(float $x, float $y): void
{
$squares = ($this->degree - 1) * 2;
for ($i = 0; $i <= $squares; $i++) {
$this->xPowers[$i] += $x ** $i;
$this->xyPowers[$i] += $y * ($x ** $i);
}
}
/**
* Współczynniki [c0, c1, ...] (c0 = wyraz wolny).
*
* @return float[]
*/
public function getCoefficients(?int $degree = null): array
{
$degree ??= $this->degree;
$eps = 1e-12;
// Macierz równań normalnych + kolumna augmentacji.
$matrix = [];
for ($row = 0; $row < $degree; $row++) {
for ($col = 0; $col < $degree; $col++) {
$matrix[$row][$col] = $this->xPowers[$row + $col];
}
$matrix[$row][$degree] = $this->xyPowers[$row];
}
$rows = count($matrix);
$isDone = array_fill(0, $rows, false);
$order = [];
// Eliminacja wierszowa do macierzy górnotrójkątnej z 1 na przekątnej.
for ($column = 0; $column < $rows; $column++) {
$activeRow = 0;
while ($activeRow < $rows
&& (abs($matrix[$activeRow][$column]) < $eps || $isDone[$activeRow])) {
$activeRow++;
}
if ($activeRow < $rows) {
$order[$column] = $activeRow;
$firstTerm = $matrix[$activeRow][$column];
for ($sub = $column; $sub <= $rows; $sub++) {
$matrix[$activeRow][$sub] /= $firstTerm;
}
$isDone[$activeRow] = true;
for ($row = 0; $row < $rows; $row++) {
if (! $isDone[$row] && abs($matrix[$row][$column]) >= $eps) {
$factor = $matrix[$row][$column];
for ($sub = $column; $sub <= $rows; $sub++) {
$matrix[$row][$sub] -= $factor * $matrix[$activeRow][$sub];
}
}
}
}
}
// Podstawianie wsteczne.
$isDone = array_fill(0, $rows, false);
$coefficients = [];
for ($column = $rows - 1; $column >= 0; $column--) {
$activeRow = $order[$column];
$isDone[$activeRow] = true;
for ($row = 0; $row < $rows; $row++) {
if (! $isDone[$row]) {
$factor = $matrix[$row][$column];
for ($sub = $column; $sub <= $rows; $sub++) {
$matrix[$row][$sub] -= $factor * $matrix[$activeRow][$sub];
}
}
}
$coefficients[$column] = $matrix[$activeRow][$rows];
}
ksort($coefficients);
return $coefficients;
}
/** @param float[] $coefficients */
public function interpolate(array $coefficients, float $x): float
{
$y = 0.0;
foreach ($coefficients as $i => $c) {
$y += $c * ($x ** $i);
}
return $y;
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+18
View File
@@ -0,0 +1,18 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+88
View File
@@ -0,0 +1,88 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "softspm/softspm2",
"type": "project",
"description": "SoftSPM 2 - migracja legacy PHP 5.6 (newPGE) do Laravela, database-first na MariaDB.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
"ext-pdo": "*",
"ext-soap": "*",
"laravel/tinker": "^2.10.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.50"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated
+8402
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];
+110
View File
@@ -0,0 +1,110 @@
<?php
use Illuminate\Support\Str;
/*
|--------------------------------------------------------------------------
| SoftSPM_2 - konfiguracja baz danych (nakładka na szkielet Laravela)
|--------------------------------------------------------------------------
| Podejście DATABASE-FIRST: pracujemy na istniejącym schemacie MariaDB
| (NIE zmieniamy struktury). Trzy osobne bazy:
| - softspm → dane operacyjne (tabele seg_/sys_/tst_) [domyślna]
| - softspm_rep → tabele raportowe (rep_)
| - softspm_his → historia (tabele _his)
|
| Wartości pobierane z .env (ustawiane na serwerze testowym). Sterownik
| 'mariadb' wymaga Laravel 11+. Wszystkie 3 bazy stoją na tym samym hoście,
| ale trzymamy je jako odrębne połączenia - modele wskazują swoje przez
| właściwość $connection.
*/
return [
'default' => env('DB_CONNECTION', 'softspm'),
'connections' => [
// === Baza operacyjna (domyślna) ===
'softspm' => [
'driver' => 'mariadb',
'host' => env('DB_HOST', '10.42.1.210'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'softspm'),
'username' => env('DB_USERNAME', 'spm_local'),
'password' => env('DB_PASSWORD', 'ff8efe59e7265fe9680b0c325e44cb53'),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8',
'collation' => 'utf8_general_ci', // zgodnie z SET NAMES utf8 w legacy
'prefix' => '',
'prefix_indexes' => true,
'strict' => false, // legacy schema - wyłączamy ONLY_FULL_GROUP_BY itd.
'engine' => null,
],
// === Baza raportowa ===
'softspm_rep' => [
'driver' => 'mariadb',
'host' => env('DB_REP_HOST', env('DB_HOST', '10.42.1.210')),
'port' => env('DB_REP_PORT', env('DB_PORT', '3306')),
'database' => env('DB_REP_DATABASE', 'softspm_rep'),
'username' => env('DB_REP_USERNAME', env('DB_USERNAME', 'spm_local')),
'password' => env('DB_REP_PASSWORD', env('DB_PASSWORD', 'ff8efe59e7265fe9680b0c325e44cb53')),
'charset' => 'utf8',
'collation' => 'utf8_general_ci',
'prefix' => '',
'strict' => false,
'engine' => null,
],
// === Baza historyczna ===
'softspm_his' => [
'driver' => 'mariadb',
'host' => env('DB_HIS_HOST', env('DB_HOST', '10.42.1.210')),
'port' => env('DB_HIS_PORT', env('DB_PORT', '3306')),
'database' => env('DB_HIS_DATABASE', 'softspm_his'),
'username' => env('DB_HIS_USERNAME', env('DB_USERNAME', 'spm_local')),
'password' => env('DB_HIS_PASSWORD', env('DB_PASSWORD', 'ff8efe59e7265fe9680b0c325e44cb53')),
'charset' => 'utf8',
'collation' => 'utf8_general_ci',
'prefix' => '',
'strict' => false,
'engine' => null,
],
],
/*
| Migracje Laravela trzymamy w bazie operacyjnej. UWAGA: w podejściu
| database-first migracji raczej nie uruchamiamy na istniejących tabelach
| - służy głównie tabelom pomocniczym (sessions, cache, jobs).
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'softspm'), '_').'_database_'),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
];
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+132
View File
@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];
+124
View File
@@ -0,0 +1,124 @@
<?php
/*
|--------------------------------------------------------------------------
| SoftSPM 2 - struktura nawigacji (jedno źródło dla całego systemu)
|--------------------------------------------------------------------------
| Wzorzec jak w oryginale:
| - górne menu = SEKCJE (zakres),
| - po kliknięciu sekcji w LEWYM menu pojawiają się jej pozycje,
| - kliknięcie pozycji otwiera funkcję.
|
| Struktura ODTWORZONA 1:1 z legacy index.php (sekcje + kolejność pozycji
| + etykiety z language/polish.php). 'route' dokładamy w miarę migracji;
| null = placeholder „w przygotowaniu".
*/
return [
[
'key' => 'start', 'label' => 'Start', 'icon' => 'home',
'route' => 'dashboard',
'items' => [
['label' => 'Mapa obiektów', 'icon' => 'map', 'route' => 'dashboard'],
],
],
// ===== Katalogi (200) - kolejność wg index.php: 203,204,205,201,206,207,208 =====
[
'key' => 'katalogi', 'label' => 'Katalogi', 'icon' => 'inventory_2',
'items' => [
['label' => 'Katalog pomp', 'icon' => 'water_drop', 'route' => 'pumps.index'],
['label' => 'Katalog silników', 'icon' => 'bolt', 'route' => 'engines.index'],
['label' => 'Katalog agregatów', 'icon' => 'precision_manufacturing', 'route' => 'aggregates.index'],
['label' => 'Wybór agregatu pompowego', 'icon' => 'checklist', 'route' => 'aggregate-selection.index'],
['label' => 'Katalog kabli', 'icon' => 'cable', 'route' => 'cables.index'],
['label' => 'Katalog orurowania i armatury', 'icon' => 'plumbing', 'route' => 'piping.index'],
['label' => 'Katalog opomiarowania', 'icon' => 'speed', 'route' => 'metering.index'],
],
],
// ===== Stan (300) - kolejność wg index.php: 303,304,301,302,309,306,307,308 =====
[
'key' => 'stan', 'label' => 'Stan', 'icon' => 'monitoring',
'items' => [
['label' => 'Stan pomp', 'icon' => 'water_drop', 'route' => null], // site303
['label' => 'Stan silników', 'icon' => 'bolt', 'route' => null], // site304
['label' => 'Stan studni', 'icon' => 'water_drop', 'route' => null], // site301
['label' => 'Stan zbiorników', 'icon' => 'inventory_2', 'route' => null], // site302
['label' => 'Stan piezometrów', 'icon' => 'biotech', 'route' => null], // site309
['label' => 'Stan kabli', 'icon' => 'cable', 'route' => null], // site306
['label' => 'Stan orurowania i armatury', 'icon' => 'plumbing', 'route' => null], // site307
['label' => 'Stan opomiarowania', 'icon' => 'speed', 'route' => null], // site308
],
],
// ===== Eksploatacja (400) - kolejność wg index.php: 401,402,404,405,406,403,407 =====
[
'key' => 'eksploatacja', 'label' => 'Eksploatacja', 'icon' => 'engineering',
'items' => [
['label' => 'Eksploatacja studni', 'icon' => 'water_drop', 'route' => 'well-operation.index'], // site401
['label' => 'Eksploatacja zbiorników', 'icon' => 'inventory_2', 'route' => null], // site402
['label' => 'Eksploatacja piezometrów', 'icon' => 'biotech', 'route' => null], // site404
['label' => 'Eksploatacja ujęć', 'icon' => 'map', 'route' => null], // site405
['label' => 'Kreator doboru pompy', 'icon' => 'checklist', 'route' => null], // site406
['label' => 'Parametry stałe', 'icon' => 'settings', 'route' => null], // site403
['label' => 'Zrzut wody - rejestr', 'icon' => 'water_drop', 'route' => null, 'append_department' => true], // site407
],
],
// ===== Stacja prób (500) - 501,502,503,504 =====
[
'key' => 'stacja_prob', 'label' => 'Stacja prób', 'icon' => 'biotech',
'items' => [
['label' => 'Pompy', 'icon' => 'water_drop', 'route' => null], // site501
['label' => 'Silniki', 'icon' => 'bolt', 'route' => null], // site502
['label' => 'Agregaty', 'icon' => 'precision_manufacturing', 'route' => null], // site503
['label' => 'Kable', 'icon' => 'cable', 'route' => null], // site504
],
],
// ===== Remonty (600) - 601,602,603 =====
[
'key' => 'remonty', 'label' => 'Remonty', 'icon' => 'build',
'items' => [
['label' => 'Demontaż', 'icon' => 'build', 'route' => null], // site601
['label' => 'Weryfikacja', 'icon' => 'checklist', 'route' => null], // site602
['label' => 'Jakość remontów', 'icon' => 'monitoring', 'route' => null], // site603
],
],
// ===== Gospodarka (700) - 701,702,703,704 =====
[
'key' => 'gospodarka', 'label' => 'Gospodarka', 'icon' => 'inventory_2',
'items' => [
['label' => 'Statystyki rezerw studni', 'icon' => 'monitoring', 'route' => null], // site701
['label' => 'Rezerwy części nowych', 'icon' => 'inventory_2', 'route' => null], // site702
['label' => 'Rezerwy części regenerowanych', 'icon' => 'inventory_2', 'route' => null], // site703
['label' => 'Rezerwy drutów i kabli', 'icon' => 'cable', 'route' => null], // site704
],
],
// ===== Administracja (800) - kolejność wg index.php: 801,802,805,803,806,807,809,810,804 =====
[
'key' => 'administracja', 'label' => 'Administracja', 'icon' => 'settings',
'items' => [
['label' => 'Zarządzanie użytkownikami', 'icon' => 'settings', 'route' => null], // site801
['label' => 'Zarządzanie profilami', 'icon' => 'settings', 'route' => null], // site802
['label' => 'Zarządzanie bazą wiedzy', 'icon' => 'menu_book', 'route' => null], // site805
['label' => 'Producenci', 'icon' => 'precision_manufacturing', 'route' => null], // site803
['label' => 'Słowniki', 'icon' => 'menu_book', 'route' => null], // site806
['label' => 'Parametryzacja', 'icon' => 'settings', 'route' => null], // site807
['label' => 'Raporty', 'icon' => 'monitoring', 'route' => null], // site809
['label' => 'Zlecenia', 'icon' => 'build', 'route' => null], // site810
['label' => 'Zmiana hasła', 'icon' => 'settings', 'route' => null], // site804
],
],
// ===== Baza wiedzy (900) - w oryginale bezpośrednia strona (site900) =====
[
'key' => 'baza_wiedzy', 'label' => 'Baza wiedzy', 'icon' => 'menu_book',
'items' => [],
],
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
+38
View File
@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];
+217
View File
@@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];
+47
View File
@@ -0,0 +1,47 @@
<?php
/*
|--------------------------------------------------------------------------
| SoftSPM_2 - ustawienia aplikacyjne (nakładka)
|--------------------------------------------------------------------------
*/
return [
/*
| Migracja haseł MD5 -> bcrypt przy logowaniu.
| Kolumna sys_users.USR_PASSWORD to VARCHAR(64) - bcrypt (60 zn.) SIĘ MIEŚCI,
| więc rehash jest bezpieczny bez zmiany schematu. WYŁĄCZONE wg decyzji klienta
| (na razie zostajemy przy MD5); włączyć przez SOFTSPM_REHASH=true po jego zgodzie.
*/
'rehash_passwords' => env('SOFTSPM_REHASH', false),
/*
| Oddziały (DRS_DEPARTMENT). Wybierane przy logowaniu, filtrują listy
| obiektów (jak legacy $_SESSION['syndis_eq_stock']). Wartości muszą
| dokładnie odpowiadać DRS_DEPARTMENT w bazie.
*/
'departments' => [
'P/Szczerców',
'P/Bełchatów',
'Dębina',
],
/*
| FAZA TESTÓW: wymuszony oddział logowania. Gdy ustawione (np. 'P/Szczerców'),
| pole „Oddział" na ekranie logowania jest UKRYTE, a logowanie używa tej wartości.
| ODWRACALNE: ustaw na null, aby przywrócić wybór oddziału przez użytkownika.
*/
'login_department' => 'P/Szczerców',
/*
| Integracja SYNDIS (SOAP) - pobór aktualnych pomiarów studni.
| WSDL + lokalizacja usługi zależne od środowiska (na prod LAN-owe IP).
| Gdy puste/niedostępne - „Pobierz aktualne dane" zgłosi błąd (graceful).
*/
'syndis' => [
'wsdl' => env('SYNDIS_WSDL', 'http://172.21.11.101/SyndisSpm.wsdl'),
'location' => env('SYNDIS_LOCATION', 'http://172.21.11.101:3615/'),
],
];
+1
View File
@@ -0,0 +1 @@
*.sqlite*
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}
+32
View File
@@ -0,0 +1,32 @@
# SoftSPM_2 - lokalne środowisko deweloperskie.
# Baza danych NIE jest kontenerem - łączymy się do istniejącej MariaDB
# w sieci LAN (10.42.1.210), zgodnie z podejściem database-first.
services:
app:
build:
context: .
dockerfile: docker/Dockerfile
container_name: softspm2-app
ports:
- "8000:80"
volumes:
- .:/var/www/html
environment:
APACHE_DOCUMENT_ROOT: /var/www/html/public
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
# Vite dev server (hot reload). Uruchamiać opcjonalnie:
# docker compose --profile dev up vite
vite:
build:
context: .
dockerfile: docker/Dockerfile
container_name: softspm2-vite
profiles: ["dev"]
ports:
- "5173:5173"
volumes:
- .:/var/www/html
command: npm run dev -- --host 0.0.0.0
+32
View File
@@ -0,0 +1,32 @@
# SoftSPM_2 - obraz deweloperski (PHP 8.3 + Apache + Node 20)
# Produkcja: Windows Server / IIS+Apache z zainstalowanym PHP - ten obraz
# służy wyłącznie do pracy lokalnej. DocumentRoot wskazuje na public/.
FROM php:8.3-apache
ARG UID=1000
ARG GID=1000
RUN apt-get update && apt-get install -y --no-install-recommends \
git unzip zip curl ca-certificates \
libzip-dev libpng-dev libjpeg62-turbo-dev libfreetype6-dev \
libxml2-dev libicu-dev libonig-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" \
pdo_mysql mysqli soap zip gd intl opcache \
&& a2enmod rewrite headers \
&& rm -rf /var/lib/apt/lists/*
# Node 20 - build assetów (Vite + Tailwind v4)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
COPY docker/php.ini /usr/local/etc/php/conf.d/zz-softspm.ini
WORKDIR /var/www/html
+34
View File
@@ -0,0 +1,34 @@
<?php
// Smoke-test połączeń DB (uruchamiać: php artisan tinker < docker/check/db-check.php
// albo: docker compose exec app php docker/check/db-check.php)
require __DIR__.'/../../vendor/autoload.php';
$app = require __DIR__.'/../../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
foreach (['softspm' => 'sys_users', 'softspm_rep' => null, 'softspm_his' => null] as $conn => $probe) {
try {
$row = DB::connection($conn)->select('SELECT DATABASE() AS d, VERSION() AS v')[0];
$extra = '';
if ($probe) {
$extra = ', ' . $probe . '=' . DB::connection($conn)->table($probe)->count() . ' rek.';
}
echo "OK {$conn} -> {$row->d} (MariaDB {$row->v}{$extra})\n";
} catch (\Throwable $e) {
echo "FAIL {$conn} -> " . $e->getMessage() . "\n";
}
}
// Kluczowe tabele legacy używane przez nakładkę
$tables = ['sys_users', 'seg_wells', 'seg_dev_resources', 'seg_dev_resources_det',
'seg_equipments', 'seg_well_pump_eng_rel', 'seg_object_hole_cards', 'sys_dictionaries',
'seg_pumps', 'seg_engines', 'seg_manufacturers', 'rep_his_data_everyday', 'tst_tests'];
foreach ($tables as $t) {
try {
$n = DB::connection('softspm')->table($t)->count();
echo "OK softspm.{$t} = {$n}\n";
} catch (\Throwable $e) {
echo "FAIL softspm.{$t} -> " . substr($e->getMessage(), 0, 120) . "\n";
}
}
+110
View File
@@ -0,0 +1,110 @@
# SoftSPM_2 - pliki NAKŁADKI (kopiowane na szkielet Laravela: UAT /var/www/html/softspm_pge).
# Wygenerowane 2026-09-03, PRZED domieszaniem szkieletu Laravela do tego katalogu.
# Reszta drzewa (artisan, bootstrap/, vendor/, public/index.php, pozostałe config/*.php) to szkielet.
app/Http/Controllers/AggregateController.php
app/Http/Controllers/AggregateSelectionController.php
app/Http/Controllers/Auth/LoginController.php
app/Http/Controllers/CableController.php
app/Http/Controllers/Concerns/SortsLists.php
app/Http/Controllers/DashboardController.php
app/Http/Controllers/EngineController.php
app/Http/Controllers/EquipmentFileController.php
app/Http/Controllers/MeteringController.php
app/Http/Controllers/PipingController.php
app/Http/Controllers/PumpController.php
app/Http/Controllers/WellController.php
app/Http/Controllers/WellOperationController.php
app/Http/Middleware/SetLocale.php
app/Http/Requests/StoreCableRequest.php
app/Http/Requests/StoreEngineRequest.php
app/Http/Requests/StoreMeteringRequest.php
app/Http/Requests/StorePipingRequest.php
app/Http/Requests/StorePumpRequest.php
app/Http/Requests/UpdateCableRequest.php
app/Http/Requests/UpdateEngineRequest.php
app/Http/Requests/UpdateMeteringRequest.php
app/Http/Requests/UpdatePipingRequest.php
app/Http/Requests/UpdatePumpRequest.php
app/Models/Engine.php
app/Models/Equipment.php
app/Models/EquipmentFile.php
app/Models/Manufacturer.php
app/Models/Pump.php
app/Models/PumpCurvePoint.php
app/Models/User.php
app/Services/EquipmentFileService.php
app/Services/PumpService.php
app/Services/SyndisService.php
app/Services/WellAssessmentService.php
app/Services/WellService.php
app/Support/PolynomialRegression.php
config/database.php
config/navigation.php
config/softspm.php
resources/css/app.css
resources/js/app.js
resources/views/auth/login.blade.php
resources/views/catalog/aggregate-selection/index.blade.php
resources/views/catalog/aggregates/edit.blade.php
resources/views/catalog/aggregates/index.blade.php
resources/views/catalog/aggregates/show.blade.php
resources/views/catalog/cables/create.blade.php
resources/views/catalog/cables/edit.blade.php
resources/views/catalog/cables/index.blade.php
resources/views/catalog/cables/show.blade.php
resources/views/catalog/engines/create.blade.php
resources/views/catalog/engines/edit.blade.php
resources/views/catalog/engines/index.blade.php
resources/views/catalog/engines/show.blade.php
resources/views/catalog/metering/create.blade.php
resources/views/catalog/metering/edit.blade.php
resources/views/catalog/metering/index.blade.php
resources/views/catalog/metering/show.blade.php
resources/views/catalog/piping/create.blade.php
resources/views/catalog/piping/edit.blade.php
resources/views/catalog/piping/index.blade.php
resources/views/catalog/piping/show.blade.php
resources/views/catalog/pumps/create.blade.php
resources/views/catalog/pumps/edit.blade.php
resources/views/catalog/pumps/index.blade.php
resources/views/catalog/pumps/show.blade.php
resources/views/components/app-layout.blade.php
resources/views/components/btn.blade.php
resources/views/components/confirm-delete.blade.php
resources/views/components/curve-chart.blade.php
resources/views/components/energy-gauge.blade.php
resources/views/components/guest-layout.blade.php
resources/views/components/icon.blade.php
resources/views/components/power-schematic.blade.php
resources/views/components/pump-curve.blade.php
resources/views/components/selection-chart.blade.php
resources/views/components/sortable-th.blade.php
resources/views/components/well-field.blade.php
resources/views/components/well-schematic.blade.php
resources/views/components/well-select.blade.php
resources/views/dashboard.blade.php
resources/views/exploitation/wells/index.blade.php
resources/views/exploitation/wells/params.blade.php
resources/views/exploitation/wells/show.blade.php
resources/views/section.blade.php
resources/views/wells/create.blade.php
resources/views/wells/edit.blade.php
resources/views/wells/index.blade.php
routes/web.php
README_SOFTSPM.md
package.json
vite.config.js
docker-compose.yml
docker/Dockerfile
docker/php.ini
public/images/logo.png
public/images/dashboard_01.png
public/images/dashboard_02.png
public/fonts/inter/Inter-Variable.woff2
public/fonts/jetbrains-mono/JetBrainsMono-Variable.woff2
public/fonts/material-symbols/MaterialSymbolsOutlined.woff2
docker/check/db-check.php
docker/overlay-files.txt
.env.example
.gitignore # (uwaga: wyjatki na public/fonts i public/images dopisane do wersji ze szkieletu)
+15
View File
@@ -0,0 +1,15 @@
; SoftSPM_2 - ustawienia PHP (dev)
memory_limit = 512M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
date.timezone = Europe/Warsaw
; Załączniki wyposażenia trzymane są jako BLOB - podnosimy limity pakietu
mysqli.allow_local_infile = Off
; SOAP (integracja SYNDIS) - bez cache WSDL w dev
soap.wsdl_cache_enabled = 0
display_errors = On
error_reporting = E_ALL
+134
View File
@@ -0,0 +1,134 @@
# Legacy SoftSPM - źródło migracji
**Katalog źródłowy: `C:\Programy\xampp_old\htdocs\trzeciCloude_v1\`**
To jest jedyne źródło, do którego się odwołujemy przy przepisywaniu na Laravela.
W `htdocs/` leży kilka równoległych kopii tej samej aplikacji (`newPGE`,
`trzeciTestowy`, `pierwszyPodstawowy`, `wellsctrl*`) - **ignorujemy je**.
`trzeciCloude_v1` jako jedyna ma repozytorium git (`Initial commit`) oraz własną
dokumentację.
## Nie powielamy istniejącej dokumentacji
W legacy leżą dwa opracowania - **czytać je zamiast wyprowadzać wnioski od nowa**:
| Plik | Co zawiera |
|---|---|
| `trzeciCloude_v1/CLAUDE.md` (181 linii, EN) | architektura, konwencje nazewnicze bazy, routing shardów historii, martwy kod |
| `trzeciCloude_v1/AUDYT.md` (125 linii, PL) | audyt techniczny 2026-06-09 + plan P0/P1/P2 (SQLi, LFI, MD5, SSO, sekrety) |
Schemat baz: `trzeciCloude_v1/db_structure/*.sql` - **zrzuty struktury bez danych**
dla wszystkich trzech baz. Grepować je zamiast zgadywać kolumny.
## Skala (zmierzone 2026-09-03)
| | |
|---|---|
| plików PHP | 983 (1933 pliki łącznie) |
| fragmentów stron `sites/*.php` | 198, w 50 numerach `siteNNN` |
| raportów `reports/*.php` | 73 |
| eksportów `export/*.php` | 45 |
| `func.php` | **4156 linii, 111 funkcji** |
| `index.php` (front controller + menu + auth) | 435 linii |
| `ajax_func.php` / `ajax_getdata4well.php` | 315 / 208 linii |
> Rozbieżność: `CLAUDE.md` legacy podaje „func.php ~3400 linii, ~140 funkcji",
> `AUDYT.md` „~4000 linii, ~140 funkcji". Faktycznie **4156 / 111**.
> `CLAUDE.md` twierdzi też „Not a git repository" - dla `trzeciCloude_v1`
> to już nieprawda.
## Architektura w skrócie
- **Front controller**: `index.php`, dispatch po `?pg=siteNNN` → `include sites/siteNNN.php`
(domyślnie `site401`). Menu, bramka auth i połączenie z bazą też są w `index.php`.
- **Warianty stron po sufiksie**: `A`=add, `E`=edit, `inf`=szczegóły, `his`/`ahis`/`mhis`/`offhis`=historie,
`charts`, `det`/`pdet`=detale, `mark`=widok mapowy. Przykłady:
`site301` ma 18 wariantów, `site401` ma 19.
- **Uprawnienia per strona**: `sys_user_profiles → sys_profile_site → sys_site_access`
spłaszczone do `$_SESSION['siteNNN']` / `$_SESSION['siteNNN_edit']`.
**Router ich nie sprawdza** - każda strona i handler AJAX sprawdza sam.
- **SOAP w dwóch rolach**: klient SYNDIS (`soap.php` + `soap_*.php`, pobieranie SCADA)
oraz serwer SOAP (`webservice/`, wystawianie danych do SYNDIS).
- **Zadania cykliczne**: Windows `.bat` + php-cli (`soap_update5min.php` co 5 min,
`reports/rep_003_hours.bat` per oddział B/S/D).
## Bazy i sharding historii
Połączenie otwiera `softspm`, ale zapytania sięgają dwóch pozostałych przez
**twardy prefiks w SQL** (~240 wystąpień `softspm_rep.`, ~90 `softspm_his.`).
Wszystkie trzy muszą stać na jednym serwerze.
- `softspm` - dane operacyjne (w tym tabele `rep_*` bieżące).
- `softspm_rep` - tabele raportowe (`rep_002_*` per miesiąc `styc…grud`, `rep_003_data`, `rep_015_*`).
- `softspm_his` - archiwum: **shardy kwartalne** `rep_his_data_everyday_YYYY_qN`
i `seg_dev_resources_det_YYYY_qN`, piezometry per rok.
Routing shardów: `getTableNameFrom_everyday_his()` / `getTableNameFrom_resources_det()`
(`func.php`). Reguła: bieżący rok → tabela bazowa, ale po **180 dniach** przełącza się
na wariant `_his`; starsze → shard `YYYY_qN`; data w **pierwszych 3 dniach miesiąca**
jest cofana do poprzedniego miesiąca (więc początek stycznia trafia w Q4 poprzedniego
roku); wszystko sprzed 2014 → `2014_q1`.
> W SoftSPM_2 tej logiki **jeszcze nie ma** - `WellOperationController` czyta tylko
> bieżące `rep_his_data_everyday` z bazy `softspm`. Połączenia `softspm_rep`
> i `softspm_his` są skonfigurowane, ale nieużywane. Przy migracji historii
> trzeba odtworzyć reguły shardowania.
## Mapa sekcji legacy → SoftSPM_2
Górne menu legacy = sekcje; `config/navigation.php` odtwarza je 1:1.
Status: ✅ = przepisane, ⬜ = `route => null` (placeholder w menu).
| Sekcja | site | Nazwa | Status w SoftSPM_2 |
|---|---|---|---|
| **Start (100)** | 100/101/102 | Start, Parametry studni 01/02 | ✅ `dashboard` (mapa = placeholder) |
| **Katalogi (200)** | 203 | Katalog pomp | ✅ `pumps.*` (pełny CRUD) |
| | 204 | Katalog silników | ✅ `engines.*` |
| | 205 | Katalog agregatów | ✅ `aggregates.*` (lista/szczegóły/edycja) |
| | 201 | Wybór agregatu pompowego | ✅ `aggregate-selection.index` |
| | 206 | Katalog kabli | ✅ `cables.*` |
| | 207 | Katalog orurowania i armatury | ✅ `piping.*` |
| | 208 | Katalog opomiarowania | ✅ `metering.*` |
| | 202 | Katalog piezometrów | ⬜ **brak w `navigation.php`** |
| **Stan (300)** | 303,304,301,302,309,306,307,308 | Stan pomp/silników/studni/zbiorników/piezometrów/kabli/orurowania/opomiarowania | ⬜ wszystkie |
| | 310 | (bez etykiety w menu; 1204 linie - największy plik `sites/`) | ⬜ |
| **Eksploatacja (400)** | 401 | Eksploatacja studni | ✅ `well-operation.*` (lista + karta, etap 1) |
| | 402,404,405,406,403,407 | zbiorniki, piezometry, ujęcia, kreator doboru pompy, parametry stałe, zrzut wody | ⬜ |
| **Stacja prób (500)** | 501-504 | Pompy, Silniki, Agregaty, Kable | ⬜ |
| **Remonty (600)** | 601-603 | Demontaż, Weryfikacja, Jakość remontów | ⬜ |
| **Gospodarka (700)** | 701-704 | Statystyki rezerw studni, rezerwy części nowych/regenerowanych, drutów i kabli | ⬜ |
| **Administracja (800)** | 801-807, 809, 810 | użytkownicy, profile, baza wiedzy, producenci, słowniki, parametryzacja, raporty, zlecenia, zmiana hasła | ⬜ |
| **Baza wiedzy (900)** | 900 | Baza wiedzy | ⬜ (sekcja bez pozycji) |
Ciężar pracy wg liczby plików `sites/`: **Stan (65)**, Administracja (42),
Eksploatacja (39), Katalogi (27) - reszta po 1-4.
### Moduł „Studnie" (`wells.*`) - odklejony od menu
`routes/web.php` ma pełny CRUD studni (`wells.index/create/store/edit/update`),
widoki istnieją, ale **`config/navigation.php` nigdzie go nie wskazuje** -
pozycja „Stan studni" (site301) ma `route => null`. Moduł jest osiągalny wyłącznie
przez wpisanie URL-a. Do decyzji: podpiąć pod „Stan studni" czy pod inną pozycję.
Pochodzenie: lista z `site401.php` (przepisana bez N+1), formularze z `site301A/E/inf`.
`WellOperationController` bierze się z tej samej grupy `site401` - te dwa moduły
łatwo pomylić.
## Pułapki przy czytaniu legacy
- **Duża część drzewa to martwy kod.** Przed edycją potwierdzić, że plik jest żywy:
`sites/backup/` (23 pliki), kopie z datą w nazwie (`20161228_soap.php`,
`20170130_soap.php`, `20210926_soap.php`), skrypty jednorazowe
(`naprawa_20210312.php`, `repair_20210313_data.php`, `rollback.php`, `test*.php`),
pięć wariantów tego samego importera `soap_of_while{,_back,_last,_manual,_new}.php`,
klony raportów per miesiąc (`rep_002_wrze_print.php`).
- **Ścieżki twardo wpisane w `.bat`** potrafią wskazywać na *inną kopię* aplikacji
(`reports/rep_003_hours.bat` kieruje na `htdocs\trzeciTestowy\`) - sprawdzić,
zanim się założy, że dany skrypt to ten działający.
- **Etykiety UI**: `language/{polish,english,german}.php`, zmienne `$lab_*`.
`polish.php` (45 kB) jest źródłem prawdy; `english.php`/`german.php` (po ~19 kB)
mają braki. SoftSPM_2 ma `SetLocale`, ale **zero wywołań `__()`** - tłumaczenia
to osobny, jeszcze nierozpoczęty etap.
- **Środowisko legacy**: PHP 5.4/5.6 + usunięte `ext/mysql` (`mysql_connect`),
dlatego uruchamia się je tylko starym XAMPP-em (`C:\Programy\xampp_old\php\php.exe`).
Facet Eclipse w `.settings/` kłamie, że to PHP 8.2.
+2180
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"axios": "^1.7.4",
"laravel-vite-plugin": "^1.0.5",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0"
}
}
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+20
View File
@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
+245
View File
@@ -0,0 +1,245 @@
@import 'tailwindcss';
@source '../**/*.blade.php';
@source '../../app/**/*.php';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
/*
| SoftSPM 2 - system "SoftSPM Industrial" (tokeny z uj_dashboard/DESIGN.md).
| Corporate Modern + Glassmorphism, karty "Bento", przyciski-pigułki.
| Tailwind v4: konfiguracja w CSS przez @theme.
*/
@theme {
/* Fonty (rodziny) */
--font-sans: 'Inter', system-ui, sans-serif;
--font-headline-lg: 'Inter', system-ui, sans-serif;
--font-headline-md: 'Inter', system-ui, sans-serif;
--font-title-sm: 'Inter', system-ui, sans-serif;
--font-body-md: 'Inter', system-ui, sans-serif;
--font-body-sm: 'Inter', system-ui, sans-serif;
--font-data-label: 'JetBrains Mono', monospace;
/* Typografia (rozmiary) */
--text-headline-lg: 24px;
--text-headline-lg--line-height: 32px;
--text-headline-lg--font-weight: 700;
--text-headline-md: 20px;
--text-headline-md--line-height: 28px;
--text-headline-md--font-weight: 600;
--text-title-sm: 16px;
--text-title-sm--line-height: 24px;
--text-title-sm--font-weight: 600;
--text-body-md: 14px;
--text-body-md--line-height: 20px;
--text-body-sm: 12px;
--text-body-sm--line-height: 18px;
--text-data-label: 12px;
--text-data-label--line-height: 16px;
--text-data-label--font-weight: 500;
--text-data-label--letter-spacing: 0.02em;
/* Odstępy nazwane */
--spacing-gutter: 1rem;
--spacing-stack-gap: 0.75rem;
--spacing-container-padding: 1rem;
--spacing-card-inner-padding: 1rem;
--spacing-touch-target-min: 44px;
/* Cień karty "Bento" */
--shadow-card: 0 4px 12px rgb(0 0 0 / 0.08);
/* Kolory - Surface / neutralne */
--color-surface: #f7fafb;
--color-surface-bright: #f7fafb;
--color-surface-dim: #d7dadc;
--color-surface-container-lowest: #ffffff;
--color-surface-container-low: #f1f4f5;
--color-surface-container: #ebeeef;
--color-surface-container-high: #e5e9ea;
--color-surface-container-highest: #e0e3e4;
--color-surface-variant: #e0e3e4;
--color-surface-tint: #b72221;
--color-on-surface: #181c1d;
--color-on-surface-variant: #5b403d;
--color-inverse-surface: #2d3132;
--color-inverse-on-surface: #eef1f2;
--color-outline: #8f706c;
--color-outline-variant: #e3beba;
--color-background: #f7fafb;
--color-on-background: #181c1d;
/* Primary - ciemna czerwień przemysłowa */
--color-primary: #7f0009;
--color-on-primary: #ffffff;
--color-primary-container: #a61317;
--color-on-primary-container: #ffb4ac;
--color-inverse-primary: #ffb4ab;
--color-primary-fixed: #ffdad6;
--color-primary-fixed-dim: #ffb4ab;
--color-on-primary-fixed: #410002;
--color-on-primary-fixed-variant: #93000d;
/* Secondary - teal */
--color-secondary: #006876;
--color-on-secondary: #ffffff;
--color-secondary-container: #9fecfc;
--color-on-secondary-container: #0d6d7b;
--color-secondary-fixed: #a2efff;
--color-secondary-fixed-dim: #85d2e2;
--color-on-secondary-fixed: #001f25;
--color-on-secondary-fixed-variant: #004e59;
/* Tertiary */
--color-tertiary: #2f3e45;
--color-on-tertiary: #ffffff;
--color-tertiary-container: #46555d;
--color-on-tertiary-container: #b9c9d3;
--color-tertiary-fixed: #d5e5ef;
--color-tertiary-fixed-dim: #b9c9d2;
--color-on-tertiary-fixed: #0e1d24;
--color-on-tertiary-fixed-variant: #3a4951;
/* Error / statusy */
--color-error: #ba1a1a;
--color-on-error: #ffffff;
--color-error-container: #ffdad6;
--color-on-error-container: #93000a;
--color-success-active: #006876;
--color-error-alert: #ba1a1a;
}
/* Wariant 'dark' sterowany klasą na <html> (strategia class, nie media). */
@custom-variant dark (&:where(.dark, .dark *));
/* Kolory neutralne wykresów SVG (adaptują się do motywu). */
:root {
--chart-grid: #eceff0;
--chart-axis: #9aa6a8;
--chart-tick: #888888;
--chart-label: #555555;
--chart-raw: #1565c0;
}
/*
| ===== MOTYW CIEMNY =====
| Nadpisujemy tylko WARTOŚCI tokenów - wszystkie utility (bg-surface,
| text-on-surface, border-outline-variant...) używają var(--color-*), więc
| cały interfejs przełącza się automatycznie. Paleta wg Material 3 (dark)
| spójna z jasną (czerwień przemysłowa + teal). Tokeny *-fixed są wspólne
| dla obu motywów - nie nadpisujemy ich.
*/
html.dark {
/* Surface / neutralne */
--color-surface: #0f1314;
--color-surface-bright: #353a3b;
--color-surface-dim: #0f1314;
--color-surface-container-lowest: #090d0e;
--color-surface-container-low: #181c1d;
--color-surface-container: #1c2021;
--color-surface-container-high: #262a2b;
--color-surface-container-highest: #313536;
--color-surface-variant: #414b4c;
--color-surface-tint: #ffb4ab;
--color-on-surface: #e0e3e4;
--color-on-surface-variant: #c4c7c8;
--color-inverse-surface: #e0e3e4;
--color-inverse-on-surface: #2d3132;
--color-outline: #899293;
--color-outline-variant: #3f4849;
--color-background: #0f1314;
--color-on-background: #e0e3e4;
/* Primary - jaśniejsza czerwień na ciemnym tle */
--color-primary: #ffb4ab;
--color-on-primary: #690005;
--color-primary-container: #93000d;
--color-on-primary-container: #ffdad6;
--color-inverse-primary: #a61317;
/* Secondary - jaśniejszy teal */
--color-secondary: #85d2e2;
--color-on-secondary: #00363f;
--color-secondary-container: #004e59;
--color-on-secondary-container: #a2efff;
/* Tertiary */
--color-tertiary: #b9c9d3;
--color-on-tertiary: #243139;
--color-tertiary-container: #3a4951;
--color-on-tertiary-container: #d5e5ef;
/* Error / statusy */
--color-error: #ffb4ab;
--color-on-error: #690005;
--color-error-container: #93000a;
--color-on-error-container: #ffdad6;
--color-success-active: #85d2e2;
--color-error-alert: #ffb4ab;
/* Wykresy */
--chart-grid: #2a2f30;
--chart-axis: #566061;
--chart-tick: #8a9293;
--chart-label: #9aa3a4;
--chart-raw: #7fb3ff;
}
/* ===== Fonty self-hosted (air-gap: pliki w public/fonts/) ===== */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/inter/Inter-Variable.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/jetbrains-mono/JetBrainsMono-Variable.woff2') format('woff2');
}
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 100 700;
font-display: block;
src: url('/fonts/material-symbols/MaterialSymbolsOutlined.woff2') format('woff2');
}
/* ===== Komponenty ===== */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* Glassmorfizm - szklane panele/paski (mapa, wyszukiwarka) */
.glass-panel {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(227, 190, 186, 0.3);
}
html.dark .glass-panel {
background: rgba(24, 28, 29, 0.7);
border-color: rgba(137, 146, 147, 0.25);
}
/* Pill-chipy statusów (OK / Warning / Alert) */
.status-chip {
@apply inline-flex items-center gap-1 px-2 py-0.5 rounded-full font-data-label text-data-label;
}
.status-chip-ok { @apply bg-green-100 text-green-800; }
.status-chip-warn { @apply bg-amber-100 text-amber-800; }
.status-chip-alert { @apply bg-error-container text-on-error-container; }
html.dark .status-chip-ok { @apply bg-green-900/40 text-green-300; }
html.dark .status-chip-warn { @apply bg-amber-900/40 text-amber-300; }
+6
View File
@@ -0,0 +1,6 @@
/*
* SoftSPM_2 - punkt wejścia JS.
*
* Aplikacja jest server-side (Blade); interakcje ekranowe siedzą w inline
* <script> w widokach. Zasada air-gap: ZERO CDN - wszystko przez Vite.
*/
+4
View File
@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+419
View File
@@ -0,0 +1,419 @@
<x-guest-layout title="SoftSPM 2 - Logowanie">
<style>
.login-screen { min-height: 100vh; display: flex; }
/* ===== Lewa kolumna - panel wizualny ===== */
.login-visual {
position: relative;
width: 50%;
overflow: hidden;
/* Gradient turkus -> czerwień (jak hero na stronie głównej SoftSPM) */
background:
radial-gradient(circle at 86% 10%, rgba(255,255,255,.10), transparent 38%),
radial-gradient(circle at 8% 92%, rgba(255,255,255,.07), transparent 44%),
radial-gradient(120% 95% at 102% 102%, rgba(140,22,38,.50), transparent 58%),
linear-gradient(118deg, #0a5366 0%, #094a5a 33%, #33263a 64%, #5e1f2c 88%, #6e1a26 100%);
}
.login-dots {
position: absolute; inset: 0; z-index: 0;
background-image: radial-gradient(rgba(255,255,255,.06) 1px, transparent 1.4px);
background-size: 24px 24px;
-webkit-mask-image: linear-gradient(to bottom, #000 28%, transparent 92%);
mask-image: linear-gradient(to bottom, #000 28%, transparent 92%);
}
/* Okręgi w tle - bardziej wyraziste */
.login-ring, .login-ring2 { position: absolute; z-index: 0; border-radius: 50%; }
.login-ring { top: -180px; right: -150px; width: 470px; height: 470px; border: 1.5px solid rgba(255,255,255,.16); }
.login-ring2 { top: -90px; right: -60px; width: 310px; height: 310px; border: 1px solid rgba(255,255,255,.11); }
.login-ring3 { position: absolute; z-index: 0; bottom: -150px; left: -120px; width: 360px; height: 360px; border-radius: 50%; border: 1.5px solid rgba(255,255,255,.10); }
.login-mesh { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; }
.login-brand { position: relative; z-index: 2; padding: 2.75rem 3rem; max-width: min(1200px, 90%); }
.login-brand .over {
color: #4fc3e8; font-family: 'JetBrains Mono', monospace; font-size: 12px;
letter-spacing: .18em; text-transform: uppercase; font-weight: 600;
}
.login-brand h1 {
color: #fff; font-weight: 700; line-height: 1.18; margin-top: .9rem;
font-size: clamp(26px, 3.2vw, 44px);
text-shadow: 0 2px 24px rgba(0,0,0,.25);
}
/* ===== Prawa kolumna - formularz ===== */
.login-form-panel {
width: 50%; display: flex; align-items: center; justify-content: center;
padding: 2rem; background: #f7fafb;
}
/* ===== Responsywnie: < 860px pionowo ===== */
@media (max-width: 860px) {
.login-screen { flex-direction: column; }
.login-visual { width: 100%; height: 200px; }
.login-brand { padding: 1.25rem 1.5rem; }
.login-form-panel { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
.login-mesh { opacity: .9; }
}
</style>
<div class="login-screen">
{{-- ===== LEWA: wizual + animacja ===== --}}
<div class="login-visual">
<div class="login-dots"></div>
<div class="login-ring"></div>
<div class="login-ring2"></div>
<div class="login-ring3"></div>
<canvas id="login-mesh" class="login-mesh" aria-hidden="true" data-mode="text" data-text="SoftSPM"></canvas>
<div class="login-brand">
<p class="over">Submersible Pumps Management</p>
<h1>Inteligentny Nadzór Eksploatacji Pomp Głębinowych i Raportowanie</h1>
</div>
</div>
{{-- ===== PRAWA: formularz ===== --}}
<div class="login-form-panel">
<div class="w-full" style="max-width: 360px;">
{{-- Logo - link do softspm.com (ta sama zakładka) --}}
<div class="mb-7">
<a href="https://softspm.com" class="inline-block">
<img src="{{ asset('images/logo.png') }}" alt="SoftSPM" style="width: 220px; max-width: 100%;" class="h-auto">
</a>
</div>
<h2 class="font-headline-md text-headline-md text-on-surface">Logowanie</h2>
<p class="font-body-sm text-body-sm text-on-surface-variant mt-1 mb-6">Zaloguj się do systemu SoftSPM</p>
@if ($errors->any())
<div class="status-chip-alert px-3 py-2 rounded-lg font-body-sm text-body-sm mb-4">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('login.attempt') }}" class="space-y-4" style="padding: 0;">
@csrf
{{-- Nazwa użytkownika --}}
<div class="space-y-1">
<label for="username" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Nazwa użytkownika</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-on-surface-variant/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>
</svg>
<input id="username" name="username" type="text" required autofocus autocomplete="username"
value="{{ old('username') }}" placeholder="Podaj nazwę użytkownika"
class="w-full bg-white border border-outline-variant pl-10 pr-3 py-3 rounded-lg focus:ring-1 focus:ring-secondary focus:border-secondary font-body-md text-on-surface transition-all">
</div>
</div>
{{-- Hasło --}}
<div class="space-y-1">
<label for="password" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Hasło</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-on-surface-variant/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<input id="password" name="password" type="password" required autocomplete="current-password"
placeholder="••••••••"
class="w-full bg-white border border-outline-variant pl-10 pr-3 py-3 rounded-lg focus:ring-1 focus:ring-secondary focus:border-secondary font-body-md text-on-surface transition-all">
</div>
</div>
{{-- Oddział - ukryty na czas testów (config softspm.login_department); odwracalne (ustaw null) --}}
@unless (config('softspm.login_department'))
<div class="space-y-1">
<label for="department" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Oddział</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-on-surface-variant/60" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18"/><path d="M5 21V7l8-4v18"/><path d="M19 21V11l-6-4"/><path d="M9 9v.01M9 12v.01M9 15v.01M9 18v.01"/>
</svg>
<select id="department" name="department" required
class="w-full bg-white border border-outline-variant pl-10 pr-3 py-3 rounded-lg focus:ring-1 focus:ring-secondary focus:border-secondary font-body-md text-on-surface transition-all">
@foreach (config('softspm.departments') as $dep)
<option value="{{ $dep }}" {{ old('department') === $dep ? 'selected' : '' }}>{{ $dep }}</option>
@endforeach
</select>
</div>
</div>
@endunless
<x-btn type="submit" variant="primary" class="w-full mt-2">Zaloguj</x-btn>
</form>
<p class="mt-8 text-center font-body-sm text-body-sm text-on-surface-variant/70">
© SoftSPM {{ date('Y') }} · v.2.0
</p>
</div>
</div>
</div>
{{-- ===== Animacja "flowing wave mesh" (Canvas 2D, bez bibliotek) ===== --}}
<script>
(function () {
const canvas = document.getElementById('login-mesh');
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: true });
// ===== CONFIG (wartości referencyjne; data-mode/data-text nadpisują) =====
const CONFIG = {
mode: canvas.dataset.mode || 'text',
text: canvas.dataset.text || 'SoftSPM',
grid: 7,
alphaThreshold: 120,
fontWeight: 700,
textHeightRatio: 0.17,
textAnchor: { x: 0.07, y: 0.90 },
amplitude: 1.7,
speed: 0.0016,
color: { r: 41, g: 171, b: 226 },
opacity: 0.85,
glow: 8,
lineWidth: 1,
nodeRadius: 1.15,
drawNodes: true,
edgeFalloff: 0.55,
buckets: 6,
// tryb wave
bandCenter: 0.72,
bandWidth: 0.40,
waveCols: 90,
waveRows: 13,
connectorEvery: 3,
waves: [
{ amp: 14, freq: 1.1, speed: 0.55, phase: 0.0 },
{ amp: 8, freq: 2.3, speed: -0.9, phase: 1.7 },
{ amp: 5, freq: 3.7, speed: 1.35, phase: 3.1 },
],
};
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const rgba = (a) => 'rgba(' + CONFIG.color.r + ',' + CONFIG.color.g + ',' + CONFIG.color.b + ',' + a + ')';
let dpr = 1, cssW = 0, cssH = 0;
let nodes = [], edges = [], spotX = 0;
let particles = [];
let rafId = null, resizeTimer = null, start = 0;
function setSize() {
const rect = canvas.getBoundingClientRect();
cssW = Math.max(1, Math.round(rect.width));
cssH = Math.max(1, Math.round(rect.height));
dpr = Math.min(window.devicePixelRatio || 1, 2); // ostre linie na HiDPI
canvas.width = Math.round(cssW * dpr);
canvas.height = Math.round(cssH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// ===== Konstelacja tła (dryfujące punkty + linie - jak hero strony głównej) =====
function makeParticles() {
const count = Math.round(cssW * cssH / 15000);
particles = [];
for (let i = 0; i < count; i++) {
particles.push({
x: Math.random() * cssW, y: Math.random() * cssH,
vx: (Math.random() - 0.5) * 0.22, vy: (Math.random() - 0.5) * 0.22,
});
}
}
function updateParticles() {
for (const p of particles) {
p.x += p.vx; p.y += p.vy;
if (p.x < 0 || p.x > cssW) p.vx = -p.vx;
if (p.y < 0 || p.y > cssH) p.vy = -p.vy;
}
}
function drawConstellation() {
const maxD = 155, maxD2 = maxD * maxD;
ctx.lineWidth = 1;
for (let i = 0; i < particles.length; i++) {
const a = particles[i];
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j];
const dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy;
if (d2 < maxD2) {
ctx.strokeStyle = 'rgba(180,214,228,' + ((1 - Math.sqrt(d2) / maxD) * 0.16) + ')';
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
}
}
ctx.fillStyle = 'rgba(205,230,240,.32)';
ctx.beginPath();
for (const p of particles) { ctx.moveTo(p.x + 1.3, p.y); ctx.arc(p.x, p.y, 1.3, 0, Math.PI * 2); }
ctx.fill();
}
// ===== Budowa modelu z napisu =====
function buildText() {
const off = document.createElement('canvas');
off.width = cssW; off.height = cssH;
const o = off.getContext('2d');
const fontSize = Math.max(12, Math.round(cssH * CONFIG.textHeightRatio));
o.clearRect(0, 0, cssW, cssH);
o.fillStyle = '#fff';
o.textBaseline = 'alphabetic';
o.textAlign = 'left';
o.font = CONFIG.fontWeight + ' ' + fontSize + "px 'Inter', system-ui, sans-serif";
o.fillText(CONFIG.text, cssW * CONFIG.textAnchor.x, cssH * CONFIG.textAnchor.y);
const data = o.getImageData(0, 0, cssW, cssH).data;
nodes = []; edges = [];
const map = new Map();
const g = CONFIG.grid;
let minX = 1e9, maxX = -1e9;
for (let y = 0; y < cssH; y += g) {
const row = (y / g) | 0;
for (let x = 0; x < cssW; x += g) {
if (data[(y * cssW + x) * 4 + 3] >= CONFIG.alphaThreshold) {
const col = (x / g) | 0;
map.set(col + '_' + row, nodes.length);
nodes.push({ bx: x, by: y, x: x, y: y, col: col, row: row, ph: col * 0.7 + row * 0.9 });
if (x < minX) minX = x;
if (x > maxX) maxX = x;
}
}
}
buildEdges(map, true);
spotX = nodes.length ? (minX + maxX) / 2 : cssW * 0.3;
}
// ===== Budowa modelu pasma fal =====
function buildWave() {
nodes = []; edges = [];
const map = new Map();
const cols = CONFIG.waveCols, rows = CONFIG.waveRows;
const bandTop = cssH * (CONFIG.bandCenter - CONFIG.bandWidth / 2);
const bandH = cssH * CONFIG.bandWidth;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = cols > 1 ? (c / (cols - 1)) * cssW : 0;
const y = rows > 1 ? bandTop + (r / (rows - 1)) * bandH : bandTop;
map.set(c + '_' + r, nodes.length);
nodes.push({ bx: x, by: y, x: x, y: y, col: c, row: r, ph: c * 0.3 + r * 0.5 });
}
}
buildEdges(map, false);
spotX = cssW * 0.5;
}
function buildEdges(map, triangular) {
for (const n of nodes) {
const i = map.get(n.col + '_' + n.row);
const right = map.get((n.col + 1) + '_' + n.row);
if (right !== undefined) edges.push([i, right]);
if (triangular) {
const down = map.get(n.col + '_' + (n.row + 1));
const diag = map.get((n.col + 1) + '_' + (n.row + 1));
if (down !== undefined) edges.push([i, down]);
if (diag !== undefined) edges.push([i, diag]);
} else if (n.col % CONFIG.connectorEvery === 0) {
const down = map.get(n.col + '_' + (n.row + 1));
if (down !== undefined) edges.push([i, down]);
}
}
}
function build() {
if (CONFIG.mode === 'wave') buildWave(); else buildText();
}
// ===== Ruch węzłów =====
function updateNodes(t) {
if (CONFIG.mode === 'wave') {
for (const n of nodes) {
let dy = 0;
for (const w of CONFIG.waves) {
dy += w.amp * Math.sin((n.col / CONFIG.waveCols) * Math.PI * 2 * w.freq + t * w.speed + w.phase + n.row * 0.1);
}
n.x = n.bx;
n.y = n.by + dy;
}
} else {
const A = CONFIG.amplitude;
for (const n of nodes) {
n.x = n.bx + A * Math.sin(t + n.ph);
n.y = n.by + A * Math.cos(t * 1.1 + n.ph * 0.8);
}
}
}
// ===== Rysowanie (kwantyzacja jasności do kubełków - 1 stroke/kubełek) =====
function draw() {
ctx.clearRect(0, 0, cssW, cssH);
ctx.shadowBlur = 0;
drawConstellation(); // tło konstelacji (bez poświaty)
const B = CONFIG.buckets;
const bins = [];
for (let i = 0; i < B; i++) bins.push([]);
for (const e of edges) {
const a = nodes[e[0]], b = nodes[e[1]];
const nx = Math.abs((a.x + b.x) / 2 - spotX) / cssW; // odległość poziomą od reflektora
let f = 1 - CONFIG.edgeFalloff * (2 * nx) * (2 * nx); // reflektor
if (f < 0.05) f = 0.05; else if (f > 1) f = 1;
bins[Math.min(B - 1, Math.floor((1 - f) * B))].push(e);
}
ctx.lineWidth = CONFIG.lineWidth;
ctx.lineCap = 'round';
ctx.shadowColor = rgba(CONFIG.opacity);
ctx.shadowBlur = CONFIG.glow;
for (let bi = 0; bi < B; bi++) {
const list = bins[bi];
if (!list.length) continue;
ctx.strokeStyle = rgba(CONFIG.opacity * (1 - (bi + 0.5) / B));
ctx.beginPath();
for (const e of list) {
const a = nodes[e[0]], b = nodes[e[1]];
ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y);
}
ctx.stroke();
}
if (CONFIG.drawNodes) {
ctx.shadowBlur = CONFIG.glow * 0.6;
ctx.fillStyle = rgba(CONFIG.opacity);
ctx.beginPath();
const r = CONFIG.nodeRadius;
for (const n of nodes) {
ctx.moveTo(n.x + r, n.y);
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
}
ctx.fill();
}
ctx.shadowBlur = 0;
}
function loop(now) {
updateNodes((now - start) * CONFIG.speed);
updateParticles();
draw();
rafId = requestAnimationFrame(loop);
}
function init() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
setSize();
makeParticles();
build();
if (reduce) { updateNodes(0); draw(); return; } // statyczny render
start = performance.now();
rafId = requestAnimationFrame(loop);
}
function onResize() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(init, 150);
}
window.addEventListener('resize', onResize);
// Czekaj na fonty (czytelny napis), potem buduj model.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(init);
} else {
init();
}
})();
</script>
</x-guest-layout>
@@ -0,0 +1,90 @@
<x-app-layout :active="'aggregate-selection.index'" title="SoftSPM 2 - Wybór agregatu pompowego">
@php $fmt = fn ($v, $d = 2) => $v === null || $v === '' ? '-' : number_format(round((float) $v, $d), $d, ',', ' '); @endphp
<div class="space-y-stack-gap">
<div>
<h1 class="font-headline-lg text-headline-lg text-on-surface">Wybór agregatu pompowego</h1>
<p class="font-body-sm text-body-sm text-on-surface-variant mt-1">Wysokość podnoszenia dla posiadanych typów pomp</p>
</div>
{{-- Parametry doboru --}}
<form method="GET" action="{{ route('aggregate-selection.index') }}"
class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<p class="font-data-label text-data-label text-on-surface-variant tracking-wide mb-3">Wprowadź parametry poszukiwanej pompy</p>
<div class="flex flex-wrap items-end gap-4">
<div class="space-y-1">
<label for="q" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Zadana wydajność Q<sub>sz</sub> [m³/h]</label>
<input id="q" name="q" type="text" inputmode="decimal" required value="{{ $q }}" placeholder="np. 120"
class="bg-surface-container-low border border-outline-variant px-3 py-2 rounded font-data-label text-on-surface focus:ring-1 focus:ring-primary focus:border-primary">
</div>
<div class="space-y-1">
<label for="h" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Zadana wysokość podnoszenia H [m] <span class="normal-case text-on-surface-variant/60">(opcjonalnie)</span></label>
<input id="h" name="h" type="text" inputmode="decimal" value="{{ $h }}" placeholder="np. 80"
class="bg-surface-container-low border border-outline-variant px-3 py-2 rounded font-data-label text-on-surface focus:ring-1 focus:ring-primary focus:border-primary">
</div>
<button type="submit"
class="bg-secondary text-on-secondary px-5 py-2.5 rounded-full font-title-sm text-title-sm hover:opacity-90 transition-all active:scale-95">
Szukaj
</button>
</div>
</form>
@if ($searched)
{{-- Wykres nałożonych krzywych H --}}
@if ($chart)
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<h2 class="font-title-sm text-title-sm text-tertiary mb-2">Wysokość podnoszenia dla posiadanych typów pomp</h2>
<x-selection-chart :chart="$chart" :q="$qVal" />
</div>
@endif
{{-- Lista pomp dla zadanego Q (ranking po sprawności) --}}
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card overflow-hidden">
<div class="px-card-inner-padding pt-card-inner-padding">
<h2 class="font-title-sm text-title-sm text-tertiary">
Lista pomp dla zadanego Q
<span class="font-body-sm text-body-sm text-on-surface-variant">(malejąco wg sprawności)</span>
</h2>
</div>
<table class="w-full text-left mt-2">
<thead class="bg-secondary/10 border-y border-outline-variant/40">
<tr class="font-data-label text-data-label text-on-surface-variant tracking-wide">
<th class="px-4 py-3 w-12">Lp.</th>
<th class="px-4 py-3">Typ pompy</th>
<th class="px-4 py-3">Typ silnika</th>
<th class="px-4 py-3 text-right">Q<sub>n</sub> [m³/h]</th>
<th class="px-4 py-3 text-right">Q<sub>sz</sub> [m³/h]</th>
<th class="px-4 py-3 text-right">H(Q<sub>sz</sub>) [m]</th>
<th class="px-4 py-3 text-right">&eta;(Q<sub>sz</sub>) [-]</th>
<th class="px-4 py-3 text-right">P<sub>s</sub> [kW]</th>
<th class="px-4 py-3 text-right">Napięcie [V]</th>
</tr>
</thead>
<tbody class="divide-y divide-outline-variant/30 font-body-md text-body-md">
@forelse ($rows as $row)
<tr class="hover:bg-surface-container transition-colors cursor-pointer"
onclick="location.href='{{ route('aggregates.show', ['pump' => $row->pump_id] + ($row->engine_id ? ['engine' => $row->engine_id] : [])) }}'">
<td class="px-4 py-3 font-data-label text-on-surface-variant">{{ $row->lp }}.</td>
<td class="px-4 py-3 font-semibold text-on-surface">{{ $row->pump_name }}</td>
<td class="px-4 py-3">{{ $row->engine_name ?? '-' }}</td>
<td class="px-4 py-3 text-right font-data-label">{{ $fmt($row->q_n, 3) }}</td>
<td class="px-4 py-3 text-right font-data-label">{{ $fmt($qVal, 3) }}</td>
<td class="px-4 py-3 text-right font-data-label">{{ $fmt($row->h_val, 2) }}</td>
<td class="px-4 py-3 text-right font-data-label">{{ $fmt($row->eta_val, 2) }}</td>
<td class="px-4 py-3 text-right font-data-label">{{ $fmt($row->power, 2) }}</td>
<td class="px-4 py-3 text-right font-data-label">{{ $row->voltage ?? '-' }}</td>
</tr>
@empty
<tr>
<td colspan="9" class="px-4 py-8 text-center font-body-md text-body-md text-on-surface-variant">
Brak pomp spełniających wprowadzone parametry.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@endif
</div>
</x-app-layout>
@@ -0,0 +1,46 @@
<x-app-layout :active="'aggregates.index'" title="SoftSPM 2 - Przypisanie silników">
<div class="max-w-3xl space-y-stack-gap">
<div class="flex items-center gap-2">
<a href="{{ route('aggregates.show', $pump) }}" class="font-body-sm text-body-sm text-on-surface-variant hover:text-primary">← Wróć</a>
<h1 class="font-headline-lg text-headline-lg text-on-surface">Silniki dla pompy: {{ $pump->PUP_NAME }}</h1>
</div>
<form method="POST" action="{{ route('aggregates.update', $pump) }}"
class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card overflow-hidden">
@csrf
@method('PUT')
<table class="w-full text-left">
<thead class="bg-secondary/10 border-b border-outline-variant/40">
<tr class="font-data-label text-data-label text-on-surface-variant tracking-wide">
<th class="px-4 py-2 w-12"></th>
<th class="px-4 py-2">Typ silnika</th>
<th class="px-4 py-2 text-right">Napięcie [V]</th>
<th class="px-4 py-2 text-right">Moc [kW]</th>
<th class="px-4 py-2">Producent</th>
</tr>
</thead>
<tbody class="divide-y divide-outline-variant/30 font-body-md text-body-md">
@foreach ($allEngines as $e)
<tr class="hover:bg-surface-container transition-colors">
<td class="px-4 py-2">
<input type="checkbox" name="engines[]" value="{{ $e->ENG_ID }}"
{{ in_array((int) $e->ENG_ID, $assigned, true) ? 'checked' : '' }}
class="w-4 h-4 accent-secondary">
</td>
<td class="px-4 py-2 font-semibold text-on-surface">{{ $e->ENG_NAME }}</td>
<td class="px-4 py-2 text-right font-data-label">{{ $e->ENG_VOLTAGE }}</td>
<td class="px-4 py-2 text-right font-data-label">{{ $e->ENG_POWER }}</td>
<td class="px-4 py-2">{{ $e->manufacturer?->MCF_NAME }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="flex items-center gap-2 p-card-inner-padding border-t border-outline-variant/40">
<button type="submit" class="bg-primary text-on-primary px-6 py-2.5 rounded-full font-title-sm text-title-sm hover:bg-primary-container transition-all active:scale-95 shadow-card">Zapisz</button>
<a href="{{ route('aggregates.show', $pump) }}" class="px-5 py-2.5 rounded-full font-title-sm text-title-sm text-on-surface-variant hover:bg-surface-container transition-all">Anuluj</a>
</div>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,79 @@
<x-app-layout :active="'aggregates.index'" title="SoftSPM 2 - Katalog agregatow">
@php $fmt = fn ($v, $d = 3) => $v === null ? '-' : number_format(round((float) $v, $d), $d, ',', ' '); @endphp
<div class="space-y-stack-gap">
@if (session('status'))
<div class="status-chip-ok px-3 py-2 rounded-lg font-body-sm text-body-sm">{{ session('status') }}</div>
@endif
<div>
<h1 class="font-headline-lg text-headline-lg text-on-surface">Katalog agregatow</h1>
<p class="font-body-sm text-body-sm text-on-surface-variant mt-1">Wynikow: <span class="font-data-label">{{ $aggregates->total() }}</span></p>
</div>
{{-- Wyszukiwarka pomp --}}
<form method="GET" action="{{ route('aggregates.index') }}"
class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<div class="flex flex-wrap items-end gap-4">
<div class="space-y-1">
<label for="by" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Kryterium</label>
<select id="by" name="by" class="bg-surface-container-low border border-outline-variant px-3 py-2 rounded font-data-label text-on-surface">
<option value="name" {{ $by === 'name' ? 'selected' : '' }}>Typ pompy</option>
<option value="flow" {{ $by === 'flow' ? 'selected' : '' }}>Wydajnosc Q</option>
</select>
</div>
<div class="space-y-1">
<label for="q" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Szukaj</label>
<input id="q" name="q" type="text" value="{{ $q }}" placeholder="typ lub Q"
class="bg-surface-container-low border border-outline-variant px-3 py-2 rounded font-data-label text-on-surface">
</div>
<div class="space-y-1">
<label for="mcf" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Producent</label>
<select id="mcf" name="mcf" class="bg-surface-container-low border border-outline-variant px-3 py-2 rounded font-data-label text-on-surface">
<option value="{{ $mcfAll }}" {{ (int) $mcf === $mcfAll ? 'selected' : '' }}>Wszyscy</option>
@foreach ($manufacturers as $id => $name)
<option value="{{ $id }}" {{ (int) $mcf === (int) $id ? 'selected' : '' }}>{{ $name }}</option>
@endforeach
</select>
</div>
<button type="submit" class="bg-secondary text-on-secondary px-5 py-2.5 rounded-full font-title-sm text-title-sm hover:opacity-90 active:scale-95 transition-all">Szukaj</button>
</div>
</form>
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card overflow-hidden">
<table class="w-full text-left">
<thead class="bg-secondary/10 border-b border-outline-variant/40">
<tr class="font-data-label text-data-label text-on-surface-variant tracking-wide">
<th class="px-4 py-3 w-12">Lp.</th>
<x-sortable-th column="name">Typ pompy</x-sortable-th>
<x-sortable-th column="flow" align="right">Q<sub>n</sub> [m³/h]</x-sortable-th>
<x-sortable-th column="mcf">Producent</x-sortable-th>
<x-sortable-th column="engines" align="right">Silniki</x-sortable-th>
<th class="px-4 py-3 text-right">Akcje</th>
</tr>
</thead>
<tbody class="divide-y divide-outline-variant/30 font-body-md text-body-md">
@forelse ($aggregates as $a)
<tr class="hover:bg-surface-container transition-colors">
<td class="px-4 py-3 font-data-label text-on-surface-variant">{{ $aggregates->firstItem() + $loop->index }}.</td>
<td class="px-4 py-3 font-semibold text-on-surface">
<a href="{{ route('aggregates.show', $a) }}" class="hover:text-primary">{{ $a->PUP_NAME }}</a>
</td>
<td class="px-4 py-3 text-right font-data-label">{{ $fmt($a->PUP_Q_N) }}</td>
<td class="px-4 py-3">{{ $a->manufacturer?->MCF_NAME }}</td>
<td class="px-4 py-3 text-right font-data-label">{{ $a->engines_count }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('aggregates.show', $a) }}" class="font-body-sm text-body-sm text-secondary hover:underline">Szczegoly</a>
</td>
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center font-body-md text-body-md text-on-surface-variant">Brak pomp dla wybranych kryteriow.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div>{{ $aggregates->links() }}</div>
</div>
</x-app-layout>
@@ -0,0 +1,96 @@
<x-app-layout :active="'aggregates.index'" title="SoftSPM 2 - Agregat {{ $pump->PUP_NAME }}">
@php $fmt = fn ($v, $d = 2) => $v === null ? '-' : number_format(round((float) $v, $d), $d, ',', ' '); @endphp
<div class="space-y-stack-gap">
@if (session('status'))
<div class="status-chip-ok px-3 py-2 rounded-lg font-body-sm text-body-sm">{{ session('status') }}</div>
@endif
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<a href="{{ route('aggregates.index') }}" class="font-body-sm text-body-sm text-on-surface-variant hover:text-primary">← Katalog agregatow</a>
<h1 class="font-headline-lg text-headline-lg text-on-surface">{{ $pump->PUP_NAME }}</h1>
</div>
<a href="{{ route('aggregates.edit', $pump) }}"
class="bg-secondary text-on-secondary px-4 py-2 rounded-full font-title-sm text-title-sm hover:opacity-90 active:scale-95 transition-all">Przypisz silniki</a>
</div>
{{-- Wykresy --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-stack-gap">
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<h2 class="font-title-sm text-title-sm text-tertiary mb-2">Wysokosc podnoszenia</h2>
@if ($hDrawable)
<x-pump-curve :plot="$hPlot" x-label="Q [m³/h]" y-label="H [m]" />
@else
<p class="font-body-sm text-body-sm text-on-surface-variant py-6 text-center">Brak parametrow energetycznych dla pompy.</p>
@endif
</div>
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<h2 class="font-title-sm text-title-sm text-tertiary mb-2">Sprawnosc</h2>
@if ($etaDrawable)
<x-pump-curve :plot="$etaPlot" x-label="Q [m³/h]" y-label="η [-]" />
@else
<p class="font-body-sm text-body-sm text-on-surface-variant py-6 text-center">Brak parametrow energetycznych dla pompy.</p>
@endif
</div>
</div>
{{-- Silniki dla pompy (kolumny wg oryginalu: Typ silnika / Napiecie / Producent) --}}
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card overflow-hidden">
<div class="px-card-inner-padding pt-card-inner-padding">
<h2 class="font-title-sm text-title-sm text-tertiary">Lista silnikow dla pompy: {{ $pump->PUP_NAME }}</h2>
</div>
<table class="w-full text-left mt-2">
<thead class="bg-secondary/10 border-y border-outline-variant/40">
<tr class="font-data-label text-data-label text-on-surface-variant tracking-wide">
<th class="px-4 py-2">Typ silnika</th>
<th class="px-4 py-2 text-right">Napiecie [V]</th>
<th class="px-4 py-2">Producent</th>
</tr>
</thead>
<tbody class="divide-y divide-outline-variant/30 font-body-md text-body-md">
@forelse ($engines as $e)
<tr class="cursor-pointer transition-colors {{ $engine && $engine->ENG_ID === $e->ENG_ID ? 'bg-secondary-container/60' : 'hover:bg-surface-container' }}"
onclick="location.href='{{ route('aggregates.show', ['pump' => $pump->PUP_ID, 'engine' => $e->ENG_ID]) }}'">
<td class="px-4 py-2 font-semibold text-on-surface">{{ $e->ENG_NAME }}</td>
<td class="px-4 py-2 text-right font-data-label">{{ $e->ENG_VOLTAGE }}</td>
<td class="px-4 py-2">{{ $e->MCF_NAME }}</td>
</tr>
@empty
<tr><td colspan="3" class="px-4 py-4 text-center font-body-sm text-on-surface-variant">Brak przypisanych silnikow - kliknij "Przypisz silniki".</td></tr>
@endforelse
</tbody>
</table>
</div>
{{-- Karta zestawu pompa + silnik --}}
@if ($engine)
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<h2 class="font-title-sm text-title-sm text-primary mb-3">{{ $pump->PUP_NAME }} + {{ $engine->ENG_NAME }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<a href="{{ route('pumps.show', $pump) }}" class="block group">
<p class="font-data-label text-data-label text-on-surface-variant tracking-wide mb-2 group-hover:text-primary">Pompa →</p>
<dl class="grid grid-cols-2 gap-y-1 font-body-md text-body-md">
<dt class="text-on-surface-variant">Typ pompy</dt><dd class="font-semibold">{{ $pump->PUP_NAME }}</dd>
<dt class="text-on-surface-variant">Producent</dt><dd>{{ $pump->manufacturer?->MCF_NAME ?? '-' }}</dd>
<dt class="text-on-surface-variant">Q<sub>n</sub> [m³/h]</dt><dd class="font-data-label">{{ $fmt($pump->PUP_Q_N, 3) }}</dd>
<dt class="text-on-surface-variant">H<sub>p</sub> [m]</dt><dd class="font-data-label">{{ $fmt($pump->PUP_H_P) }}</dd>
<dt class="text-on-surface-variant">η<sub>max</sub> [-]</dt><dd class="font-data-label">{{ $fmt($pump->PUP_ETA_MAX) }}</dd>
</dl>
</a>
<a href="{{ route('engines.show', $engine) }}" class="block group">
<p class="font-data-label text-data-label text-on-surface-variant tracking-wide mb-2 group-hover:text-primary">Silnik →</p>
<dl class="grid grid-cols-2 gap-y-1 font-body-md text-body-md">
<dt class="text-on-surface-variant">Typ silnika</dt><dd class="font-semibold">{{ $engine->ENG_NAME }}</dd>
<dt class="text-on-surface-variant">Producent</dt><dd>{{ $engine->manufacturer?->MCF_NAME ?? '-' }}</dd>
<dt class="text-on-surface-variant">Napiecie [V]</dt><dd class="font-data-label">{{ $engine->ENG_VOLTAGE ?? '-' }}</dd>
<dt class="text-on-surface-variant">Moc [kW]</dt><dd class="font-data-label">{{ $fmt($engine->ENG_POWER) }}</dd>
<dt class="text-on-surface-variant">Sprawnosc η [-]</dt><dd class="font-data-label">{{ $fmt($engine->ENG_ETA) }}</dd>
</dl>
</a>
</div>
</div>
@endif
</div>
</x-app-layout>
@@ -0,0 +1,47 @@
<x-app-layout :active="'cables.index'" title="SoftSPM 2 - Nowy typ kabla">
<div class="max-w-2xl space-y-stack-gap">
<div class="flex items-center gap-2">
<a href="{{ route('cables.index') }}" class="font-body-sm text-body-sm text-on-surface-variant hover:text-primary">← Wróć</a>
<h1 class="font-headline-lg text-headline-lg text-on-surface">Nowy typ kabla</h1>
</div>
@if ($errors->any())
<div class="status-chip-alert px-3 py-2 rounded-lg font-body-sm text-body-sm">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('cables.store') }}" enctype="multipart/form-data"
class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding space-y-4">
@csrf
<x-well-field name="name" label="Typ kabla" :value="old('name')" required />
<x-well-select name="manufacturer_id" label="Producent" :options="$manufacturers" :selected="old('manufacturer_id')" required placeholder="- wybierz -" />
<div class="space-y-1">
<label for="description" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Opis</label>
<textarea id="description" name="description" rows="3"
class="w-full bg-surface-container-low border border-outline-variant px-3 py-2 rounded focus:ring-1 focus:ring-primary focus:border-primary font-body-md text-on-surface">{{ old('description') }}</textarea>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<label for="catalogue_file" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Dokumentacja katalogowa</label>
<input id="catalogue_file" name="catalogue_file" type="file" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
class="w-full font-body-sm text-body-sm text-on-surface file:mr-3 file:px-4 file:py-2 file:rounded-full file:border-0 file:bg-secondary file:text-on-secondary file:font-title-sm file:cursor-pointer hover:file:opacity-90">
<p class="font-body-sm text-body-sm text-on-surface-variant/70">PDF lub Office, do 8 MB.</p>
</div>
<div class="space-y-1">
<label for="image_file" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Zdjęcie</label>
<input id="image_file" name="image_file" type="file" accept="image/*"
class="w-full font-body-sm text-body-sm text-on-surface file:mr-3 file:px-4 file:py-2 file:rounded-full file:border-0 file:bg-secondary file:text-on-secondary file:font-title-sm file:cursor-pointer hover:file:opacity-90">
<p class="font-body-sm text-body-sm text-on-surface-variant/70">JPG/PNG, do 8 MB.</p>
</div>
</div>
<div class="flex items-center gap-2 pt-2">
<button type="submit" class="bg-primary text-on-primary px-6 py-2.5 rounded-full font-title-sm text-title-sm hover:bg-primary-container transition-all active:scale-95 shadow-card">Zapisz</button>
<a href="{{ route('cables.index') }}" class="px-5 py-2.5 rounded-full font-title-sm text-title-sm text-on-surface-variant hover:bg-surface-container transition-all">Anuluj</a>
</div>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,56 @@
<x-app-layout :active="'cables.index'" title="SoftSPM 2 - Edycja: {{ $cable->EQU_NAME }}">
<div class="max-w-2xl space-y-stack-gap">
<div class="flex items-center gap-2">
<a href="{{ route('cables.show', $cable) }}" class="font-body-sm text-body-sm text-on-surface-variant hover:text-primary">← Wróć</a>
<h1 class="font-headline-lg text-headline-lg text-on-surface">Edycja typu kabla</h1>
</div>
@if ($errors->any())
<div class="status-chip-alert px-3 py-2 rounded-lg font-body-sm text-body-sm">{{ $errors->first() }}</div>
@endif
<form method="POST" action="{{ route('cables.update', $cable) }}" enctype="multipart/form-data"
class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding space-y-4">
@csrf
@method('PUT')
<x-well-field name="name" label="Typ kabla" :value="old('name', $cable->EQU_NAME)" required />
<x-well-select name="manufacturer_id" label="Producent" :options="$manufacturers" :selected="old('manufacturer_id', $cable->EQU_MCF_ID)" required placeholder="- wybierz -" />
<div class="space-y-1">
<label for="description" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Opis</label>
<textarea id="description" name="description" rows="3"
class="w-full bg-surface-container-low border border-outline-variant px-3 py-2 rounded focus:ring-1 focus:ring-primary focus:border-primary font-body-md text-on-surface">{{ old('description', $cable->EQU_DESCRIPTION) }}</textarea>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1">
<label for="catalogue_file" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Dokumentacja katalogowa</label>
<input id="catalogue_file" name="catalogue_file" type="file" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"
class="w-full font-body-sm text-body-sm text-on-surface file:mr-3 file:px-4 file:py-2 file:rounded-full file:border-0 file:bg-secondary file:text-on-secondary file:font-title-sm file:cursor-pointer hover:file:opacity-90">
@if ($document)
<p class="font-body-sm text-body-sm text-on-surface-variant/70">Obecny: <a href="{{ route('equipment.file', $document->EQF_ID) }}" target="_blank" class="text-secondary hover:underline">{{ $document->EQF_FILE_NAME }}</a>. Wgranie nowego zastąpi.</p>
@else
<p class="font-body-sm text-body-sm text-on-surface-variant/70">Brak. PDF lub Office, do 8 MB.</p>
@endif
</div>
<div class="space-y-1">
<label for="image_file" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Zdjęcie</label>
<input id="image_file" name="image_file" type="file" accept="image/*"
class="w-full font-body-sm text-body-sm text-on-surface file:mr-3 file:px-4 file:py-2 file:rounded-full file:border-0 file:bg-secondary file:text-on-secondary file:font-title-sm file:cursor-pointer hover:file:opacity-90">
@if ($image)
<p class="font-body-sm text-body-sm text-on-surface-variant/70">Obecne: <a href="{{ route('equipment.file', $image->EQF_ID) }}" target="_blank" class="text-secondary hover:underline">{{ $image->EQF_FILE_NAME }}</a>. Wgranie nowego zastąpi.</p>
@else
<p class="font-body-sm text-body-sm text-on-surface-variant/70">Brak. JPG/PNG, do 8 MB.</p>
@endif
</div>
</div>
<div class="flex items-center gap-2 pt-2">
<button type="submit" class="bg-primary text-on-primary px-6 py-2.5 rounded-full font-title-sm text-title-sm hover:bg-primary-container transition-all active:scale-95 shadow-card">Zapisz zmiany</button>
<a href="{{ route('cables.show', $cable) }}" class="px-5 py-2.5 rounded-full font-title-sm text-title-sm text-on-surface-variant hover:bg-surface-container transition-all">Anuluj</a>
</div>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,75 @@
<x-app-layout :active="'cables.index'" title="SoftSPM 2 - Katalog kabli">
<div class="space-y-stack-gap">
@if (session('status'))
<div class="status-chip-ok px-3 py-2 rounded-lg font-body-sm text-body-sm">{{ session('status') }}</div>
@endif
<div class="flex items-end justify-between">
<div>
<h1 class="font-headline-lg text-headline-lg text-on-surface">Katalog kabli</h1>
<p class="font-body-sm text-body-sm text-on-surface-variant mt-1">Wyników: <span class="font-data-label">{{ $cables->total() }}</span></p>
</div>
<a href="{{ route('cables.create') }}"
class="bg-primary text-on-primary px-5 py-2.5 rounded-full font-title-sm text-title-sm hover:bg-primary-container transition-all active:scale-95 shadow-card">
Dodaj typ kabla
</a>
</div>
{{-- Wyszukiwarka --}}
<form method="GET" action="{{ route('cables.index') }}"
class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<div class="flex flex-wrap items-end gap-4">
<div class="space-y-1">
<label for="q" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Typ kabla</label>
<input id="q" name="q" type="text" value="{{ $q }}" placeholder="nazwa / typ"
class="bg-surface-container-low border border-outline-variant px-3 py-2 rounded font-data-label text-on-surface">
</div>
<div class="space-y-1">
<label for="mcf" class="font-data-label text-data-label text-on-surface-variant tracking-wide">Producent</label>
<select id="mcf" name="mcf" class="bg-surface-container-low border border-outline-variant px-3 py-2 rounded font-data-label text-on-surface">
<option value="{{ $mcfAll }}" {{ (int) $mcf === $mcfAll ? 'selected' : '' }}>Wszyscy</option>
@foreach ($manufacturers as $id => $name)
<option value="{{ $id }}" {{ (int) $mcf === (int) $id ? 'selected' : '' }}>{{ $name }}</option>
@endforeach
</select>
</div>
<button type="submit" class="bg-secondary text-on-secondary px-5 py-2.5 rounded-full font-title-sm text-title-sm hover:opacity-90 transition-all active:scale-95">Szukaj</button>
</div>
</form>
{{-- Tabela --}}
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card overflow-hidden">
<table class="w-full text-left">
<thead class="bg-secondary/10 border-b border-outline-variant/40">
<tr class="font-data-label text-data-label text-on-surface-variant tracking-wide">
<th class="px-4 py-3 w-12">Lp.</th>
<x-sortable-th column="name">Typ kabla</x-sortable-th>
<x-sortable-th column="mcf">Producent</x-sortable-th>
<th class="px-4 py-3 text-right">Akcje</th>
</tr>
</thead>
<tbody class="divide-y divide-outline-variant/30 font-body-md text-body-md">
@forelse ($cables as $cable)
<tr class="hover:bg-surface-container transition-colors">
<td class="px-4 py-3 font-data-label text-on-surface-variant">{{ $cables->firstItem() + $loop->index }}.</td>
<td class="px-4 py-3 font-semibold text-on-surface">
<a href="{{ route('cables.show', $cable) }}" class="hover:text-primary">{{ $cable->EQU_NAME }}</a>
</td>
<td class="px-4 py-3">{{ $cable->manufacturer?->MCF_NAME }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<a href="{{ route('cables.show', $cable) }}" class="font-body-sm text-body-sm text-secondary hover:underline">Szczegóły</a>
<span class="text-outline-variant mx-1">·</span>
<a href="{{ route('cables.edit', $cable) }}" class="font-body-sm text-body-sm text-secondary hover:underline">Edytuj</a>
</td>
</tr>
@empty
<tr><td colspan="4" class="px-4 py-8 text-center font-body-md text-body-md text-on-surface-variant">Brak kabli dla wybranych kryteriów.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div>{{ $cables->links() }}</div>
</div>
</x-app-layout>
@@ -0,0 +1,61 @@
<x-app-layout :active="'cables.index'" title="SoftSPM 2 - {{ $cable->EQU_NAME }}">
<div class="space-y-stack-gap">
@if (session('status'))
<div class="status-chip-ok px-3 py-2 rounded-lg font-body-sm text-body-sm">{{ session('status') }}</div>
@endif
@if (session('error'))
<div class="status-chip-alert px-3 py-2 rounded-lg font-body-sm text-body-sm">{{ session('error') }}</div>
@endif
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<a href="{{ route('cables.index') }}" class="font-body-sm text-body-sm text-on-surface-variant hover:text-primary">← Katalog kabli</a>
<h1 class="font-headline-lg text-headline-lg text-on-surface">{{ $cable->EQU_NAME }}</h1>
</div>
<div class="flex items-center gap-2">
<a href="{{ route('cables.edit', $cable) }}"
class="bg-secondary text-on-secondary px-4 py-2 rounded-full font-title-sm text-title-sm hover:opacity-90 transition-all active:scale-95">Edytuj</a>
@unless ($inStock)
<x-confirm-delete :action="route('cables.destroy', $cable)"
message="Czy na pewno usunąć typ kabla „{{ $cable->EQU_NAME }}”?" />
@endunless
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-stack-gap">
{{-- Dane --}}
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<h2 class="font-title-sm text-title-sm text-tertiary mb-3">Dane</h2>
<dl class="grid grid-cols-3 gap-x-4 gap-y-2 font-body-md text-body-md">
<dt class="text-on-surface-variant">Typ kabla</dt><dd class="col-span-2 font-semibold">{{ $cable->EQU_NAME }}</dd>
<dt class="text-on-surface-variant">Producent</dt><dd class="col-span-2">{{ $cable->manufacturer?->MCF_NAME ?? '-' }}</dd>
<dt class="text-on-surface-variant">Opis</dt><dd class="col-span-2">{!! $cable->EQU_DESCRIPTION ? nl2br(e($cable->EQU_DESCRIPTION)) : '-' !!}</dd>
@if ($document)
<dt class="text-on-surface-variant">Dokumentacja katalogowa</dt>
<dd class="col-span-2">
<a href="{{ route('equipment.file', $document->EQF_ID) }}" target="_blank"
class="text-secondary hover:underline break-all">{{ $document->EQF_FILE_NAME }}</a>
</dd>
@endif
</dl>
@if ($inStock)
<p class="mt-4 font-body-sm text-body-sm text-on-surface-variant/80">Kabel jest na stanie magazynowym - usunięcie zablokowane.</p>
@endif
</div>
{{-- Zdjęcie --}}
<div class="bg-surface-container-lowest border border-outline-variant/40 rounded-lg shadow-card p-card-inner-padding">
<h2 class="font-title-sm text-title-sm text-tertiary mb-3">Zdjęcie</h2>
@if ($image)
<img src="{{ route('equipment.file', $image->EQF_ID) }}" alt="{{ $cable->EQU_NAME }}"
class="max-w-full h-auto rounded border border-outline-variant/40">
@else
<p class="font-body-sm text-body-sm text-on-surface-variant py-6 text-center">Brak zdjęcia.</p>
@endif
</div>
</div>
</div>
</x-app-layout>

Some files were not shown because too many files have changed in this diff Show More