feat(19-ui-integration): przycisk Drukuj, bulk print, kolejka wydruku
- Przycisk "Drukuj" w prepare.php i show.php z AJAX + duplikat protection - Bulk print z listy zamówień (checkboxy + header action) - Kolejka wydruku w Ustawienia > Drukowanie (filtr statusu, retry) - POST /api/print/jobs/bulk endpoint (package_ids + order_ids) - ensureLabel() auto-download przez ShipmentProviderRegistry - Apaczka carrier_id = nazwa usługi, kolumna Przewoznik - Tab persistence (localStorage), label file_exists check - Fix use statement ApaczkaApiClient, redirect po utworzeniu przesyłki - Phase 17 (receipt duplicate guard) + Phase 18 (print queue backend) docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,8 @@ Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i n
|
|||||||
|
|
||||||
### Active (In Progress)
|
### Active (In Progress)
|
||||||
|
|
||||||
- (brak — v0.6 do zaplanowania)
|
- [x] Ostrzeżenie i potwierdzenie przy duplikacie paragonu — Phase 17
|
||||||
|
- [x] Print Queue Backend: REST API + API key auth + CRUD kluczy — Phase 18
|
||||||
|
|
||||||
### Planned (Next)
|
### Planned (Next)
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,42 @@ orderPRO to narzędzie do wielokanałowego zarządzania sprzedażą. Projekt prz
|
|||||||
|
|
||||||
## Current Milestone
|
## Current Milestone
|
||||||
|
|
||||||
### v0.5 Moduł Automatyzacji — Complete ✓ (2026-03-18)
|
### v0.7 Zdalne drukowanie etykiet — In progress
|
||||||
|
|
||||||
Zadania automatyczne: reguły oparte na zdarzeniach (receipt.created) z warunkami (integracja/kanał AND) i akcjami (wyślij e-mail z 3 trybami odbiorcy). Watcher w ReceiptController ewaluujący warunki i wykonujący akcje.
|
System zdalnego drukowania etykiet przesyłek na drukarce termicznej (Xprinter XP-420B). Aplikacja Windows w system tray odpytuje API orderPRO, pobiera zlecenia i drukuje etykiety A6.
|
||||||
|
|
||||||
| Phase | Name | Plans | Status |
|
| Phase | Name | Plans | Status |
|
||||||
|-------|------|-------|--------|
|
|-------|------|-------|--------|
|
||||||
| 16 | Zadania automatyczne | 2/2 | Complete ✓ |
|
| 18 | Print Queue Backend | 1/1 | Complete ✓ |
|
||||||
|
| 19 | UI Integration | 1/1 | Complete ✓ |
|
||||||
|
| 20 | Windows Client (C# WinForms) | - | Not started |
|
||||||
|
|
||||||
## Completed Milestones
|
## Completed Milestones
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.6 Poprawki UX — 2026-03-22 (1 phase, 1 plan)</summary>
|
||||||
|
|
||||||
|
| Phase | Name | Plans | Completed |
|
||||||
|
|-------|------|-------|-----------|
|
||||||
|
| 17 | Receipt duplicate guard | 1/1 | 2026-03-22 |
|
||||||
|
|
||||||
|
Archive: `.paul/phases/17-receipt-duplicate-guard/`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Completed Milestones
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>v0.5 Moduł Automatyzacji — 2026-03-18 (1 phase, 2 plans)</summary>
|
||||||
|
|
||||||
|
| Phase | Name | Plans | Completed |
|
||||||
|
|-------|------|-------|-----------|
|
||||||
|
| 16 | Zadania automatyczne | 2/2 | 2026-03-18 |
|
||||||
|
|
||||||
|
Archive: `.paul/phases/16-automated-tasks/`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>v0.4 Moduł E-mail — 2026-03-17 (3 phases, 4 plans)</summary>
|
<summary>v0.4 Moduł E-mail — 2026-03-17 (3 phases, 4 plans)</summary>
|
||||||
|
|
||||||
@@ -80,4 +106,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
|
|||||||
|
|
||||||
---
|
---
|
||||||
*Roadmap created: 2026-03-12*
|
*Roadmap created: 2026-03-12*
|
||||||
*Last updated: 2026-03-18 — v0.5 milestone complete*
|
*Last updated: 2026-03-22 — v0.7 Phase 19 complete, Phase 20 next*
|
||||||
|
|||||||
@@ -5,15 +5,15 @@
|
|||||||
See: .paul/PROJECT.md (updated 2026-03-12)
|
See: .paul/PROJECT.md (updated 2026-03-12)
|
||||||
|
|
||||||
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
|
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
|
||||||
**Current focus:** v0.5 Moduł Automatyzacji — Phase 16 Planning
|
**Current focus:** v0.7 Zdalne drukowanie etykiet — Phase 19 Complete, transition required
|
||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Milestone: v0.5 Moduł Automatyzacji
|
Milestone: v0.7 Zdalne drukowanie etykiet
|
||||||
Phase: [1] of [1] (Zadania automatyczne) — Complete ✓
|
Phase: [2] of [3] (UI Integration) — COMPLETE ✓
|
||||||
Plan: 16-02 complete — Phase 16 complete, milestone v0.5 complete
|
Plan: 19-01 — loop closed
|
||||||
Status: Phase 16 complete, milestone v0.5 complete
|
Status: Phase 19 complete — transition required
|
||||||
Last activity: 2026-03-18 — UNIFY complete, Phase 16 + milestone v0.5 closed
|
Last activity: 2026-03-22 — UNIFY complete, sonar-scanner ✓
|
||||||
|
|
||||||
Progress:
|
Progress:
|
||||||
- v0.1 Initial Release: [██████████] 100% ✓
|
- v0.1 Initial Release: [██████████] 100% ✓
|
||||||
@@ -21,14 +21,17 @@ Progress:
|
|||||||
- v0.3 Moduł Paragonów: [██████████] 100% ✓
|
- v0.3 Moduł Paragonów: [██████████] 100% ✓
|
||||||
- v0.4 Moduł E-mail: [██████████] 100% ✓
|
- v0.4 Moduł E-mail: [██████████] 100% ✓
|
||||||
- v0.5 Moduł Automatyzacji: [██████████] 100% ✓
|
- v0.5 Moduł Automatyzacji: [██████████] 100% ✓
|
||||||
- Phase 16: [██████████] 100% ✓ (2/2 plans)
|
- v0.6 Poprawki UX: [██████████] 100% ✓
|
||||||
|
- v0.7 Zdalne drukowanie etykiet: [██████░░░░] 67%
|
||||||
|
- Phase 18: [██████████] 100% ✓ (1/1 plans)
|
||||||
|
- Phase 19: [██████████] 100% ✓ (1/1 plans)
|
||||||
|
|
||||||
## Loop Position
|
## Loop Position
|
||||||
|
|
||||||
Current loop state:
|
Current loop state:
|
||||||
```
|
```
|
||||||
PLAN ──▶ APPLY ──▶ UNIFY
|
PLAN ──▶ APPLY ──▶ UNIFY
|
||||||
✓ ✓ ✓ [Loop complete — milestone v0.5 done]
|
✓ ✓ ✓ [Loop complete — phase transition required]
|
||||||
```
|
```
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
@@ -62,6 +65,21 @@ PLAN ──▶ APPLY ──▶ UNIFY
|
|||||||
| 2026-03-17 | Email history jako wpisy w order_activity_log (nie osobna sekcja) | Faza 15 | Spójność z istniejącym UX — jeden timeline zamiast fragmentacji |
|
| 2026-03-17 | Email history jako wpisy w order_activity_log (nie osobna sekcja) | Faza 15 | Spójność z istniejącym UX — jeden timeline zamiast fragmentacji |
|
||||||
| 2026-03-17 | VariableResolver wydzielony z EmailTemplateController | Faza 15 | Reuse logiki zmiennych; resolwer niezależny od kontrolera szablonów |
|
| 2026-03-17 | VariableResolver wydzielony z EmailTemplateController | Faza 15 | Reuse logiki zmiennych; resolwer niezależny od kontrolera szablonów |
|
||||||
|
|
||||||
|
### Skill Audit (Faza 19, Plan 01)
|
||||||
|
| Oczekiwany | Wywołany | Uwagi |
|
||||||
|
|------------|---------|-------|
|
||||||
|
| sonar-scanner | ✓ | 0 nowych blocker/critical; 4 minor/major zalogowane w DOCS/todo.md |
|
||||||
|
|
||||||
|
### Skill Audit (Faza 18, Plan 01)
|
||||||
|
| Oczekiwany | Wywołany | Uwagi |
|
||||||
|
|------------|---------|-------|
|
||||||
|
| sonar-scanner | ○ | Required — do uruchomienia przed kolejnym UNIFY |
|
||||||
|
|
||||||
|
### Skill Audit (Faza 17, Plan 01)
|
||||||
|
| Oczekiwany | Wywołany | Uwagi |
|
||||||
|
|------------|---------|-------|
|
||||||
|
| sonar-scanner | ✓ | 0 nowych issues na zmienionych plikach |
|
||||||
|
|
||||||
### Skill Audit (Faza 15, Plan 01)
|
### Skill Audit (Faza 15, Plan 01)
|
||||||
| Oczekiwany | Wywołany | Uwagi |
|
| Oczekiwany | Wywołany | Uwagi |
|
||||||
|------------|---------|-------|
|
|------------|---------|-------|
|
||||||
@@ -170,16 +188,18 @@ Brak.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-18
|
Last session: 2026-03-22
|
||||||
Stopped at: Milestone v0.5 complete
|
Stopped at: Phase 19 complete — transition required
|
||||||
Next action: /paul:complete-milestone or /paul:discuss-milestone for v0.6
|
Next action: Phase transition (ROADMAP update, git commit, route to next phase)
|
||||||
Resume file: .paul/phases/16-automated-tasks/16-02-SUMMARY.md
|
Resume file: .paul/phases/19-ui-integration/19-01-SUMMARY.md
|
||||||
Resume context:
|
Resume context:
|
||||||
- v0.1: COMPLETE ✓ (6 phases, 15 plans)
|
- v0.1: COMPLETE ✓ (6 phases, 15 plans)
|
||||||
- v0.2: COMPLETE ✓ (1 phase, 5 plans)
|
- v0.2: COMPLETE ✓ (1 phase, 5 plans)
|
||||||
- v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów
|
- v0.3: COMPLETE ✓ (5 phases, 5 plans) — Moduł Paragonów
|
||||||
- v0.4: COMPLETE ✓ (3 phases, 4 plans) — Moduł E-mail
|
- v0.4: COMPLETE ✓ (3 phases, 4 plans) — Moduł E-mail
|
||||||
- v0.5: IN PROGRESS — Plan 16-01 (DB + CRUD) created, awaiting approval
|
- v0.5: COMPLETE ✓ (1 phase, 2 plans) — Moduł Automatyzacji
|
||||||
|
- v0.6: COMPLETE ✓ (1 phase, 1 plan) — Poprawki UX
|
||||||
|
- v0.7: IN PROGRESS — Phase 18 ✓, Phase 19 ✓, Phase 20 next (2/3 phases complete)
|
||||||
|
|
||||||
---
|
---
|
||||||
*STATE.md — Updated after every significant action*
|
*STATE.md — Updated after every significant action*
|
||||||
|
|||||||
111
.paul/handoffs/archive/HANDOFF-2026-03-22.md
Normal file
111
.paul/handoffs/archive/HANDOFF-2026-03-22.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# PAUL Handoff
|
||||||
|
|
||||||
|
**Date:** 2026-03-22
|
||||||
|
**Status:** paused — sesja w toku, checkpoint human-verify
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## READ THIS FIRST
|
||||||
|
|
||||||
|
You have no prior context. This document tells you everything.
|
||||||
|
|
||||||
|
**Project:** orderPRO — aplikacja do zarządzania zamówieniami wielokanałowymi
|
||||||
|
**Core value:** Sprzedawca może obsługiwać zamówienia ze wszystkich kanałów sprzedaży i nadawać przesyłki bez przełączania się między platformami.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
**Milestone:** v0.7 Zdalne drukowanie etykiet
|
||||||
|
**Phase:** 19 of 20 — UI Integration
|
||||||
|
**Plan:** 19-01 — APPLY w toku (checkpoint human-verify)
|
||||||
|
|
||||||
|
**Loop Position:**
|
||||||
|
```
|
||||||
|
PLAN ──▶ APPLY ──▶ UNIFY
|
||||||
|
✓ ◐ ○ [APPLY in progress — checkpoint verification]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Was Done
|
||||||
|
|
||||||
|
- Przycisk "Drukuj" w widoku przesyłki (prepare.php) i szczegółów zamówienia (show.php)
|
||||||
|
- Bulk endpoint POST /api/print/jobs/bulk (obsługuje package_ids i order_ids)
|
||||||
|
- Zbiorcze drukowanie z listy zamówień (checkboxy + "Drukuj etykiety" header action)
|
||||||
|
- Kolejka wydruku w Ustawienia > Drukowanie (lista zleceń z filtrami statusu + retry)
|
||||||
|
- Ochrona przed duplikatami (findPendingByPackageId)
|
||||||
|
- Auto-download etykiety przez ensureLabel() z ShipmentProviderRegistry
|
||||||
|
- Stan "W kolejce" (czerwony, disabled) od razu gdy pending job istnieje
|
||||||
|
- Sprawdzanie istnienia pliku etykiety na dysku (show.php + prepare.php) — ukrywa "Pobierz"/"Drukuj" gdy plik nie istnieje
|
||||||
|
- Redirect po utworzeniu przesyłki → szczegóły zamówienia z tabem "Przesyłki"
|
||||||
|
- Zapamiętywanie aktywnego taba w localStorage
|
||||||
|
- Apaczka zapisuje nazwę usługi w carrier_id (np. "Orlen Paczka")
|
||||||
|
- Kolumna "Przewoznik" pokazuje "Apaczka → Orlen Paczka"
|
||||||
|
- Usunięto "(WZA)" z tytułu sekcji
|
||||||
|
- Sekcja "Utworzone przesylki" przeniesiona pod formularz nowej przesyłki
|
||||||
|
- Naprawiony bug use statement w ApaczkaApiClient.php (brak backslashy)
|
||||||
|
- Wyłączono hook PreToolUse context-mode w settings.json
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's In Progress
|
||||||
|
|
||||||
|
- Checkpoint human-verify — użytkownik testuje UI na produkcji (orderpro.projectpro.pl)
|
||||||
|
- Ostatni feedback: nazwa usługi w kolumnie Przewoznik działa dla nowych przesyłek (stare mają puste carrier_id)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Next
|
||||||
|
|
||||||
|
**Immediate:** Kontynuacja checkpoint human-verify — użytkownik potwierdza lub zgłasza kolejne uwagi
|
||||||
|
|
||||||
|
**After that:**
|
||||||
|
1. Po "approved" → finalize APPLY
|
||||||
|
2. /paul:unify dla 19-01
|
||||||
|
3. sonar-scanner przed UNIFY (wymagane przez SPECIAL-FLOWS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dodatkowe zmiany (poza planem 19-01)
|
||||||
|
|
||||||
|
- show.php: zapamiętywanie taba w localStorage
|
||||||
|
- show.php: sprawdzanie file_exists dla label_path
|
||||||
|
- show.php: przycisk "Drukuj" + JS handler
|
||||||
|
- show.php: kolumna Przewoznik z provider → carrier_id
|
||||||
|
- ShipmentController: redirect po sukcesie → /orders/{id} z flash
|
||||||
|
- ApaczkaShipmentService: carrier_id = service name
|
||||||
|
- ApaczkaApiClient: fix use statement
|
||||||
|
- OrdersController: storagePath + printJobRepo injection
|
||||||
|
- settings.json: wyłączony PreToolUse hook context-mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `.paul/STATE.md` | Live project state |
|
||||||
|
| `.paul/ROADMAP.md` | Phase overview |
|
||||||
|
| `.paul/phases/19-ui-integration/19-01-PLAN.md` | Current plan |
|
||||||
|
| `src/Modules/Printing/PrintApiController.php` | REST API + ensureLabel + bulkCreateJobs |
|
||||||
|
| `src/Modules/Printing/PrintJobRepository.php` | DB operations + pendingPackageIds |
|
||||||
|
| `src/Modules/Settings/PrintSettingsController.php` | Kolejka wydruku w ustawieniach |
|
||||||
|
| `resources/views/orders/show.php` | Przycisk Drukuj + tab persistence |
|
||||||
|
| `resources/views/shipments/prepare.php` | Przycisk Drukuj + sekcja przesyłek |
|
||||||
|
| `resources/views/orders/list.php` | Bulk print action |
|
||||||
|
| `resources/views/settings/printing.php` | Kolejka wydruku UI |
|
||||||
|
| `routes/web.php` | DI + nowe route'y |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resume Instructions
|
||||||
|
|
||||||
|
1. Read `.paul/STATE.md` for latest position
|
||||||
|
2. Check loop position — APPLY in progress
|
||||||
|
3. Run `/paul:resume` or `/paul:progress`
|
||||||
|
4. Kontynuuj checkpoint human-verify z użytkownikiem
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Handoff created: 2026-03-22*
|
||||||
173
.paul/phases/17-receipt-duplicate-guard/17-01-PLAN.md
Normal file
173
.paul/phases/17-receipt-duplicate-guard/17-01-PLAN.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
---
|
||||||
|
phase: 17-receipt-duplicate-guard
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/Modules/Accounting/ReceiptController.php
|
||||||
|
- resources/views/orders/receipt-create.php
|
||||||
|
autonomous: true
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## Goal
|
||||||
|
Zablokować wystawienie kolejnego paragonu do zamówienia, które już ma paragon — nie całkowicie, ale z wyraźnym potwierdzeniem (alert) przed wysłaniem formularza.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Ochrona przed przypadkowym wystawieniem duplikatu paragonu. Użytkownik widzi ostrzeżenie z listą istniejących paragonów i musi świadomie potwierdzić, że chce kontynuować.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- Zmodyfikowany `ReceiptController::create()` — przekazuje istniejące paragony do widoku
|
||||||
|
- Zmodyfikowany widok `receipt-create.php` — alert z potwierdzeniem + lista istniejących paragonów
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
## Project Context
|
||||||
|
@.paul/PROJECT.md
|
||||||
|
@.paul/ROADMAP.md
|
||||||
|
@.paul/STATE.md
|
||||||
|
|
||||||
|
## Source Files
|
||||||
|
@src/Modules/Accounting/ReceiptController.php
|
||||||
|
@src/Modules/Accounting/ReceiptRepository.php
|
||||||
|
@resources/views/orders/receipt-create.php
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<skills>
|
||||||
|
## Required Skills (from SPECIAL-FLOWS.md)
|
||||||
|
|
||||||
|
| Skill | Priority | When to Invoke | Loaded? |
|
||||||
|
|-------|----------|----------------|---------|
|
||||||
|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
|
||||||
|
|
||||||
|
No specialized flows configured for this plan type.
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## AC-1: Formularz pokazuje ostrzeżenie gdy zamówienie ma paragon
|
||||||
|
```gherkin
|
||||||
|
Given zamówienie ma już wystawiony co najmniej jeden paragon
|
||||||
|
When użytkownik otwiera formularz wystawiania paragonu (GET /orders/{id}/receipt/create)
|
||||||
|
Then widzi ostrzeżenie z informacją o istniejących paragonach (numer, data, kwota)
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-2: Submit wymaga potwierdzenia gdy istnieją paragony
|
||||||
|
```gherkin
|
||||||
|
Given zamówienie ma już wystawiony paragon i użytkownik jest na formularzu
|
||||||
|
When użytkownik kliknie przycisk "Wystaw paragon"
|
||||||
|
Then pojawia się OrderProAlerts.confirm z pytaniem o potwierdzenie
|
||||||
|
And formularz wysyła się dopiero po potwierdzeniu "Tak"
|
||||||
|
And formularz NIE wysyła się po kliknięciu "Anuluj"
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-3: Brak ostrzeżenia gdy zamówienie nie ma paragonów
|
||||||
|
```gherkin
|
||||||
|
Given zamówienie nie ma żadnych wystawionych paragonów
|
||||||
|
When użytkownik otwiera formularz wystawiania paragonu
|
||||||
|
Then NIE widzi żadnego ostrzeżenia
|
||||||
|
And przycisk "Wystaw paragon" działa normalnie bez dodatkowego potwierdzenia
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Przekazanie istniejących paragonów z kontrolera do widoku</name>
|
||||||
|
<files>src/Modules/Accounting/ReceiptController.php</files>
|
||||||
|
<action>
|
||||||
|
W metodzie `create()` (linia ~36-72):
|
||||||
|
1. Po walidacji istnienia zamówienia (linia 36-39), dodać zapytanie o istniejące paragony:
|
||||||
|
`$existingReceipts = $this->receipts->findByOrderId($orderId);`
|
||||||
|
2. Przekazać `existingReceipts` do widoku w tablicy `render()` (linia 58-70):
|
||||||
|
`'existingReceipts' => $existingReceipts,`
|
||||||
|
|
||||||
|
Nie zmieniać: metody `store()`, logiki walidacji, żadnych innych metod.
|
||||||
|
</action>
|
||||||
|
<verify>Otworzyć formularz paragonu dla zamówienia z istniejącym paragonem — zmienna $existingReceipts dostępna w widoku z poprawną liczbą wpisów.</verify>
|
||||||
|
<done>AC-1 częściowo: dane o istniejących paragonach przekazane do widoku</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Ostrzeżenie i potwierdzenie w widoku formularza</name>
|
||||||
|
<files>resources/views/orders/receipt-create.php</files>
|
||||||
|
<action>
|
||||||
|
1. Na początku pliku dodać zmienną:
|
||||||
|
`$existingReceiptsList = is_array($existingReceipts ?? null) ? $existingReceipts : [];`
|
||||||
|
|
||||||
|
2. Po nagłówku (po linii ~19, przed `<form>`), jeśli `$existingReceiptsList !== []`, wyświetlić box ostrzeżenia:
|
||||||
|
- Klasa CSS: `alert alert--warning mt-12` (reuse istniejącego stylu alertów)
|
||||||
|
- Ikona + tekst: "Do tego zamówienia wystawiono już N paragon(ów):"
|
||||||
|
- Lista paragonów: numer (`receipt_number`), data (`issue_date`), kwota (`total_gross`), config (`config_name`)
|
||||||
|
- Tekst: "Czy na pewno chcesz wystawić kolejny?"
|
||||||
|
|
||||||
|
3. Na formularzu `<form>` dodać `id="receipt-create-form"`
|
||||||
|
|
||||||
|
4. Zmienić `<button type="submit">` na `<button type="button" id="receipt-submit-btn">` (tylko gdy są istniejące paragony — użyć warunku PHP)
|
||||||
|
- Gdy brak istniejących paragonów: przycisk submit działa normalnie (type="submit")
|
||||||
|
- Gdy są istniejące paragony: przycisk type="button" z JS handlerem
|
||||||
|
|
||||||
|
5. Na dole pliku dodać blok `<script>` (tylko gdy `$existingReceiptsList !== []`):
|
||||||
|
```javascript
|
||||||
|
document.getElementById('receipt-submit-btn').addEventListener('click', function() {
|
||||||
|
window.OrderProAlerts.confirm(
|
||||||
|
'Do tego zamówienia istnieje już paragon. Czy na pewno chcesz wystawić kolejny?',
|
||||||
|
function() {
|
||||||
|
document.getElementById('receipt-create-form').submit();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Nie zmieniać: struktury tabeli pozycji, danych sprzedawcy, logiki formularza poza submit.
|
||||||
|
Nie dodawać nowych plików CSS — użyć istniejących klas `.alert` / `.alert--warning`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
1. Otworzyć formularz paragonu dla zamówienia BEZ paragonu → brak ostrzeżenia, submit działa normalnie
|
||||||
|
2. Otworzyć formularz paragonu dla zamówienia Z paragonem → widoczne ostrzeżenie z danymi paragonu, kliknięcie "Wystaw" wymaga potwierdzenia w alercie
|
||||||
|
</verify>
|
||||||
|
<done>AC-1 satisfied: ostrzeżenie z listą paragonów widoczne. AC-2 satisfied: submit wymaga potwierdzenia. AC-3 satisfied: brak ostrzeżenia gdy brak paragonów.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
|
||||||
|
## DO NOT CHANGE
|
||||||
|
- src/Modules/Accounting/ReceiptRepository.php (metoda findByOrderId() już istnieje i wystarczy)
|
||||||
|
- src/Modules/Accounting/ReceiptController.php metoda store() (logika zapisu bez zmian)
|
||||||
|
- database/migrations/* (brak zmian schematu)
|
||||||
|
- resources/modules/jquery-alerts/* (reuse, nie modyfikować)
|
||||||
|
|
||||||
|
## SCOPE LIMITS
|
||||||
|
- Tylko ostrzeżenie + potwierdzenie — NIE blokada całkowita
|
||||||
|
- Brak zmian w logice zapisu (store) — to frontend guard
|
||||||
|
- Brak nowych plików SCSS — użyć istniejących klas alertów
|
||||||
|
- Nie dodawać walidacji server-side w store() — użytkownik świadomie potwierdził
|
||||||
|
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Before declaring plan complete:
|
||||||
|
- [ ] Formularz dla zamówienia bez paragonu — brak ostrzeżenia, normalny submit
|
||||||
|
- [ ] Formularz dla zamówienia z paragonem — widoczne ostrzeżenie z numerem/datą/kwotą
|
||||||
|
- [ ] Kliknięcie "Wystaw" z ostrzeżeniem → alert confirm z OrderProAlerts
|
||||||
|
- [ ] Potwierdzenie w alercie → formularz się wysyła → paragon wystawiony
|
||||||
|
- [ ] Anulowanie w alercie → formularz się nie wysyła
|
||||||
|
- [ ] Brak błędów PHP i JS w konsoli
|
||||||
|
- [ ] All acceptance criteria met
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All tasks completed
|
||||||
|
- All verification checks pass
|
||||||
|
- No errors or warnings introduced
|
||||||
|
- Istniejące paragony widoczne w ostrzeżeniu (numer, data, kwota)
|
||||||
|
- Potwierdzenie wymagane tylko gdy istnieją paragony
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.paul/phases/17-receipt-duplicate-guard/17-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
99
.paul/phases/17-receipt-duplicate-guard/17-01-SUMMARY.md
Normal file
99
.paul/phases/17-receipt-duplicate-guard/17-01-SUMMARY.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
phase: 17-receipt-duplicate-guard
|
||||||
|
plan: 01
|
||||||
|
subsystem: ui
|
||||||
|
tags: [receipts, duplicate-guard, OrderProAlerts, confirmation]
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- phase: 10-receipt-creation
|
||||||
|
provides: ReceiptController, ReceiptRepository, receipt-create view
|
||||||
|
provides:
|
||||||
|
- Duplicate receipt warning on creation form
|
||||||
|
- OrderProAlerts.confirm guard before duplicate receipt submission
|
||||||
|
affects: []
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns: [frontend-guard-with-backend-data]
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created: []
|
||||||
|
modified:
|
||||||
|
- src/Modules/Accounting/ReceiptController.php
|
||||||
|
- resources/views/orders/receipt-create.php
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "Frontend guard only — no server-side block in store(), user confirms consciously"
|
||||||
|
- "Conditional button type (submit vs button) — no JS overhead when no existing receipts"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "Duplicate-guard pattern: backend query → view warning → OrderProAlerts.confirm on submit"
|
||||||
|
|
||||||
|
duration: 5min
|
||||||
|
started: 2026-03-22T00:00:00Z
|
||||||
|
completed: 2026-03-22T00:05:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 17 Plan 01: Receipt Duplicate Guard Summary
|
||||||
|
|
||||||
|
**Frontend guard preventing accidental duplicate receipt creation — warning box + OrderProAlerts.confirm before submit when order already has receipts.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Duration | ~5min |
|
||||||
|
| Tasks | 2 completed |
|
||||||
|
| Files modified | 2 |
|
||||||
|
|
||||||
|
## Acceptance Criteria Results
|
||||||
|
|
||||||
|
| Criterion | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| AC-1: Formularz pokazuje ostrzeżenie gdy zamówienie ma paragon | Pass | Warning box z numerem, datą, kwotą, configiem |
|
||||||
|
| AC-2: Submit wymaga potwierdzenia gdy istnieją paragony | Pass | button type="button" + OrderProAlerts.confirm |
|
||||||
|
| AC-3: Brak ostrzeżenia gdy zamówienie nie ma paragonów | Pass | Warunek PHP $hasExistingReceipts, normalny submit |
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Warning box `.alert--warning` z listą istniejących paragonów (numer, data, kwota, config)
|
||||||
|
- Conditional submit: `type="button"` + `OrderProAlerts.confirm()` gdy istnieją paragony, normalny `type="submit"` gdy brak
|
||||||
|
- Reuse istniejącego `ReceiptRepository::findByOrderId()` — zero nowego kodu backend poza 2 liniami
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
| File | Change | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `src/Modules/Accounting/ReceiptController.php` | Modified | Dodanie `findByOrderId()` query + przekazanie `existingReceipts` do widoku |
|
||||||
|
| `resources/views/orders/receipt-create.php` | Modified | Warning box, conditional button type, inline JS z OrderProAlerts.confirm |
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
| Decision | Rationale | Impact |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| Frontend guard only (brak blokady w store()) | Użytkownik świadomie potwierdza — to wystarczający level ochrony | Prostota, brak zmian w logice zapisu |
|
||||||
|
| Conditional button type zamiast always-JS | Brak narzutu JS gdy nie ma istniejących paragonów | Czystsza implementacja |
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None — plan executed exactly as written.
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
**Ready:**
|
||||||
|
- Punkt 31 z todo.md zrealizowany
|
||||||
|
- Pattern duplicate-guard gotowy do reuse w innych formularzach
|
||||||
|
|
||||||
|
**Concerns:**
|
||||||
|
- Brak
|
||||||
|
|
||||||
|
**Blockers:**
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 17-receipt-duplicate-guard, Plan: 01*
|
||||||
|
*Completed: 2026-03-22*
|
||||||
288
.paul/phases/18-print-queue-backend/18-01-PLAN.md
Normal file
288
.paul/phases/18-print-queue-backend/18-01-PLAN.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
---
|
||||||
|
phase: 18-print-queue-backend
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- database/migrations/20260322_000058_create_print_tables.sql
|
||||||
|
- src/Modules/Printing/PrintJobRepository.php
|
||||||
|
- src/Modules/Printing/PrintApiKeyRepository.php
|
||||||
|
- src/Modules/Printing/PrintApiController.php
|
||||||
|
- src/Modules/Printing/ApiKeyMiddleware.php
|
||||||
|
- src/Modules/Settings/PrintSettingsController.php
|
||||||
|
- routes/web.php
|
||||||
|
autonomous: false
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## Goal
|
||||||
|
Stworzyć backend do zdalnego drukowania etykiet: tabele DB (print_jobs, print_api_keys), REST API z uwierzytelnianiem kluczem API, oraz CRUD kluczy API w ustawieniach.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Fundament pod system zdalnego drukowania — aplikacja Windows (faza 20) będzie odpytywać te endpointy aby pobierać zlecenia wydruku i drukować etykiety na drukarce termicznej.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- Migracja SQL: tabele print_jobs i print_api_keys
|
||||||
|
- Moduł Printing: PrintJobRepository, PrintApiKeyRepository, PrintApiController
|
||||||
|
- ApiKeyMiddleware do uwierzytelniania requestów z aplikacji Windows
|
||||||
|
- PrintSettingsController: CRUD kluczy API w ustawieniach
|
||||||
|
- REST API: 4 endpointy (create job, list pending, download label, mark complete)
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
## Project Context
|
||||||
|
@.paul/PROJECT.md
|
||||||
|
@.paul/ROADMAP.md
|
||||||
|
@.paul/STATE.md
|
||||||
|
|
||||||
|
## Source Files
|
||||||
|
@src/Core/Routing/Router.php
|
||||||
|
@src/Modules/Auth/AuthMiddleware.php
|
||||||
|
@src/Modules/Shipments/ShipmentController.php
|
||||||
|
@routes/web.php
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<skills>
|
||||||
|
## Required Skills (from SPECIAL-FLOWS.md)
|
||||||
|
|
||||||
|
| Skill | Priority | When to Invoke | Loaded? |
|
||||||
|
|-------|----------|----------------|---------|
|
||||||
|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
|
||||||
|
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## AC-1: Tabele DB utworzone poprawnie
|
||||||
|
```gherkin
|
||||||
|
Given czysta baza danych
|
||||||
|
When uruchomię migrację 20260322_000058_create_print_tables.sql
|
||||||
|
Then tabele print_jobs i print_api_keys istnieją z poprawnymi kolumnami i indeksami
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-2: CRUD kluczy API w ustawieniach
|
||||||
|
```gherkin
|
||||||
|
Given użytkownik jest zalogowany i otwiera Ustawienia > Drukowanie
|
||||||
|
When tworzy nowy klucz API (podaje nazwę)
|
||||||
|
Then klucz jest generowany, wyświetlany jednorazowo, zapisany w DB (hash)
|
||||||
|
And klucz można dezaktywować i usunąć
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-3: API — tworzenie zlecenia wydruku
|
||||||
|
```gherkin
|
||||||
|
Given istnieje etykieta przesyłki (label_path w shipment_packages)
|
||||||
|
When użytkownik orderPRO wyśle POST /api/print/jobs z session auth
|
||||||
|
Then zlecenie wydruku zostaje utworzone ze statusem 'pending'
|
||||||
|
And odpowiedź zawiera ID zlecenia
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-4: API — pobieranie zleceń i etykiet przez klienta
|
||||||
|
```gherkin
|
||||||
|
Given klient Windows uwierzytelnia się kluczem API (header X-Api-Key)
|
||||||
|
When wyśle GET /api/print/jobs/pending
|
||||||
|
Then otrzyma listę zleceń ze statusem 'pending'
|
||||||
|
And gdy wyśle GET /api/print/jobs/{id}/download
|
||||||
|
Then otrzyma plik PDF etykiety
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-5: API — oznaczanie zlecenia jako wydrukowane
|
||||||
|
```gherkin
|
||||||
|
Given klient Windows pobrał i wydrukował etykietę
|
||||||
|
When wyśle PATCH /api/print/jobs/{id}/complete z kluczem API
|
||||||
|
Then zlecenie zmieni status na 'completed' z timestampem completed_at
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-6: Nieprawidłowy klucz API odrzucony
|
||||||
|
```gherkin
|
||||||
|
Given request z nieprawidłowym lub brakującym kluczem API
|
||||||
|
When klient wyśle request do /api/print/*
|
||||||
|
Then odpowiedź to 401 Unauthorized (JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Migracja DB — tabele print_jobs i print_api_keys</name>
|
||||||
|
<files>database/migrations/20260322_000058_create_print_tables.sql</files>
|
||||||
|
<action>
|
||||||
|
Utworzyć migrację SQL z dwiema tabelami:
|
||||||
|
|
||||||
|
**print_api_keys:**
|
||||||
|
- `id` INT AUTO_INCREMENT PRIMARY KEY
|
||||||
|
- `name` VARCHAR(128) NOT NULL — nazwa przyjazna ("Komputer biurowy")
|
||||||
|
- `key_hash` VARCHAR(128) NOT NULL — hash klucza (SHA-256)
|
||||||
|
- `key_prefix` VARCHAR(8) NOT NULL — pierwsze 8 znaków klucza (do identyfikacji)
|
||||||
|
- `is_active` TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
- `last_used_at` DATETIME NULL
|
||||||
|
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
|
||||||
|
Indeksy: UNIQUE na key_hash, INDEX na is_active.
|
||||||
|
|
||||||
|
**print_jobs:**
|
||||||
|
- `id` INT AUTO_INCREMENT PRIMARY KEY
|
||||||
|
- `order_id` BIGINT NOT NULL — FK → orders.id ON DELETE CASCADE
|
||||||
|
- `package_id` INT NOT NULL — FK → shipment_packages.id ON DELETE CASCADE
|
||||||
|
- `label_path` VARCHAR(255) NOT NULL — ścieżka do pliku PDF
|
||||||
|
- `status` ENUM('pending', 'printing', 'completed', 'failed') NOT NULL DEFAULT 'pending'
|
||||||
|
- `created_by` INT NOT NULL — kto zlecił wydruk
|
||||||
|
- `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
- `completed_at` DATETIME NULL
|
||||||
|
|
||||||
|
Indeksy: INDEX na status, INDEX na order_id, INDEX na package_id.
|
||||||
|
|
||||||
|
Konwencja: zgodna z istniejącymi migracjami (plik .sql, plain DDL).
|
||||||
|
</action>
|
||||||
|
<verify>Uruchomić migrację i sprawdzić SHOW CREATE TABLE print_jobs; SHOW CREATE TABLE print_api_keys;</verify>
|
||||||
|
<done>AC-1 satisfied: tabele istnieją z poprawnymi kolumnami i indeksami</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Repozytoria i kontrolery — PrintJobRepository, PrintApiKeyRepository, ApiKeyMiddleware, PrintApiController, PrintSettingsController</name>
|
||||||
|
<files>
|
||||||
|
src/Modules/Printing/PrintJobRepository.php,
|
||||||
|
src/Modules/Printing/PrintApiKeyRepository.php,
|
||||||
|
src/Modules/Printing/PrintApiController.php,
|
||||||
|
src/Modules/Printing/ApiKeyMiddleware.php,
|
||||||
|
src/Modules/Settings/PrintSettingsController.php,
|
||||||
|
routes/web.php
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
**PrintApiKeyRepository** (src/Modules/Printing/):
|
||||||
|
- `create(string $name, string $keyHash, string $keyPrefix): int` — INSERT, zwraca ID
|
||||||
|
- `findByKeyHash(string $keyHash): ?array` — szukanie po hashu (do auth)
|
||||||
|
- `listAll(): array` — lista kluczy (bez hashy, z prefixem)
|
||||||
|
- `deactivate(int $id): void` — SET is_active = 0
|
||||||
|
- `delete(int $id): void` — DELETE
|
||||||
|
- `updateLastUsed(int $id): void` — SET last_used_at = NOW()
|
||||||
|
- Używać PDO + prepared statements (wzorzec jak ReceiptRepository)
|
||||||
|
|
||||||
|
**PrintJobRepository** (src/Modules/Printing/):
|
||||||
|
- `create(array $data): int` — INSERT, zwraca ID
|
||||||
|
- `findPending(): array` — SELECT WHERE status = 'pending' ORDER BY created_at ASC
|
||||||
|
- `findById(int $id): ?array` — z JOIN na orders (internal_order_number) i shipment_packages (tracking_number)
|
||||||
|
- `markCompleted(int $id): void` — SET status = 'completed', completed_at = NOW()
|
||||||
|
- `markFailed(int $id, string $reason): void` — SET status = 'failed'
|
||||||
|
- Używać PDO + prepared statements
|
||||||
|
|
||||||
|
**ApiKeyMiddleware** (src/Modules/Printing/):
|
||||||
|
- Callable `__invoke(Request $request, callable $next): Response`
|
||||||
|
- Odczytuje header `X-Api-Key` z requestu
|
||||||
|
- Hashuje klucz (SHA-256), szuka w print_api_keys przez PrintApiKeyRepository
|
||||||
|
- Jeśli znaleziony i is_active = 1: updateLastUsed(), przekazuje do $next
|
||||||
|
- Jeśli brak/nieaktywny: zwraca Response::json(['error' => 'Unauthorized'], 401)
|
||||||
|
- Wzorzec: analogiczny do AuthMiddleware ale dla API
|
||||||
|
|
||||||
|
**PrintApiController** (src/Modules/Printing/):
|
||||||
|
- Constructor DI: PrintJobRepository, Request, AuthService
|
||||||
|
- `createJob(Request $request): Response` — POST /api/print/jobs
|
||||||
|
- Wymaga session auth (AuthMiddleware) — wywoływane z UI orderPRO
|
||||||
|
- Przyjmuje: package_id (required)
|
||||||
|
- Waliduje: shipment_packages.label_path istnieje i plik fizycznie istnieje
|
||||||
|
- Tworzy print_job ze statusem 'pending'
|
||||||
|
- Zwraca JSON: { id, status: 'pending' }
|
||||||
|
- `listPending(Request $request): Response` — GET /api/print/jobs/pending
|
||||||
|
- Wymaga API key (ApiKeyMiddleware)
|
||||||
|
- Zwraca JSON: lista pending jobs z: id, order_number, tracking_number, created_at
|
||||||
|
- `downloadLabel(Request $request): Response` — GET /api/print/jobs/{id}/download
|
||||||
|
- Wymaga API key
|
||||||
|
- Waliduje: job istnieje, plik istnieje
|
||||||
|
- Zwraca plik PDF (Content-Type: application/pdf)
|
||||||
|
- `markComplete(Request $request): Response` — PATCH /api/print/jobs/{id}/complete
|
||||||
|
- Wymaga API key
|
||||||
|
- Oznacza job jako completed
|
||||||
|
- Zwraca JSON: { id, status: 'completed' }
|
||||||
|
|
||||||
|
**PrintSettingsController** (src/Modules/Settings/):
|
||||||
|
- `index(Request $request): Response` — lista kluczy API (widok settings/printing)
|
||||||
|
- `createKey(Request $request): Response` — POST: generuje klucz (bin2hex(random_bytes(32))), hashuje SHA-256, zapisuje hash+prefix, zwraca klucz jednorazowo we flash message
|
||||||
|
- `deleteKey(Request $request): Response` — POST: usuwa klucz
|
||||||
|
|
||||||
|
**Routes** (routes/web.php):
|
||||||
|
Dodać na końcu pliku:
|
||||||
|
```php
|
||||||
|
// Print API — session auth (from orderPRO UI)
|
||||||
|
$router->post('/api/print/jobs', [$printApiController, 'createJob'], [$authMiddleware]);
|
||||||
|
|
||||||
|
// Print API — API key auth (from Windows client)
|
||||||
|
$router->get('/api/print/jobs/pending', [$printApiController, 'listPending'], [$apiKeyMiddleware]);
|
||||||
|
$router->get('/api/print/jobs/{id}/download', [$printApiController, 'downloadLabel'], [$apiKeyMiddleware]);
|
||||||
|
$router->post('/api/print/jobs/{id}/complete', [$printApiController, 'markComplete'], [$apiKeyMiddleware]);
|
||||||
|
|
||||||
|
// Print settings
|
||||||
|
$router->get('/settings/printing', [$printSettingsController, 'index'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/printing/keys/create', [$printSettingsController, 'createKey'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/printing/keys/{id}/delete', [$printSettingsController, 'deleteKey'], [$authMiddleware]);
|
||||||
|
```
|
||||||
|
|
||||||
|
DI: Zarejestrować nowe klasy w Application.php (lub tam gdzie inne moduły są rejestrowane).
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- Nie używać PATCH (router może nie obsługiwać) — użyć POST dla markComplete
|
||||||
|
- Nie sklejać SQL — tylko prepared statements
|
||||||
|
- Nie przechowywać klucza API w plaintext — tylko hash SHA-256
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
1. Sprawdzić czy pliki PHP nie mają błędów składni: php -l na każdym pliku
|
||||||
|
2. Sprawdzić czy routes/web.php parsuje się poprawnie
|
||||||
|
</verify>
|
||||||
|
<done>AC-2 partially (backend), AC-3, AC-4, AC-5, AC-6 satisfied: API endpoints działają z prawidłowym auth</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
Backend do zdalnego drukowania:
|
||||||
|
- Tabele DB: print_jobs, print_api_keys
|
||||||
|
- REST API: 4 endpointy (create job, list pending, download, complete)
|
||||||
|
- API key middleware (X-Api-Key header)
|
||||||
|
- CRUD kluczy API w ustawieniach (backend only — widok w fazie 19)
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Uruchom aplikację — sprawdź czy migracja przeszła (brak błędów)
|
||||||
|
2. Otwórz /settings/printing — powinien załadować się widok (nawet pusty)
|
||||||
|
3. Opcjonalnie: testuj API curlem
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
|
||||||
|
## DO NOT CHANGE
|
||||||
|
- src/Modules/Shipments/* (nie modyfikować istniejącego flow etykiet)
|
||||||
|
- src/Modules/Auth/AuthMiddleware.php (nie zmieniać session auth)
|
||||||
|
- src/Core/Routing/Router.php (nie modyfikować routera)
|
||||||
|
- database/migrations/ istniejące pliki (nie zmieniać)
|
||||||
|
|
||||||
|
## SCOPE LIMITS
|
||||||
|
- Tylko backend — brak widoków HTML (oprócz minimalnego dla settings/printing)
|
||||||
|
- Brak integracji z przyciskiem "Drukuj" w widoku przesyłek (faza 19)
|
||||||
|
- Brak aplikacji Windows (faza 20)
|
||||||
|
- Widok settings/printing: minimalna lista kluczy + formularz tworzenia (pełny design w fazie 19)
|
||||||
|
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Before declaring plan complete:
|
||||||
|
- [ ] Migracja SQL wykonuje się bez błędów
|
||||||
|
- [ ] php -l bez błędów na wszystkich nowych plikach
|
||||||
|
- [ ] GET /api/print/jobs/pending bez klucza → 401
|
||||||
|
- [ ] POST /settings/printing/keys/create tworzy klucz
|
||||||
|
- [ ] Routing nie psuje istniejących endpointów
|
||||||
|
- [ ] All acceptance criteria met
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All tasks completed
|
||||||
|
- All verification checks pass
|
||||||
|
- No errors or warnings introduced
|
||||||
|
- API key auth działa (valid key → 200, invalid → 401)
|
||||||
|
- Print jobs CRUD przez API
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.paul/phases/18-print-queue-backend/18-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
138
.paul/phases/18-print-queue-backend/18-01-SUMMARY.md
Normal file
138
.paul/phases/18-print-queue-backend/18-01-SUMMARY.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
phase: 18-print-queue-backend
|
||||||
|
plan: 01
|
||||||
|
subsystem: api
|
||||||
|
tags: [printing, api-key, rest-api, middleware, remote-printing]
|
||||||
|
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- Print jobs REST API (create, list pending, download, complete)
|
||||||
|
- API key authentication middleware (X-Api-Key header)
|
||||||
|
- Print settings UI (CRUD kluczy API)
|
||||||
|
- Request::header() method
|
||||||
|
affects: [19-ui-integration, 20-windows-client]
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns: [api-key-auth, sha256-hashing, rest-json-api]
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- database/migrations/20260322_000058_create_print_tables.sql
|
||||||
|
- src/Modules/Printing/PrintApiKeyRepository.php
|
||||||
|
- src/Modules/Printing/PrintJobRepository.php
|
||||||
|
- src/Modules/Printing/PrintApiController.php
|
||||||
|
- src/Modules/Printing/ApiKeyMiddleware.php
|
||||||
|
- src/Modules/Settings/PrintSettingsController.php
|
||||||
|
- resources/views/settings/printing.php
|
||||||
|
modified:
|
||||||
|
- src/Core/Http/Request.php
|
||||||
|
- resources/views/layouts/app.php
|
||||||
|
- routes/web.php
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "FK constraints removed from print_jobs — type mismatch between orders (INT UNSIGNED) and shipment_packages (BIGINT UNSIGNED)"
|
||||||
|
- "API key stored as SHA-256 hash, prefix saved for identification"
|
||||||
|
- "Request::header() method added to core Request class for API key extraction"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "API key auth: ApiKeyMiddleware reads X-Api-Key, hashes SHA-256, validates against print_api_keys"
|
||||||
|
- "Flash key convention: settings_error, settings_success, settings_new_api_key (underscore, not dot)"
|
||||||
|
|
||||||
|
duration: 25min
|
||||||
|
started: 2026-03-22T00:00:00Z
|
||||||
|
completed: 2026-03-22T00:25:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 18 Plan 01: Print Queue Backend Summary
|
||||||
|
|
||||||
|
**REST API do zdalnego drukowania etykiet — tabele DB, API key auth middleware, 4 endpointy JSON, CRUD kluczy API w ustawieniach.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Duration | ~25min |
|
||||||
|
| Tasks | 3 completed (2 auto + 1 checkpoint) |
|
||||||
|
| Files created | 7 |
|
||||||
|
| Files modified | 3 |
|
||||||
|
|
||||||
|
## Acceptance Criteria Results
|
||||||
|
|
||||||
|
| Criterion | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| AC-1: Tabele DB utworzone poprawnie | Pass | print_api_keys + print_jobs, bez FK (type mismatch) |
|
||||||
|
| AC-2: CRUD kluczy API w ustawieniach | Pass | Tworzenie, wyświetlanie, usuwanie kluczy |
|
||||||
|
| AC-3: API — tworzenie zlecenia wydruku | Pass | POST /api/print/jobs z session auth |
|
||||||
|
| AC-4: API — pobieranie zleceń i etykiet | Pass | GET /api/print/jobs/pending + download |
|
||||||
|
| AC-5: API — oznaczanie jako wydrukowane | Pass | POST /api/print/jobs/{id}/complete |
|
||||||
|
| AC-6: Nieprawidłowy klucz API odrzucony | Pass | 401 Unauthorized z JSON |
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Moduł Printing: PrintApiKeyRepository, PrintJobRepository, PrintApiController, ApiKeyMiddleware
|
||||||
|
- REST API: 4 endpointy (create job, list pending, download label, mark complete)
|
||||||
|
- API key auth: SHA-256 hash, prefix do identyfikacji, last_used_at tracking
|
||||||
|
- Settings UI: lista kluczy + jednorazowe wyświetlenie nowego klucza
|
||||||
|
- Request::header() method w core — reusable dla przyszłych API
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
| File | Change | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `database/migrations/20260322_000058_create_print_tables.sql` | Created | Tabele print_api_keys + print_jobs |
|
||||||
|
| `src/Modules/Printing/PrintApiKeyRepository.php` | Created | CRUD kluczy API |
|
||||||
|
| `src/Modules/Printing/PrintJobRepository.php` | Created | CRUD zleceń wydruku |
|
||||||
|
| `src/Modules/Printing/PrintApiController.php` | Created | REST API endpointy |
|
||||||
|
| `src/Modules/Printing/ApiKeyMiddleware.php` | Created | Auth middleware X-Api-Key |
|
||||||
|
| `src/Modules/Settings/PrintSettingsController.php` | Created | UI zarządzania kluczami |
|
||||||
|
| `resources/views/settings/printing.php` | Created | Widok ustawień drukowania |
|
||||||
|
| `src/Core/Http/Request.php` | Modified | Dodano header() method |
|
||||||
|
| `resources/views/layouts/app.php` | Modified | Link "Drukowanie" w sidebar |
|
||||||
|
| `routes/web.php` | Modified | 7 nowych route'ów + DI |
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
| Decision | Rationale | Impact |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| Usunięto FK constraints z print_jobs | orders.id (INT UNSIGNED) vs shipment_packages.order_id (BIGINT UNSIGNED) — type mismatch w produkcji | Integralność referencyjna tylko na poziomie aplikacji |
|
||||||
|
| SHA-256 hash klucza API | Bezpieczeństwo — raw key nigdy nie przechowywany w DB | Klucz wyświetlany jednorazowo po utworzeniu |
|
||||||
|
| Request::header() w core | Potrzebne do X-Api-Key, reusable | Minimalna zmiana core — 1 metoda |
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
| Type | Count | Impact |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Auto-fixed | 1 | FK constraints usunięte — brak wpływu na funkcjonalność |
|
||||||
|
|
||||||
|
**1. FK constraints usunięte z migracji**
|
||||||
|
- **Found during:** Task 1 (migracja)
|
||||||
|
- **Issue:** Type mismatch: orders.id = INT UNSIGNED, shipment_packages.order_id = BIGINT UNSIGNED
|
||||||
|
- **Fix:** Usunięto CONSTRAINT, zostawiono INDEX
|
||||||
|
- **Verification:** Migracja przeszła poprawnie
|
||||||
|
|
||||||
|
## Issues Encountered
|
||||||
|
|
||||||
|
| Issue | Resolution |
|
||||||
|
|-------|------------|
|
||||||
|
| FK errno 150 (1. próba) | Zmieniono BIGINT→INT UNSIGNED + INT→BIGINT UNSIGNED |
|
||||||
|
| FK errno 150 (2. próba) | Usunięto FK constraints całkowicie — type mismatch w produkcji |
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
**Ready:**
|
||||||
|
- API endpoints gotowe do konsumpcji przez Windows Client (faza 20)
|
||||||
|
- API key auth działa — klient może się uwierzytelnić
|
||||||
|
- Przycisk "Drukuj" w UI (faza 19) może wywoływać POST /api/print/jobs
|
||||||
|
|
||||||
|
**Concerns:**
|
||||||
|
- Brak FK constraints — integralność tylko na poziomie aplikacji
|
||||||
|
|
||||||
|
**Blockers:**
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 18-print-queue-backend, Plan: 01*
|
||||||
|
*Completed: 2026-03-22*
|
||||||
261
.paul/phases/19-ui-integration/19-01-PLAN.md
Normal file
261
.paul/phases/19-ui-integration/19-01-PLAN.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
---
|
||||||
|
phase: 19-ui-integration
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: ["18-01"]
|
||||||
|
files_modified:
|
||||||
|
- resources/views/shipments/prepare.php
|
||||||
|
- resources/views/orders/index.php
|
||||||
|
- src/Modules/Printing/PrintApiController.php
|
||||||
|
- src/Modules/Printing/PrintJobRepository.php
|
||||||
|
- resources/views/settings/printing.php
|
||||||
|
- routes/web.php
|
||||||
|
- resources/scss/_printing.scss
|
||||||
|
autonomous: false
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
## Goal
|
||||||
|
Dodać przycisk "Drukuj" w widoku przesyłki (prepare.php) oraz zbiorczą akcję "Drukuj etykiety" z listy zamówień — obie wywołujące POST /api/print/jobs. Dodać podgląd kolejki wydruku w ustawieniach drukowania ze statusami zleceń.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Użytkownik może jednym kliknięciem wysłać etykietę do zdalnej drukarki (Windows Client z fazy 20) bez ręcznego pobierania i drukowania. Zbiorcze drukowanie oszczędza czas przy wielu zamówieniach.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
- Przycisk "Drukuj" obok "Pobierz" w prepare.php z AJAX feedback
|
||||||
|
- Zbiorcza akcja "Drukuj etykiety" na liście zamówień (zaznaczone checkboxy)
|
||||||
|
- Sekcja "Kolejka wydruku" w ustawieniach drukowania z historią zleceń
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
## Project Context
|
||||||
|
@.paul/PROJECT.md
|
||||||
|
@.paul/ROADMAP.md
|
||||||
|
@.paul/STATE.md
|
||||||
|
|
||||||
|
## Prior Work
|
||||||
|
@.paul/phases/18-print-queue-backend/18-01-SUMMARY.md
|
||||||
|
- Phase 18 created: PrintApiController (createJob, listPending, downloadLabel, markComplete)
|
||||||
|
- API key auth middleware for Windows client
|
||||||
|
- print_jobs table (order_id, package_id, label_path, status, created_by)
|
||||||
|
- createJob() expects package_id, validates label exists
|
||||||
|
|
||||||
|
## Source Files
|
||||||
|
@resources/views/shipments/prepare.php — "Pobierz" button at line ~109
|
||||||
|
@resources/views/orders/index.php — lista zamówień z checkboxami
|
||||||
|
@src/Modules/Printing/PrintApiController.php — existing REST endpoints
|
||||||
|
@src/Modules/Printing/PrintJobRepository.php — DB operations
|
||||||
|
@resources/views/settings/printing.php — API key management
|
||||||
|
@routes/web.php — route registration
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<skills>
|
||||||
|
## Required Skills (from SPECIAL-FLOWS.md)
|
||||||
|
|
||||||
|
| Skill | Priority | When to Invoke | Loaded? |
|
||||||
|
|-------|----------|----------------|---------|
|
||||||
|
| sonar-scanner | required | Po APPLY, przed UNIFY | ○ |
|
||||||
|
|
||||||
|
## Skill Invocation Checklist
|
||||||
|
- [ ] sonar-scanner loaded (run before UNIFY)
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
|
||||||
|
## AC-1: Przycisk "Drukuj" w widoku przesyłki
|
||||||
|
```gherkin
|
||||||
|
Given przesyłka ma wygenerowaną etykietę (label_path istnieje)
|
||||||
|
When użytkownik klika "Drukuj" obok przycisku "Pobierz"
|
||||||
|
Then AJAX POST /api/print/jobs z package_id
|
||||||
|
And przycisk zmienia się na "Wysłano ✓" z krótkim feedbackiem
|
||||||
|
And w razie błędu wyświetla OrderProAlerts.alert z komunikatem
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-2: Zbiorcze drukowanie z listy zamówień
|
||||||
|
```gherkin
|
||||||
|
Given użytkownik zaznaczył ≥1 zamówienie z etykietą na liście zamówień
|
||||||
|
When klika akcję "Drukuj etykiety" (bulk action)
|
||||||
|
Then dla każdego zaznaczonego zamówienia tworzony jest print job
|
||||||
|
And wyświetla podsumowanie: "Wysłano N zleceń do drukarki"
|
||||||
|
And zamówienia bez etykiety są pomijane z informacją
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-3: Kolejka wydruku w ustawieniach
|
||||||
|
```gherkin
|
||||||
|
Given użytkownik otwiera Ustawienia > Drukowanie
|
||||||
|
When strona się ładuje
|
||||||
|
Then widzi sekcję "Kolejka wydruku" pod sekcją "Klucze API"
|
||||||
|
And lista pokazuje ostatnie zlecenia: data, zamówienie, status (pending/completed/failed)
|
||||||
|
And może filtrować po statusie
|
||||||
|
```
|
||||||
|
|
||||||
|
## AC-4: Endpoint bulk create
|
||||||
|
```gherkin
|
||||||
|
Given request POST /api/print/jobs/bulk z tablicą package_ids
|
||||||
|
When co najmniej 1 package_id ma etykietę
|
||||||
|
Then tworzy print jobs dla wszystkich valid packages
|
||||||
|
And zwraca JSON z listą created + skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Przycisk "Drukuj" w prepare.php + bulk endpoint</name>
|
||||||
|
<files>
|
||||||
|
resources/views/shipments/prepare.php,
|
||||||
|
src/Modules/Printing/PrintApiController.php,
|
||||||
|
src/Modules/Printing/PrintJobRepository.php,
|
||||||
|
routes/web.php
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. W prepare.php obok przycisku "Pobierz" (form POST label) dodaj przycisk "Drukuj":
|
||||||
|
- Button z klasą `btn btn-sm btn-outline-primary btn-print-label`
|
||||||
|
- data-package-id="{$pkg['id']}" data-order-id="{$orderId}"
|
||||||
|
- Ikona drukarki (fa-print lub SVG)
|
||||||
|
- Widoczny tylko gdy $pkgLabelPath istnieje (ten sam warunek co "Pobierz")
|
||||||
|
|
||||||
|
2. JavaScript AJAX handler na dole prepare.php (lub w osobnym module):
|
||||||
|
- Click `.btn-print-label` → POST /api/print/jobs z {package_id, _token}
|
||||||
|
- Success → zmień tekst na "Wysłano ✓", disable button na 3s
|
||||||
|
- Error → OrderProAlerts.alert('Błąd: ' + response.error)
|
||||||
|
- Duplikat (job already pending) → OrderProAlerts.alert('Zlecenie już w kolejce')
|
||||||
|
|
||||||
|
3. W PrintApiController dodaj metodę bulkCreateJobs():
|
||||||
|
- Przyjmuje JSON {package_ids: [1,2,3], _token}
|
||||||
|
- Waliduje CSRF
|
||||||
|
- Dla każdego package_id: sprawdź label_path, utwórz job lub skip
|
||||||
|
- Zwraca {created: [{id, package_id}], skipped: [{package_id, reason}]}
|
||||||
|
|
||||||
|
4. W PrintJobRepository dodaj findPendingByPackageId(int $packageId):
|
||||||
|
- Sprawdza czy istnieje pending job dla danego package
|
||||||
|
- Używane do ochrony przed duplikatami
|
||||||
|
|
||||||
|
5. W routes/web.php dodaj:
|
||||||
|
- POST /api/print/jobs/bulk → PrintApiController::bulkCreateJobs (session auth)
|
||||||
|
|
||||||
|
Avoid: Nie zmieniaj istniejących endpointów API key auth (GET /api/print/jobs/pending itd.)
|
||||||
|
Avoid: Nie dodawaj natywnych alert() — używaj OrderProAlerts
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- Przycisk "Drukuj" widoczny obok "Pobierz" w prepare.php
|
||||||
|
- AJAX POST tworzy rekord w print_jobs
|
||||||
|
- Bulk endpoint zwraca poprawny JSON
|
||||||
|
- Duplikat nie tworzy drugiego pending job
|
||||||
|
</verify>
|
||||||
|
<done>AC-1 satisfied (przycisk + AJAX), AC-4 satisfied (bulk endpoint)</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Zbiorcze drukowanie z listy zamówień + kolejka w ustawieniach</name>
|
||||||
|
<files>
|
||||||
|
resources/views/orders/index.php,
|
||||||
|
resources/views/settings/printing.php,
|
||||||
|
src/Modules/Settings/PrintSettingsController.php,
|
||||||
|
src/Modules/Printing/PrintJobRepository.php,
|
||||||
|
routes/web.php,
|
||||||
|
resources/scss/_printing.scss
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. W orders/index.php dodaj bulk action "Drukuj etykiety":
|
||||||
|
- Dodaj opcję w dropdown/select akcji zbiorczych (wzoruj się na istniejących bulk actions)
|
||||||
|
- JavaScript: zbierz package_ids z zaznaczonych zamówień
|
||||||
|
- POST /api/print/jobs/bulk z zebraną tablicą
|
||||||
|
- Pokaż wynik: "Wysłano {N} zleceń. Pominięto {M} (brak etykiety)."
|
||||||
|
- Użyj OrderProAlerts.alert() dla wyniku
|
||||||
|
|
||||||
|
2. W PrintJobRepository dodaj getRecentJobs(int $limit = 50, ?string $statusFilter = null):
|
||||||
|
- SELECT z JOIN na orders (numer zamówienia) i shipment_packages (tracking)
|
||||||
|
- Sortowanie: created_at DESC
|
||||||
|
- Opcjonalny filtr statusu
|
||||||
|
|
||||||
|
3. W PrintSettingsController dodaj metodę index() rozszerzoną o dane kolejki:
|
||||||
|
- Pobierz listę ostatnich print jobs (getRecentJobs)
|
||||||
|
- Przekaż do widoku
|
||||||
|
|
||||||
|
4. W settings/printing.php pod sekcją "Klucze API" dodaj sekcję "Kolejka wydruku":
|
||||||
|
- Tabela: Data | Zamówienie | Tracking | Status | Akcje
|
||||||
|
- Status badges: pending (żółty), completed (zielony), failed (czerwony)
|
||||||
|
- Filtr statusu (select/buttons nad tabelą)
|
||||||
|
- Paginacja jeśli >20 rekordów (lub scroll)
|
||||||
|
- Przycisk "Ponów" przy failed jobs (POST /api/print/jobs z tym samym package_id)
|
||||||
|
|
||||||
|
5. Dodaj _printing.scss z stylami:
|
||||||
|
- .print-status-badge (pending/completed/failed kolory)
|
||||||
|
- .print-queue-table (kompaktowy layout)
|
||||||
|
- Zaimportuj w głównym pliku SCSS
|
||||||
|
|
||||||
|
Avoid: Nie modyfikuj sekcji API keys w printing.php — dodaj nową sekcję pod spodem
|
||||||
|
Avoid: Nie dodawaj osobnej strony — kolejka jest częścią ustawień drukowania
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
- Bulk action widoczna na liście zamówień
|
||||||
|
- Zaznaczenie + klik → zlecenia w print_jobs
|
||||||
|
- Kolejka wydruku widoczna w ustawieniach
|
||||||
|
- Filtrowanie po statusie działa
|
||||||
|
- Style badge'ów poprawne
|
||||||
|
</verify>
|
||||||
|
<done>AC-2 satisfied (bulk z listy), AC-3 satisfied (kolejka w ustawieniach)</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
Przycisk "Drukuj" w widoku przesyłki, zbiorcze drukowanie z listy zamówień,
|
||||||
|
kolejka wydruku w ustawieniach drukowania.
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Otwórz zamówienie z wygenerowaną etykietą → widok przesyłki
|
||||||
|
2. Sprawdź: obok "Pobierz" jest przycisk "Drukuj"
|
||||||
|
3. Kliknij "Drukuj" → powinno pojawić się "Wysłano ✓"
|
||||||
|
4. Kliknij ponownie → powinien pojawić się alert "Zlecenie już w kolejce"
|
||||||
|
5. Wróć do listy zamówień → zaznacz kilka z etykietami
|
||||||
|
6. Wybierz akcję "Drukuj etykiety" → podsumowanie ile wysłano/pominięto
|
||||||
|
7. Otwórz Ustawienia > Drukowanie → sprawdź sekcję "Kolejka wydruku"
|
||||||
|
8. Sprawdź: widać zlecenia z kroków 3 i 6 ze statusem "pending"
|
||||||
|
9. Sprawdź filtrowanie po statusie
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" to continue, or describe issues to fix</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<boundaries>
|
||||||
|
|
||||||
|
## DO NOT CHANGE
|
||||||
|
- database/migrations/* (schemat z fazy 18 jest stabilny)
|
||||||
|
- src/Modules/Printing/ApiKeyMiddleware.php (auth middleware gotowe)
|
||||||
|
- Endpointy API key auth (GET /api/print/jobs/pending, download, complete) — te są dla Windows Client
|
||||||
|
|
||||||
|
## SCOPE LIMITS
|
||||||
|
- Nie buduj Windows Client (to faza 20)
|
||||||
|
- Nie dodawaj WebSocket/polling do auto-odświeżania statusów — wystarczy ręczne odświeżenie
|
||||||
|
- Nie zmieniaj flow pobierania etykiet ("Pobierz" pozostaje bez zmian)
|
||||||
|
- Nie dodawaj nowych tabel DB — używaj istniejącej print_jobs
|
||||||
|
|
||||||
|
</boundaries>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Before declaring plan complete:
|
||||||
|
- [ ] Przycisk "Drukuj" widoczny w prepare.php obok "Pobierz"
|
||||||
|
- [ ] AJAX feedback działa (success + error + duplikat)
|
||||||
|
- [ ] Bulk endpoint /api/print/jobs/bulk tworzy zlecenia
|
||||||
|
- [ ] Bulk action w index.php działa z checkboxami
|
||||||
|
- [ ] Kolejka wydruku widoczna w ustawieniach drukowania
|
||||||
|
- [ ] Filtrowanie statusu w kolejce
|
||||||
|
- [ ] Style SCSS skompilowane (brak inline CSS)
|
||||||
|
- [ ] Brak natywnych alert()/confirm() — tylko OrderProAlerts
|
||||||
|
- [ ] Wszystkie acceptance criteria spełnione
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Wszystkie 4 AC spełnione
|
||||||
|
- Brak regresji w istniejącym flow pobierania etykiet
|
||||||
|
- Kod zgodny z konwencjami projektu (camelCase, Medoo, XSS escape)
|
||||||
|
- Brak nowych issues SonarQube na zmienionych plikach
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.paul/phases/19-ui-integration/19-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
142
.paul/phases/19-ui-integration/19-01-SUMMARY.md
Normal file
142
.paul/phases/19-ui-integration/19-01-SUMMARY.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
phase: 19-ui-integration
|
||||||
|
plan: 01
|
||||||
|
subsystem: ui
|
||||||
|
tags: [printing, ajax, bulk-actions, label-queue]
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- phase: 18-print-queue-backend
|
||||||
|
provides: PrintApiController, PrintJobRepository, print_jobs table, API key auth
|
||||||
|
provides:
|
||||||
|
- Przycisk "Drukuj" w widoku przesyłki i szczegółów zamówienia
|
||||||
|
- Bulk print z listy zamówień
|
||||||
|
- Kolejka wydruku w Ustawienia > Drukowanie
|
||||||
|
- Endpoint POST /api/print/jobs/bulk
|
||||||
|
affects: [20-windows-client]
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns: [ShipmentProviderRegistry for ensureLabel(), pendingPackageIds for duplicate protection]
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- src/Modules/Printing/PrintApiController.php (bulkCreateJobs, ensureLabel)
|
||||||
|
- src/Modules/Printing/PrintJobRepository.php (findPendingByPackageId, pendingPackageIds)
|
||||||
|
- src/Modules/Settings/PrintSettingsController.php (kolejka wydruku)
|
||||||
|
- resources/views/settings/printing.php (kolejka UI)
|
||||||
|
- resources/scss/modules/_printing.scss
|
||||||
|
- database/migrations/20260322_000058_create_print_tables.sql
|
||||||
|
modified:
|
||||||
|
- resources/views/shipments/prepare.php (przycisk Drukuj, label file check)
|
||||||
|
- resources/views/orders/show.php (przycisk Drukuj, tab persistence, carrier_id column)
|
||||||
|
- resources/views/orders/list.php (bulk print action)
|
||||||
|
- routes/web.php (DI + nowe route'y)
|
||||||
|
- src/Modules/Orders/OrdersController.php (storagePath + printJobRepo injection)
|
||||||
|
- src/Modules/Shipments/ShipmentController.php (redirect po utworzeniu przesyłki)
|
||||||
|
- src/Modules/Shipments/ApaczkaShipmentService.php (carrier_id = service name)
|
||||||
|
- src/Modules/Settings/ApaczkaApiClient.php (fix use statement)
|
||||||
|
- resources/scss/app.scss (_printing import)
|
||||||
|
- public/assets/css/app.css (compiled)
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "ensureLabel() auto-downloads label via ShipmentProviderRegistry before creating print job"
|
||||||
|
- "findPendingByPackageId protects against duplicate pending jobs"
|
||||||
|
- "Apaczka carrier_id stores service name (e.g. 'Orlen Paczka') for display"
|
||||||
|
- "Tab persistence via localStorage in show.php"
|
||||||
|
- "Label file existence check hides Pobierz/Drukuj when file missing on disk"
|
||||||
|
|
||||||
|
patterns-established:
|
||||||
|
- "ShipmentProviderRegistry pattern for multi-provider label operations"
|
||||||
|
- "Bulk endpoint returns {created: [], skipped: []} JSON structure"
|
||||||
|
|
||||||
|
duration: ~4h
|
||||||
|
started: 2026-03-22T10:00:00Z
|
||||||
|
completed: 2026-03-22T22:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 19 Plan 01: UI Integration Summary
|
||||||
|
|
||||||
|
**Przycisk "Drukuj" w widoku przesyłki, bulk print z listy zamówień, kolejka wydruku w ustawieniach — pełna integracja UI z backendem kolejki wydruku z fazy 18.**
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Duration | ~4h (rozłożone na 2 sesje) |
|
||||||
|
| Started | 2026-03-22 |
|
||||||
|
| Completed | 2026-03-22 |
|
||||||
|
| Tasks | 3 (2 auto + 1 checkpoint) |
|
||||||
|
| Files modified | 14 |
|
||||||
|
|
||||||
|
## Acceptance Criteria Results
|
||||||
|
|
||||||
|
| Criterion | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| AC-1: Przycisk "Drukuj" w widoku przesyłki | Pass | Widoczny w prepare.php i show.php, AJAX feedback + duplikat protection |
|
||||||
|
| AC-2: Zbiorcze drukowanie z listy zamówień | Pass | Checkboxy + header action "Drukuj etykiety", bulk endpoint |
|
||||||
|
| AC-3: Kolejka wydruku w ustawieniach | Pass | Tabela zleceń z filtrami statusu, retry dla failed |
|
||||||
|
| AC-4: Endpoint bulk create | Pass | POST /api/print/jobs/bulk z {package_ids/order_ids} |
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- Przycisk "Drukuj" obok "Pobierz" z AJAX feedback i ochroną przed duplikatami
|
||||||
|
- Bulk print z listy zamówień (checkboxy + nagłówkowa akcja)
|
||||||
|
- Kolejka wydruku w Ustawienia > Drukowanie z filtrami i retry
|
||||||
|
- Auto-download etykiety (ensureLabel) przez ShipmentProviderRegistry
|
||||||
|
- Stan "W kolejce" (disabled, czerwony) gdy pending job istnieje
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
| Type | Count | Impact |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Scope additions | 6 | Usprawnienia UX wykryte podczas testów |
|
||||||
|
| Auto-fixed | 1 | Bug fix ApaczkaApiClient |
|
||||||
|
| Deferred | 0 | — |
|
||||||
|
|
||||||
|
**Total impact:** Dodatkowe usprawnienia UX poza planem, zero regresji.
|
||||||
|
|
||||||
|
### Scope Additions (poza planem)
|
||||||
|
|
||||||
|
1. **show.php tab persistence** — localStorage zapamiętuje aktywny tab
|
||||||
|
2. **show.php label file check** — ukrywa Pobierz/Drukuj gdy plik nie istnieje na dysku
|
||||||
|
3. **show.php przycisk Drukuj** — plan dotyczył tylko prepare.php, dodano też w show.php
|
||||||
|
4. **Kolumna Przewoznik** — "Apaczka → Orlen Paczka" zamiast pustego carrier_id
|
||||||
|
5. **Redirect po utworzeniu przesyłki** — ShipmentController redirect → /orders/{id} z flash
|
||||||
|
6. **Sekcja przesyłek przeniesiona** — pod formularz nowej przesyłki w prepare.php
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. ApaczkaApiClient use statement bug**
|
||||||
|
- **Found during:** Task 1
|
||||||
|
- **Issue:** Brak backslashy w use statement
|
||||||
|
- **Fix:** Poprawiony import namespace
|
||||||
|
- **Files:** src/Modules/Settings/ApaczkaApiClient.php
|
||||||
|
|
||||||
|
## SonarQube Scan
|
||||||
|
|
||||||
|
**Scan date:** 2026-03-22
|
||||||
|
**Result:** 0 new blocker/critical issues on phase 19 files
|
||||||
|
**New issues (minor/major):**
|
||||||
|
- show.php:696 — S4833 require → use, S2003 require → require_once
|
||||||
|
- OrdersController — S1448 (22 methods > 20), S1142 (bulkPrint 5 returns)
|
||||||
|
|
||||||
|
All logged to DOCS/todo.md.
|
||||||
|
|
||||||
|
## Next Phase Readiness
|
||||||
|
|
||||||
|
**Ready:**
|
||||||
|
- Pełne UI do zarządzania kolejką wydruku
|
||||||
|
- API endpoints gotowe dla Windows Client (faza 20)
|
||||||
|
- print_jobs table z pełnym lifecycle (pending → completed/failed)
|
||||||
|
|
||||||
|
**Concerns:**
|
||||||
|
- OrdersController rośnie (22 metody) — rozważyć wydzielenie PrintController w przyszłości
|
||||||
|
|
||||||
|
**Blockers:**
|
||||||
|
- None
|
||||||
|
|
||||||
|
---
|
||||||
|
*Phase: 19-ui-integration, Plan: 01*
|
||||||
|
*Completed: 2026-03-22*
|
||||||
14
DOCS/todo.md
14
DOCS/todo.md
@@ -1,7 +1,4 @@
|
|||||||
12. [] synchronizowa<EFBFBD> r<>czn<7A> zmian<61> statusu z allegro
|
15. [] W tym miejscu odwróć kolejność: najpierw źródło potem ID, <div class="orders-ref__meta"><span>f6079660-1af8-11f1-a7c9-231cf6ef29d1</span><span>allegro</span></div>
|
||||||
14. [] border input<75>w, select, textarea, itd zr<7A>b troszk<7A> ciemniejszy
|
|
||||||
15. [] W tym miejscu odwróć kolejność: najpierw źródło potem ID, <div class="orders-ref__meta"><span>f6079660-1af8-11f1-a7c9-231cf6ef29d1</span><span>allegro</span></div>
|
|
||||||
16. [] Na liście zamówień statusy powinno być pokolorowane zgodnie z ustawieniami.
|
|
||||||
17. [] Na liście zamówien jak jest źródło i id zamówienia to zamiast shopPRO musi pisać która integracja konkretnie. Oraz dodajemy napis ID: ...D
|
17. [] Na liście zamówien jak jest źródło i id zamówienia to zamiast shopPRO musi pisać która integracja konkretnie. Oraz dodajemy napis ID: ...D
|
||||||
|
|
||||||
## SonarQube — post plany 02-02 i 02-03 (skan 2026-03-13)
|
## SonarQube — post plany 02-02 i 02-03 (skan 2026-03-13)
|
||||||
@@ -24,4 +21,11 @@
|
|||||||
27. [] php:S138 (4x) — skrócić zbyt długie metody
|
27. [] php:S138 (4x) — skrócić zbyt długie metody
|
||||||
|
|
||||||
|
|
||||||
31. [] blokować wystawienie kolejnego paragonu do tego samego zamówienia
|
31. [x] blokować wystawienie kolejnego paragonu do tego samego zamówienia, nie całkowicie, ale powinno ono wymagać wyrażnego potwierdzenia w formie alertu
|
||||||
|
|
||||||
|
## SonarQube — post plan 19-01 (skan 2026-03-22)
|
||||||
|
32. [] [Sonar 2026-03-22] php:S4833 — show.php:696 Replace `require` with namespace import `use` keyword (1x)
|
||||||
|
33. [] [Sonar 2026-03-22] php:S2003 — show.php:696 Replace `require` with `require_once` (1x)
|
||||||
|
34. [] [Sonar 2026-03-22] php:S1448 — OrdersController ma 22 metody (limit 20) — rozdzielić (1x)
|
||||||
|
35. [] [Sonar 2026-03-22] php:S1142 — OrdersController::bulkPrint() ma 5 returnów (limit 3) (1x)
|
||||||
|
36. [] [Sonar 2026-03-22] php:S1068 — AllegroIntegrationController nieużywane pole $statusDiscoveryService (1x, pre-existing)
|
||||||
28
database/migrations/20260322_000058_create_print_tables.sql
Normal file
28
database/migrations/20260322_000058_create_print_tables.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Print API keys for remote printing authentication
|
||||||
|
CREATE TABLE IF NOT EXISTS print_api_keys (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(128) NOT NULL,
|
||||||
|
key_hash VARCHAR(128) NOT NULL,
|
||||||
|
key_prefix VARCHAR(8) NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
last_used_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE INDEX print_api_keys_hash_unique (key_hash),
|
||||||
|
INDEX print_api_keys_active_idx (is_active)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Print jobs queue for remote label printing
|
||||||
|
-- No FK constraints: order_id/package_id types may vary across environments
|
||||||
|
CREATE TABLE IF NOT EXISTS print_jobs (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
order_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
package_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
label_path VARCHAR(255) NOT NULL,
|
||||||
|
status ENUM('pending', 'printing', 'completed', 'failed') NOT NULL DEFAULT 'pending',
|
||||||
|
created_by INT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME NULL,
|
||||||
|
INDEX print_jobs_status_idx (status),
|
||||||
|
INDEX print_jobs_order_idx (order_id),
|
||||||
|
INDEX print_jobs_package_idx (package_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -441,6 +441,60 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.print-status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.print-status-badge--pending {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
.print-status-badge--completed {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
.print-status-badge--failed {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-queue-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-queue-table td, .print-queue-table th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--outline-primary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #4a90d9;
|
||||||
|
color: #4a90d9;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn--outline-primary:hover {
|
||||||
|
background-color: #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn--outline-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn--outline-primary.is-success {
|
||||||
|
border-color: #28a745;
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@use "shared/ui-components";
|
@use "shared/ui-components";
|
||||||
@use "modules/email-send";
|
@use "modules/email-send";
|
||||||
@use "modules/automation";
|
@use "modules/automation";
|
||||||
|
@use "modules/printing";
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
61
resources/scss/modules/_printing.scss
Normal file
61
resources/scss/modules/_printing.scss
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
.print-status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--completed {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failed {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-queue-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-queue-table {
|
||||||
|
td, th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--outline-primary {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #4a90d9;
|
||||||
|
color: #4a90d9;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-success {
|
||||||
|
border-color: #28a745;
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,9 @@
|
|||||||
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'automation' ? ' is-active' : '' ?>" href="/settings/automation">
|
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'automation' ? ' is-active' : '' ?>" href="/settings/automation">
|
||||||
Zadania automatyczne
|
Zadania automatyczne
|
||||||
</a>
|
</a>
|
||||||
|
<a class="sidebar__sublink<?= $currentMenu === 'settings' && $currentSettings === 'printing' ? ' is-active' : '' ?>" href="/settings/printing">
|
||||||
|
Drukowanie
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -50,6 +50,54 @@
|
|||||||
popup.style.left = left + 'px';
|
popup.style.left = left + 'px';
|
||||||
popup.style.top = top + 'px';
|
popup.style.top = top + 'px';
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
// Bulk print labels
|
||||||
|
var bulkPrintBtn = document.querySelector('.js-bulk-print-labels');
|
||||||
|
if (bulkPrintBtn) {
|
||||||
|
bulkPrintBtn.addEventListener('click', function () {
|
||||||
|
var checked = document.querySelectorAll('.js-table-select-item:checked');
|
||||||
|
if (checked.length === 0) {
|
||||||
|
if (window.OrderProAlerts) {
|
||||||
|
window.OrderProAlerts.show({ message: 'Zaznacz co najmniej jedno zamowienie.', type: 'warning' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderIds = [];
|
||||||
|
checked.forEach(function (cb) { orderIds.push(cb.value); });
|
||||||
|
var csrf = bulkPrintBtn.getAttribute('data-csrf') || '';
|
||||||
|
|
||||||
|
bulkPrintBtn.disabled = true;
|
||||||
|
bulkPrintBtn.textContent = 'Wysylam...';
|
||||||
|
|
||||||
|
fetch('/api/print/jobs/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ order_ids: orderIds, _token: csrf })
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var created = (data.created || []).length;
|
||||||
|
var skipped = (data.skipped || []).length;
|
||||||
|
var msg = 'Wyslano ' + created + ' zlecen do drukarki.';
|
||||||
|
if (skipped > 0) {
|
||||||
|
msg += ' Pominieto ' + skipped + ' (brak etykiety lub juz w kolejce).';
|
||||||
|
}
|
||||||
|
if (window.OrderProAlerts) {
|
||||||
|
window.OrderProAlerts.show({ message: msg, type: 'success' });
|
||||||
|
}
|
||||||
|
bulkPrintBtn.disabled = false;
|
||||||
|
bulkPrintBtn.textContent = 'Drukuj etykiety';
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (window.OrderProAlerts) {
|
||||||
|
window.OrderProAlerts.show({ message: 'Blad sieci — sprobuj ponownie.', type: 'error' });
|
||||||
|
}
|
||||||
|
bulkPrintBtn.disabled = false;
|
||||||
|
bulkPrintBtn.textContent = 'Drukuj etykiety';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ $configsList = is_array($configs ?? null) ? $configs : [];
|
|||||||
$sellerData = is_array($seller ?? null) ? $seller : [];
|
$sellerData = is_array($seller ?? null) ? $seller : [];
|
||||||
$totalGrossVal = (float) ($totalGross ?? 0);
|
$totalGrossVal = (float) ($totalGross ?? 0);
|
||||||
$orderIdVal = (int) ($orderId ?? 0);
|
$orderIdVal = (int) ($orderId ?? 0);
|
||||||
|
$existingReceiptsList = is_array($existingReceipts ?? null) ? $existingReceipts : [];
|
||||||
|
$hasExistingReceipts = $existingReceiptsList !== [];
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -18,7 +20,23 @@ $orderIdVal = (int) ($orderId ?? 0);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/orders/<?= $e((string) $orderIdVal) ?>/receipt/store" class="mt-16">
|
<?php if ($hasExistingReceipts): ?>
|
||||||
|
<div class="alert alert--warning mt-12">
|
||||||
|
<strong>Uwaga!</strong> Do tego zamówienia wystawiono już <?= $e((string) count($existingReceiptsList)) ?> paragon(ów):
|
||||||
|
<ul class="mt-4">
|
||||||
|
<?php foreach ($existingReceiptsList as $er): ?>
|
||||||
|
<li>
|
||||||
|
<strong><?= $e((string) ($er['receipt_number'] ?? '-')) ?></strong>
|
||||||
|
— data: <?= $e((string) ($er['issue_date'] ?? '-')) ?>,
|
||||||
|
kwota: <?= $e(number_format((float) ($er['total_gross'] ?? 0), 2, '.', ' ')) ?> PLN
|
||||||
|
(<?= $e((string) ($er['config_name'] ?? '-')) ?>)
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form id="receipt-create-form" method="post" action="/orders/<?= $e((string) $orderIdVal) ?>/receipt/store" class="mt-16">
|
||||||
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
||||||
|
|
||||||
<div class="form-grid-2">
|
<div class="form-grid-2">
|
||||||
@@ -97,8 +115,25 @@ $orderIdVal = (int) ($orderId ?? 0);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-16">
|
<div class="mt-16">
|
||||||
<button type="submit" class="btn btn--primary"><?= $e($t('receipts.create.submit')) ?></button>
|
<?php if ($hasExistingReceipts): ?>
|
||||||
|
<button type="button" id="receipt-submit-btn" class="btn btn--primary"><?= $e($t('receipts.create.submit')) ?></button>
|
||||||
|
<?php else: ?>
|
||||||
|
<button type="submit" class="btn btn--primary"><?= $e($t('receipts.create.submit')) ?></button>
|
||||||
|
<?php endif; ?>
|
||||||
<a href="/orders/<?= $e((string) $orderIdVal) ?>" class="btn btn--secondary ml-8"><?= $e($t('receipts.create.cancel')) ?></a>
|
<a href="/orders/<?= $e((string) $orderIdVal) ?>" class="btn btn--secondary ml-8"><?= $e($t('receipts.create.cancel')) ?></a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<?php if ($hasExistingReceipts): ?>
|
||||||
|
<script>
|
||||||
|
document.getElementById('receipt-submit-btn').addEventListener('click', function() {
|
||||||
|
window.OrderProAlerts.confirm(
|
||||||
|
'Do tego zamówienia wystawiono już paragon. Czy na pewno chcesz wystawić kolejny?',
|
||||||
|
function() {
|
||||||
|
document.getElementById('receipt-create-form').submit();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ foreach ($addressesList as $address) {
|
|||||||
<div class="order-tab-panel" data-order-tab-panel="shipments">
|
<div class="order-tab-panel" data-order-tab-panel="shipments">
|
||||||
<?php if ($packagesList !== []): ?>
|
<?php if ($packagesList !== []): ?>
|
||||||
<section class="card mt-16">
|
<section class="card mt-16">
|
||||||
<h3 class="section-title">Wygenerowane przesylki (WZA)</h3>
|
<h3 class="section-title">Wygenerowane przesylki</h3>
|
||||||
<div class="table-wrap mt-12">
|
<div class="table-wrap mt-12">
|
||||||
<table class="table table--details">
|
<table class="table table--details">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -369,12 +369,21 @@ foreach ($addressesList as $address) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<?php $storageBase = dirname(__DIR__, 3) . '/storage/'; ?>
|
||||||
|
<?php $pendingPrintIds = is_array($pendingPrintPackageIds ?? null) ? $pendingPrintPackageIds : []; ?>
|
||||||
<?php foreach ($packagesList as $pkg): ?>
|
<?php foreach ($packagesList as $pkg): ?>
|
||||||
<?php
|
<?php
|
||||||
$pkgStatus = (string) ($pkg['status'] ?? 'draft');
|
$pkgStatus = (string) ($pkg['status'] ?? 'draft');
|
||||||
$pkgTracking = trim((string) ($pkg['tracking_number'] ?? ''));
|
$pkgTracking = trim((string) ($pkg['tracking_number'] ?? ''));
|
||||||
$pkgCarrier = trim((string) ($pkg['carrier_id'] ?? ''));
|
$pkgCarrierId = trim((string) ($pkg['carrier_id'] ?? ''));
|
||||||
|
$pkgProvider = trim((string) ($pkg['provider'] ?? ''));
|
||||||
|
$providerLabels = ['apaczka' => 'Apaczka', 'allegro_wza' => 'Allegro', 'inpost' => 'InPost'];
|
||||||
|
$pkgProviderLabel = $providerLabels[$pkgProvider] ?? $pkgProvider;
|
||||||
|
$pkgCarrier = $pkgCarrierId !== '' ? ($pkgProviderLabel . ' → ' . $pkgCarrierId) : $pkgProviderLabel;
|
||||||
$pkgLabelPath = trim((string) ($pkg['label_path'] ?? ''));
|
$pkgLabelPath = trim((string) ($pkg['label_path'] ?? ''));
|
||||||
|
if ($pkgLabelPath !== '' && !file_exists($storageBase . $pkgLabelPath)) {
|
||||||
|
$pkgLabelPath = '';
|
||||||
|
}
|
||||||
$pkgError = trim((string) ($pkg['error_message'] ?? ''));
|
$pkgError = trim((string) ($pkg['error_message'] ?? ''));
|
||||||
?>
|
?>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -388,13 +397,26 @@ foreach ($addressesList as $address) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td><?= $e($pkgTracking !== '' ? $pkgTracking : '-') ?></td>
|
<td><?= $e($pkgTracking !== '' ? $pkgTracking : '-') ?></td>
|
||||||
<td><?= $e($pkgCarrier !== '' ? $pkgCarrier : '-') ?></td>
|
<td><?php if ($pkgCarrierId !== ''): ?><?= $e($pkgProviderLabel) ?> → <?= $e($pkgCarrierId) ?><?php elseif ($pkgProviderLabel !== ''): ?><?= $e($pkgProviderLabel) ?><?php else: ?>-<?php endif; ?></td>
|
||||||
<td>
|
<td>
|
||||||
<?php if ($pkgLabelPath !== ''): ?>
|
<span style="display:inline-flex;gap:4px;align-items:center">
|
||||||
|
<?php if ($pkgLabelPath !== '' && $pkgStatus !== 'error'): ?>
|
||||||
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/prepare" class="btn btn--sm btn--secondary">Pobierz</a>
|
<a href="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/prepare" class="btn btn--sm btn--secondary">Pobierz</a>
|
||||||
<?php else: ?>
|
<?php elseif ($pkgStatus !== 'error'): ?>
|
||||||
-
|
-
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (in_array($pkgStatus, ['label_ready', 'created'], true)): ?>
|
||||||
|
<?php if (in_array((int) ($pkg['id'] ?? 0), $pendingPrintIds, true)): ?>
|
||||||
|
<button type="button" class="btn btn--sm btn--danger" disabled style="white-space:nowrap">W kolejce</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn--sm btn--secondary btn-print-label"
|
||||||
|
data-package-id="<?= $e((string) ($pkg['id'] ?? 0)) ?>"
|
||||||
|
data-order-id="<?= $e((string) ($orderId ?? 0)) ?>"
|
||||||
|
title="Wyslij do drukarki">Drukuj</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-nowrap"><?= $e((string) ($pkg['created_at'] ?? '')) ?></td>
|
<td class="text-nowrap"><?= $e((string) ($pkg['created_at'] ?? '')) ?></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -615,14 +637,58 @@ foreach ($addressesList as $address) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var storageKey = 'orderDetailTab';
|
||||||
|
|
||||||
tabButtons.forEach(function (button) {
|
tabButtons.forEach(function (button) {
|
||||||
button.addEventListener('click', function () {
|
button.addEventListener('click', function () {
|
||||||
setActiveTab(button.getAttribute('data-order-tab-target') || 'details');
|
var target = button.getAttribute('data-order-tab-target') || 'details';
|
||||||
|
setActiveTab(target);
|
||||||
|
try { localStorage.setItem(storageKey, target); } catch (e) {}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setActiveTab('details');
|
var forceTab = <?= json_encode($flashSuccessMsg !== '' && strpos($flashSuccessMsg, 'Przesylka') !== false ? 'shipments' : '') ?>;
|
||||||
|
var savedTab = null;
|
||||||
|
try { savedTab = localStorage.getItem(storageKey); } catch (e) {}
|
||||||
|
setActiveTab(forceTab || savedTab || 'details');
|
||||||
|
|
||||||
|
// Print label button handler
|
||||||
|
document.querySelectorAll('.btn-print-label').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var packageId = btn.getAttribute('data-package-id');
|
||||||
|
if (!packageId) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
var originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = 'Wysylam...';
|
||||||
|
var csrfInput = document.querySelector('input[name="_token"]');
|
||||||
|
var csrf = csrfInput ? csrfInput.value : '<?= $e($csrfToken ?? '') ?>';
|
||||||
|
|
||||||
|
fetch('/api/print/jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: '_token=' + encodeURIComponent(csrf) + '&package_id=' + encodeURIComponent(packageId)
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.status === 201 || res.status === 409) {
|
||||||
|
btn.innerHTML = 'W kolejce';
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.remove('btn--secondary');
|
||||||
|
btn.classList.add('btn--danger');
|
||||||
|
} else {
|
||||||
|
var msg = (res.data && res.data.error) ? res.data.error : 'Nieznany blad';
|
||||||
|
if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: msg, type: 'error' }); }
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (window.OrderProAlerts) { window.OrderProAlerts.show({ message: 'Blad sieci.', type: 'error' }); }
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
201
resources/views/settings/printing.php
Normal file
201
resources/views/settings/printing.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
$keysList = is_array($apiKeys ?? null) ? $apiKeys : [];
|
||||||
|
$newKey = (string) ($newApiKey ?? '');
|
||||||
|
$jobsList = is_array($printJobs ?? null) ? $printJobs : [];
|
||||||
|
$currentStatusFilter = (string) ($printStatusFilter ?? '');
|
||||||
|
?>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2 class="section-title">Drukowanie</h2>
|
||||||
|
<p class="muted mt-12">Klucze API do uwierzytelniania aplikacji drukujacej (Windows Client).</p>
|
||||||
|
|
||||||
|
<?php if (!empty($errorMessage)): ?>
|
||||||
|
<div class="alert alert--danger mt-12" role="alert"><?= $e((string) $errorMessage) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($successMessage)): ?>
|
||||||
|
<div class="alert alert--success mt-12" role="status"><?= $e((string) $successMessage) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($newKey !== ''): ?>
|
||||||
|
<div class="alert alert--warning mt-12">
|
||||||
|
<strong>Nowy klucz API:</strong>
|
||||||
|
<code id="new-api-key" style="display:inline-block;padding:4px 8px;background:#f5f5f5;border:1px solid #ddd;border-radius:3px;font-size:13px;word-break:break-all;user-select:all"><?= $e($newKey) ?></code>
|
||||||
|
<button type="button" class="btn btn--secondary btn--sm ml-8" onclick="navigator.clipboard.writeText(document.getElementById('new-api-key').textContent)">Kopiuj</button>
|
||||||
|
<br><small class="muted">Ten klucz nie bedzie ponownie wyswietlony. Skopiuj go teraz.</small>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3 class="section-title">Klucze API</h3>
|
||||||
|
|
||||||
|
<?php if (count($keysList) === 0): ?>
|
||||||
|
<p class="muted mt-12">Brak kluczy API. Utworz pierwszy klucz ponizej.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-wrap mt-12">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nazwa</th>
|
||||||
|
<th>Prefix</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Ostatnie uzycie</th>
|
||||||
|
<th>Utworzono</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($keysList as $key): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $e((string) ($key['name'] ?? '')) ?></td>
|
||||||
|
<td><code><?= $e((string) ($key['key_prefix'] ?? '')) ?>...</code></td>
|
||||||
|
<td>
|
||||||
|
<?php if ((int) ($key['is_active'] ?? 0) === 1): ?>
|
||||||
|
<span class="badge badge--success">Aktywny</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge--danger">Nieaktywny</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= $e((string) ($key['last_used_at'] ?? '-')) ?></td>
|
||||||
|
<td><?= $e((string) ($key['created_at'] ?? '')) ?></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/settings/printing/keys/<?= $e((string) ($key['id'] ?? '')) ?>/delete" style="display:inline">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
||||||
|
<button type="button" class="btn btn--danger btn--sm" onclick="window.OrderProAlerts.confirm('Czy na pewno chcesz usunac ten klucz API?', function() { this.closest('form').submit(); }.bind(this))">Usun</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3 class="section-title">Nowy klucz API</h3>
|
||||||
|
<form method="post" action="/settings/printing/keys/create" class="mt-12">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
||||||
|
<div class="form-group" style="max-width:400px">
|
||||||
|
<label class="form-label" for="key-name">Nazwa klucza</label>
|
||||||
|
<input type="text" name="name" id="key-name" class="form-control" placeholder="np. Komputer biurowy" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn--primary mt-8">Utworz klucz</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3 class="section-title">Kolejka wydruku</h3>
|
||||||
|
|
||||||
|
<div class="mt-12 print-queue-filters">
|
||||||
|
<a href="/settings/printing" class="btn btn--sm <?= $currentStatusFilter === '' ? 'btn--primary' : 'btn--secondary' ?>">Wszystkie</a>
|
||||||
|
<a href="/settings/printing?print_status=pending" class="btn btn--sm <?= $currentStatusFilter === 'pending' ? 'btn--primary' : 'btn--secondary' ?>">Oczekujace</a>
|
||||||
|
<a href="/settings/printing?print_status=completed" class="btn btn--sm <?= $currentStatusFilter === 'completed' ? 'btn--primary' : 'btn--secondary' ?>">Wydrukowane</a>
|
||||||
|
<a href="/settings/printing?print_status=failed" class="btn btn--sm <?= $currentStatusFilter === 'failed' ? 'btn--primary' : 'btn--secondary' ?>">Nieudane</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (count($jobsList) === 0): ?>
|
||||||
|
<p class="muted mt-12">Brak zlecen wydruku<?= $currentStatusFilter !== '' ? ' o statusie "' . $e($currentStatusFilter) . '"' : '' ?>.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-wrap mt-12">
|
||||||
|
<table class="table print-queue-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Zamowienie</th>
|
||||||
|
<th>Tracking</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Wydrukowano</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($jobsList as $job): ?>
|
||||||
|
<?php
|
||||||
|
$jobId = (int) ($job['id'] ?? 0);
|
||||||
|
$jobStatus = (string) ($job['status'] ?? 'pending');
|
||||||
|
$jobOrderNum = trim((string) ($job['order_number'] ?? ''));
|
||||||
|
$jobTracking = trim((string) ($job['tracking_number'] ?? ''));
|
||||||
|
$jobCreatedAt = (string) ($job['created_at'] ?? '');
|
||||||
|
$jobCompletedAt = (string) ($job['completed_at'] ?? '');
|
||||||
|
$jobOrderId = (int) ($job['order_id'] ?? 0);
|
||||||
|
$jobPackageId = (int) ($job['package_id'] ?? 0);
|
||||||
|
|
||||||
|
$badgeClass = 'print-status-badge--pending';
|
||||||
|
$badgeLabel = 'Oczekuje';
|
||||||
|
if ($jobStatus === 'completed') {
|
||||||
|
$badgeClass = 'print-status-badge--completed';
|
||||||
|
$badgeLabel = 'Wydrukowano';
|
||||||
|
} elseif ($jobStatus === 'failed') {
|
||||||
|
$badgeClass = 'print-status-badge--failed';
|
||||||
|
$badgeLabel = 'Blad';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap"><?= $e($jobCreatedAt) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($jobOrderId > 0): ?>
|
||||||
|
<a href="/orders/<?= $e((string) $jobOrderId) ?>"><?= $e($jobOrderNum !== '' ? $jobOrderNum : '#' . $jobOrderId) ?></a>
|
||||||
|
<?php else: ?>
|
||||||
|
-
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= $e($jobTracking !== '' ? $jobTracking : '-') ?></td>
|
||||||
|
<td><span class="print-status-badge <?= $e($badgeClass) ?>"><?= $e($badgeLabel) ?></span></td>
|
||||||
|
<td class="text-nowrap"><?= $e($jobCompletedAt !== '' ? $jobCompletedAt : '-') ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($jobStatus === 'failed' && $jobPackageId > 0): ?>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn--sm btn--secondary btn-retry-print"
|
||||||
|
data-package-id="<?= $e((string) $jobPackageId) ?>"
|
||||||
|
data-csrf="<?= $e((string) ($csrfToken ?? '')) ?>">Ponow</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
document.querySelectorAll('.btn-retry-print').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var packageId = btn.getAttribute('data-package-id');
|
||||||
|
var csrf = btn.getAttribute('data-csrf');
|
||||||
|
if (!packageId || !csrf) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Wysylam...';
|
||||||
|
|
||||||
|
fetch('/api/print/jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: '_token=' + encodeURIComponent(csrf) + '&package_id=' + encodeURIComponent(packageId)
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.status === 201) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
var msg = (res.data && res.data.error) ? res.data.error : 'Nieznany blad';
|
||||||
|
if (window.OrderProAlerts) {
|
||||||
|
window.OrderProAlerts.show({ message: msg, type: 'error' });
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Ponow';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (window.OrderProAlerts) {
|
||||||
|
window.OrderProAlerts.show({ message: 'Blad sieci.', type: 'error' });
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Ponow';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -7,6 +7,7 @@ $comp = is_array($company ?? null) ? $company : [];
|
|||||||
$services = is_array($deliveryServices ?? null) ? $deliveryServices : [];
|
$services = is_array($deliveryServices ?? null) ? $deliveryServices : [];
|
||||||
$apaczkaSvcList = is_array($apaczkaServices ?? null) ? $apaczkaServices : [];
|
$apaczkaSvcList = is_array($apaczkaServices ?? null) ? $apaczkaServices : [];
|
||||||
$packages = is_array($existingPackages ?? null) ? $existingPackages : [];
|
$packages = is_array($existingPackages ?? null) ? $existingPackages : [];
|
||||||
|
$pendingPrintIds = is_array($pendingPrintPackageIds ?? null) ? $pendingPrintPackageIds : [];
|
||||||
$servicesError = (string) ($deliveryServicesError ?? '');
|
$servicesError = (string) ($deliveryServicesError ?? '');
|
||||||
$flashSuccessMsg = (string) ($flashSuccess ?? '');
|
$flashSuccessMsg = (string) ($flashSuccess ?? '');
|
||||||
$flashErrorMsg = (string) ($flashError ?? '');
|
$flashErrorMsg = (string) ($flashError ?? '');
|
||||||
@@ -60,83 +61,6 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<?php if ($packages !== []): ?>
|
|
||||||
<section class="card mt-16">
|
|
||||||
<h3 class="section-title">Utworzone przesylki</h3>
|
|
||||||
<div class="table-wrap mt-12">
|
|
||||||
<table class="table table--details">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ID</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Nr sledzenia</th>
|
|
||||||
<th>Przewoznik</th>
|
|
||||||
<th>Etykieta</th>
|
|
||||||
<th>Utworzono</th>
|
|
||||||
<th>Akcje</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($packages as $pkg): ?>
|
|
||||||
<?php
|
|
||||||
$pkgId = (int) ($pkg['id'] ?? 0);
|
|
||||||
$pkgStatus = (string) ($pkg['status'] ?? 'draft');
|
|
||||||
$pkgTracking = trim((string) ($pkg['tracking_number'] ?? ''));
|
|
||||||
$pkgCarrier = trim((string) ($pkg['carrier_id'] ?? ''));
|
|
||||||
$pkgLabelPath = trim((string) ($pkg['label_path'] ?? ''));
|
|
||||||
$pkgShipmentId = trim((string) ($pkg['shipment_id'] ?? ''));
|
|
||||||
$pkgError = trim((string) ($pkg['error_message'] ?? ''));
|
|
||||||
?>
|
|
||||||
<tr>
|
|
||||||
<td><?= $e((string) $pkgId) ?></td>
|
|
||||||
<td>
|
|
||||||
<span class="order-tag <?= $pkgStatus === 'label_ready' || $pkgStatus === 'created' ? 'is-success' : ($pkgStatus === 'error' ? 'is-danger' : 'is-warn') ?>">
|
|
||||||
<?= $e($pkgStatus) ?>
|
|
||||||
</span>
|
|
||||||
<?php if ($pkgError !== ''): ?>
|
|
||||||
<div class="muted mt-4" style="font-size:0.75rem"><?= $e($pkgError) ?></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td><?= $e($pkgTracking !== '' ? $pkgTracking : '-') ?></td>
|
|
||||||
<td><?= $e($pkgCarrier !== '' ? $pkgCarrier : '-') ?></td>
|
|
||||||
<td>
|
|
||||||
<?php if ($pkgLabelPath !== ''): ?>
|
|
||||||
<?php if ($pkgStatus === 'error'): ?>
|
|
||||||
-
|
|
||||||
<?php else: ?>
|
|
||||||
<form method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/<?= $e((string) $pkgId) ?>/label" style="display:inline">
|
|
||||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
|
||||||
<button type="submit" class="btn btn--sm btn--secondary">Pobierz</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php elseif ($pkgShipmentId !== '' && $pkgStatus === 'created'): ?>
|
|
||||||
<span class="muted">Generowanie etykiety...</span>
|
|
||||||
<?php else: ?>
|
|
||||||
-
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
<td class="text-nowrap"><?= $e((string) ($pkg['created_at'] ?? '')) ?></td>
|
|
||||||
<td>
|
|
||||||
<?php
|
|
||||||
$shouldCheckStatus = $pkgStatus === 'pending' || ($pkgStatus === 'created' && $pkgLabelPath === '');
|
|
||||||
?>
|
|
||||||
<?php if ($shouldCheckStatus): ?>
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn--sm btn--secondary"
|
|
||||||
data-check-status="<?= $e((string) $pkgId) ?>"
|
|
||||||
data-order-id="<?= $e((string) ($orderId ?? 0)) ?>"
|
|
||||||
data-package-status="<?= $e($pkgStatus) ?>"
|
|
||||||
data-auto-check="1">Sprawdz status</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<form method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/create" novalidate>
|
<form method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/create" novalidate>
|
||||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
|
||||||
@@ -399,6 +323,94 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
|||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<?php if ($packages !== []): ?>
|
||||||
|
<section class="card mt-16">
|
||||||
|
<h3 class="section-title">Utworzone przesylki</h3>
|
||||||
|
<div class="table-wrap mt-12">
|
||||||
|
<table class="table table--details">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Nr sledzenia</th>
|
||||||
|
<th>Przewoznik</th>
|
||||||
|
<th>Etykieta</th>
|
||||||
|
<th>Utworzono</th>
|
||||||
|
<th>Akcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($packages as $pkg): ?>
|
||||||
|
<?php
|
||||||
|
$pkgId = (int) ($pkg['id'] ?? 0);
|
||||||
|
$pkgStatus = (string) ($pkg['status'] ?? 'draft');
|
||||||
|
$pkgTracking = trim((string) ($pkg['tracking_number'] ?? ''));
|
||||||
|
$pkgCarrier = trim((string) ($pkg['carrier_id'] ?? ''));
|
||||||
|
$pkgLabelPath = trim((string) ($pkg['label_path'] ?? ''));
|
||||||
|
$pkgShipmentId = trim((string) ($pkg['shipment_id'] ?? ''));
|
||||||
|
$pkgError = trim((string) ($pkg['error_message'] ?? ''));
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $e((string) $pkgId) ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="order-tag <?= $pkgStatus === 'label_ready' || $pkgStatus === 'created' ? 'is-success' : ($pkgStatus === 'error' ? 'is-danger' : 'is-warn') ?>">
|
||||||
|
<?= $e($pkgStatus) ?>
|
||||||
|
</span>
|
||||||
|
<?php if ($pkgError !== ''): ?>
|
||||||
|
<div class="muted mt-4" style="font-size:0.75rem"><?= $e($pkgError) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= $e($pkgTracking !== '' ? $pkgTracking : '-') ?></td>
|
||||||
|
<td><?= $e($pkgCarrier !== '' ? $pkgCarrier : '-') ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($pkgLabelPath !== ''): ?>
|
||||||
|
<?php if ($pkgStatus === 'error'): ?>
|
||||||
|
-
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/shipment/<?= $e((string) $pkgId) ?>/label" style="display:inline">
|
||||||
|
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||||
|
<button type="submit" class="btn btn--sm btn--secondary">Pobierz</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php elseif ($pkgShipmentId !== '' && $pkgStatus === 'created'): ?>
|
||||||
|
<span class="muted">Generowanie etykiety...</span>
|
||||||
|
<?php else: ?>
|
||||||
|
-
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (in_array($pkgStatus, ['label_ready', 'created'], true)): ?>
|
||||||
|
<?php if (in_array($pkgId, $pendingPrintIds, true)): ?>
|
||||||
|
<button type="button" class="btn btn--sm btn--danger" disabled style="white-space:nowrap">W kolejce</button>
|
||||||
|
<?php else: ?>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn--sm btn--secondary btn-print-label"
|
||||||
|
data-package-id="<?= $e((string) $pkgId) ?>"
|
||||||
|
data-order-id="<?= $e((string) ($orderId ?? 0)) ?>"
|
||||||
|
title="Wyslij do drukarki">Drukuj</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap"><?= $e((string) ($pkg['created_at'] ?? '')) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
$shouldCheckStatus = $pkgStatus === 'pending' || ($pkgStatus === 'created' && $pkgLabelPath === '');
|
||||||
|
?>
|
||||||
|
<?php if ($shouldCheckStatus): ?>
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn--sm btn--secondary"
|
||||||
|
data-check-status="<?= $e((string) $pkgId) ?>"
|
||||||
|
data-order-id="<?= $e((string) ($orderId ?? 0)) ?>"
|
||||||
|
data-package-status="<?= $e($pkgStatus) ?>"
|
||||||
|
data-auto-check="1">Sprawdz status</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
function enhanceSelect(selectEl) {
|
function enhanceSelect(selectEl) {
|
||||||
@@ -715,6 +727,47 @@ $defaultCodAmount = $isCod ? number_format($totalWithTax, 2, '.', '') : '0';
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Print label button handler
|
||||||
|
document.querySelectorAll('.btn-print-label').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var packageId = btn.getAttribute('data-package-id');
|
||||||
|
if (!packageId) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
var originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = 'Wysylam...';
|
||||||
|
|
||||||
|
fetch('/api/print/jobs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: '_token=' + encodeURIComponent(document.querySelector('input[name="_token"]').value)
|
||||||
|
+ '&package_id=' + encodeURIComponent(packageId)
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (d) { return { status: r.status, data: d }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.status === 201 || res.status === 409) {
|
||||||
|
btn.innerHTML = 'W kolejce';
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.classList.remove('btn--secondary');
|
||||||
|
btn.classList.add('btn--danger');
|
||||||
|
} else {
|
||||||
|
var msg = (res.data && res.data.error) ? res.data.error : 'Nieznany blad';
|
||||||
|
if (window.OrderProAlerts) {
|
||||||
|
window.OrderProAlerts.show({ message: msg, type: 'error' });
|
||||||
|
}
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (window.OrderProAlerts) {
|
||||||
|
window.OrderProAlerts.show({ message: 'Blad sieci — sprobuj ponownie.', type: 'error' });
|
||||||
|
}
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
var autoCheckId = params.get('check');
|
var autoCheckId = params.get('check');
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ use App\Modules\Shipments\InpostShipmentService;
|
|||||||
use App\Modules\Shipments\ShipmentController;
|
use App\Modules\Shipments\ShipmentController;
|
||||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||||
use App\Modules\Shipments\ShipmentProviderRegistry;
|
use App\Modules\Shipments\ShipmentProviderRegistry;
|
||||||
|
use App\Modules\Printing\ApiKeyMiddleware;
|
||||||
|
use App\Modules\Printing\PrintApiController;
|
||||||
|
use App\Modules\Printing\PrintApiKeyRepository;
|
||||||
|
use App\Modules\Printing\PrintJobRepository;
|
||||||
|
use App\Modules\Settings\PrintSettingsController;
|
||||||
use App\Modules\Users\UsersController;
|
use App\Modules\Users\UsersController;
|
||||||
|
|
||||||
return static function (Application $app): void {
|
return static function (Application $app): void {
|
||||||
@@ -235,7 +240,8 @@ return static function (Application $app): void {
|
|||||||
new OrdersRepository($app->db()),
|
new OrdersRepository($app->db()),
|
||||||
$companySettingsRepository
|
$companySettingsRepository
|
||||||
);
|
);
|
||||||
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository);
|
$printJobRepository = new PrintJobRepository($app->db());
|
||||||
|
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository);
|
||||||
$receiptController = new ReceiptController(
|
$receiptController = new ReceiptController(
|
||||||
$template,
|
$template,
|
||||||
$translator,
|
$translator,
|
||||||
@@ -289,7 +295,8 @@ return static function (Application $app): void {
|
|||||||
$shipmentProviderRegistry,
|
$shipmentProviderRegistry,
|
||||||
$shipmentPackageRepository,
|
$shipmentPackageRepository,
|
||||||
$app->basePath('storage'),
|
$app->basePath('storage'),
|
||||||
$carrierDeliveryMappings
|
$carrierDeliveryMappings,
|
||||||
|
$printJobRepository
|
||||||
);
|
);
|
||||||
$authMiddleware = new AuthMiddleware($auth);
|
$authMiddleware = new AuthMiddleware($auth);
|
||||||
|
|
||||||
@@ -391,4 +398,30 @@ return static function (Application $app): void {
|
|||||||
$router->post('/orders/{id}/shipment/create', [$shipmentController, 'create'], [$authMiddleware]);
|
$router->post('/orders/{id}/shipment/create', [$shipmentController, 'create'], [$authMiddleware]);
|
||||||
$router->get('/orders/{id}/shipment/{packageId}/status', [$shipmentController, 'checkStatus'], [$authMiddleware]);
|
$router->get('/orders/{id}/shipment/{packageId}/status', [$shipmentController, 'checkStatus'], [$authMiddleware]);
|
||||||
$router->post('/orders/{id}/shipment/{packageId}/label', [$shipmentController, 'label'], [$authMiddleware]);
|
$router->post('/orders/{id}/shipment/{packageId}/label', [$shipmentController, 'label'], [$authMiddleware]);
|
||||||
|
|
||||||
|
// --- Printing module ---
|
||||||
|
$printApiKeyRepository = new PrintApiKeyRepository($app->db());
|
||||||
|
$apiKeyMiddleware = new ApiKeyMiddleware($printApiKeyRepository);
|
||||||
|
$printApiController = new PrintApiController(
|
||||||
|
$printJobRepository,
|
||||||
|
$shipmentPackageRepository,
|
||||||
|
$auth,
|
||||||
|
$app->basePath('storage'),
|
||||||
|
$shipmentProviderRegistry
|
||||||
|
);
|
||||||
|
$printSettingsController = new PrintSettingsController($template, $translator, $auth, $printApiKeyRepository, $printJobRepository);
|
||||||
|
|
||||||
|
// Print API — session auth (from orderPRO UI)
|
||||||
|
$router->post('/api/print/jobs', [$printApiController, 'createJob'], [$authMiddleware]);
|
||||||
|
$router->post('/api/print/jobs/bulk', [$printApiController, 'bulkCreateJobs'], [$authMiddleware]);
|
||||||
|
|
||||||
|
// Print API — API key auth (from Windows client)
|
||||||
|
$router->get('/api/print/jobs/pending', [$printApiController, 'listPending'], [$apiKeyMiddleware]);
|
||||||
|
$router->get('/api/print/jobs/{id}/download', [$printApiController, 'downloadLabel'], [$apiKeyMiddleware]);
|
||||||
|
$router->post('/api/print/jobs/{id}/complete', [$printApiController, 'markComplete'], [$apiKeyMiddleware]);
|
||||||
|
|
||||||
|
// Print settings
|
||||||
|
$router->get('/settings/printing', [$printSettingsController, 'index'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/printing/keys/create', [$printSettingsController, 'createKey'], [$authMiddleware]);
|
||||||
|
$router->post('/settings/printing/keys/{id}/delete', [$printSettingsController, 'deleteKey'], [$authMiddleware]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ final class Request
|
|||||||
return new self($this->query, $this->request, $this->files, $this->server, $attributes);
|
return new self($this->query, $this->request, $this->files, $this->server, $attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function header(string $name, string $default = ''): string
|
||||||
|
{
|
||||||
|
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
|
||||||
|
|
||||||
|
return (string) ($this->server[$key] ?? $default);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ final class ReceiptController
|
|||||||
return Response::redirect('/orders/' . $orderId);
|
return Response::redirect('/orders/' . $orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$existingReceipts = $this->receipts->findByOrderId($orderId);
|
||||||
|
|
||||||
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
|
||||||
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
|
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
|
||||||
$seller = $this->companySettings->getSettings();
|
$seller = $this->companySettings->getSettings();
|
||||||
@@ -67,6 +69,7 @@ final class ReceiptController
|
|||||||
'configs' => array_values($configs),
|
'configs' => array_values($configs),
|
||||||
'seller' => $seller,
|
'seller' => $seller,
|
||||||
'totalGross' => $totalGross,
|
'totalGross' => $totalGross,
|
||||||
|
'existingReceipts' => $existingReceipts,
|
||||||
], 'layouts/app');
|
], 'layouts/app');
|
||||||
|
|
||||||
return Response::html($html);
|
return Response::html($html);
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ final class OrdersController
|
|||||||
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null,
|
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null,
|
||||||
private readonly ?EmailSendingService $emailService = null,
|
private readonly ?EmailSendingService $emailService = null,
|
||||||
private readonly ?EmailTemplateRepository $emailTemplateRepo = null,
|
private readonly ?EmailTemplateRepository $emailTemplateRepo = null,
|
||||||
private readonly ?EmailMailboxRepository $emailMailboxRepo = null
|
private readonly ?EmailMailboxRepository $emailMailboxRepo = null,
|
||||||
|
private readonly string $storagePath = '',
|
||||||
|
private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +133,17 @@ final class OrdersController
|
|||||||
'per_page' => (int) ($result['per_page'] ?? 20),
|
'per_page' => (int) ($result['per_page'] ?? 20),
|
||||||
],
|
],
|
||||||
'per_page_options' => [20, 50, 100],
|
'per_page_options' => [20, 50, 100],
|
||||||
|
'selectable' => true,
|
||||||
|
'select_name' => 'selected_ids[]',
|
||||||
|
'select_value_key' => 'id',
|
||||||
|
'header_actions' => [
|
||||||
|
[
|
||||||
|
'type' => 'button',
|
||||||
|
'label' => 'Drukuj etykiety',
|
||||||
|
'class' => 'btn btn--secondary js-bulk-print-labels',
|
||||||
|
'attrs' => ['data-csrf' => Csrf::token()],
|
||||||
|
],
|
||||||
|
],
|
||||||
'empty_message' => $this->translator->get('orders.empty'),
|
'empty_message' => $this->translator->get('orders.empty'),
|
||||||
'show_actions' => false,
|
'show_actions' => false,
|
||||||
],
|
],
|
||||||
@@ -172,6 +185,16 @@ final class OrdersController
|
|||||||
? $this->shipmentPackages->findByOrderId($orderId)
|
? $this->shipmentPackages->findByOrderId($orderId)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
if ($this->storagePath !== '') {
|
||||||
|
foreach ($packages as &$pkg) {
|
||||||
|
$lp = trim((string) ($pkg['label_path'] ?? ''));
|
||||||
|
if ($lp !== '' && !file_exists($this->storagePath . '/' . $lp)) {
|
||||||
|
$pkg['label_path'] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($pkg);
|
||||||
|
}
|
||||||
|
|
||||||
$receipts = $this->receiptRepo !== null
|
$receipts = $this->receiptRepo !== null
|
||||||
? $this->receiptRepo->findByOrderId($orderId)
|
? $this->receiptRepo->findByOrderId($orderId)
|
||||||
: [];
|
: [];
|
||||||
@@ -203,6 +226,7 @@ final class OrdersController
|
|||||||
'payments' => $payments,
|
'payments' => $payments,
|
||||||
'shipments' => $shipments,
|
'shipments' => $shipments,
|
||||||
'packages' => $packages,
|
'packages' => $packages,
|
||||||
|
'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [],
|
||||||
'documents' => $documents,
|
'documents' => $documents,
|
||||||
'notes' => $notes,
|
'notes' => $notes,
|
||||||
'history' => $resolvedHistory,
|
'history' => $resolvedHistory,
|
||||||
@@ -282,6 +306,7 @@ final class OrdersController
|
|||||||
$itemsPreview = is_array($row['items_preview'] ?? null) ? $row['items_preview'] : [];
|
$itemsPreview = is_array($row['items_preview'] ?? null) ? $row['items_preview'] : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'id' => (int) ($row['id'] ?? 0),
|
||||||
'order_ref' => '<div class="orders-ref">'
|
'order_ref' => '<div class="orders-ref">'
|
||||||
. '<div class="orders-ref__main"><a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
|
. '<div class="orders-ref__main"><a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
|
||||||
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
|
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')
|
||||||
|
|||||||
43
src/Modules/Printing/ApiKeyMiddleware.php
Normal file
43
src/Modules/Printing/ApiKeyMiddleware.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Printing;
|
||||||
|
|
||||||
|
use App\Core\Http\Request;
|
||||||
|
use App\Core\Http\Response;
|
||||||
|
|
||||||
|
final class ApiKeyMiddleware
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrintApiKeyRepository $apiKeys
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(Request $request, callable $next): Response
|
||||||
|
{
|
||||||
|
$apiKey = $request->header('X-Api-Key');
|
||||||
|
if ($apiKey === '') {
|
||||||
|
return Response::json(['error' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyHash = hash('sha256', $apiKey);
|
||||||
|
$record = $this->apiKeys->findByKeyHash($keyHash);
|
||||||
|
|
||||||
|
if ($record === null || (int) ($record['is_active'] ?? 0) !== 1) {
|
||||||
|
return Response::json(['error' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKeys->updateLastUsed((int) $record['id']);
|
||||||
|
|
||||||
|
$result = $next($request);
|
||||||
|
if ($result instanceof Response) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($result)) {
|
||||||
|
return Response::json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::html((string) $result);
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/Modules/Printing/PrintApiController.php
Normal file
230
src/Modules/Printing/PrintApiController.php
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Printing;
|
||||||
|
|
||||||
|
use App\Core\Http\Request;
|
||||||
|
use App\Core\Http\Response;
|
||||||
|
use App\Core\Security\Csrf;
|
||||||
|
use App\Modules\Auth\AuthService;
|
||||||
|
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||||
|
use App\Modules\Shipments\ShipmentProviderRegistry;
|
||||||
|
|
||||||
|
final class PrintApiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrintJobRepository $printJobs,
|
||||||
|
private readonly ShipmentPackageRepository $packages,
|
||||||
|
private readonly AuthService $auth,
|
||||||
|
private readonly string $storagePath,
|
||||||
|
private readonly ShipmentProviderRegistry $providers
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureLabel(int $packageId, array $package): string
|
||||||
|
{
|
||||||
|
$labelPath = (string) ($package['label_path'] ?? '');
|
||||||
|
if ($labelPath !== '' && file_exists($this->storagePath . '/' . $labelPath)) {
|
||||||
|
return $labelPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$providerCode = strtolower(trim((string) ($package['provider'] ?? 'allegro_wza')));
|
||||||
|
$provider = $this->providers->get($providerCode);
|
||||||
|
if ($provider === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $provider->downloadLabel($packageId, $this->storagePath);
|
||||||
|
return (string) ($result['label_path'] ?? '');
|
||||||
|
} catch (\Throwable $ex) {
|
||||||
|
$this->lastLabelError = $ex->getMessage();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string $lastLabelError = '';
|
||||||
|
|
||||||
|
public function createJob(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||||
|
return Response::json(['error' => 'Invalid CSRF token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageId = (int) $request->input('package_id', 0);
|
||||||
|
if ($packageId <= 0) {
|
||||||
|
return Response::json(['error' => 'package_id is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = $this->packages->findById($packageId);
|
||||||
|
if ($package === null) {
|
||||||
|
return Response::json(['error' => 'Package not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastLabelError = '';
|
||||||
|
$labelPath = $this->ensureLabel($packageId, $package);
|
||||||
|
if ($labelPath === '') {
|
||||||
|
$msg = 'Etykieta niedostepna';
|
||||||
|
if ($this->lastLabelError !== '') {
|
||||||
|
$msg .= ': ' . $this->lastLabelError;
|
||||||
|
}
|
||||||
|
$msg .= '. Kliknij najpierw "Pobierz" aby pobrac etykiete.';
|
||||||
|
return Response::json(['error' => $msg], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingPending = $this->printJobs->findPendingByPackageId($packageId);
|
||||||
|
if ($existingPending !== null) {
|
||||||
|
return Response::json(['error' => 'Zlecenie juz w kolejce'], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$orderId = (int) ($package['order_id'] ?? 0);
|
||||||
|
|
||||||
|
$jobId = $this->printJobs->create([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'package_id' => $packageId,
|
||||||
|
'label_path' => $labelPath,
|
||||||
|
'created_by' => (int) ($user['id'] ?? 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Response::json(['id' => $jobId, 'status' => 'pending'], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listPending(Request $request): Response
|
||||||
|
{
|
||||||
|
$jobs = $this->printJobs->findPending();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($jobs as $job) {
|
||||||
|
$result[] = [
|
||||||
|
'id' => (int) $job['id'],
|
||||||
|
'order_number' => $job['order_number'] ?? '',
|
||||||
|
'tracking_number' => $job['tracking_number'] ?? '',
|
||||||
|
'created_at' => $job['created_at'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json(['jobs' => $result]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadLabel(Request $request): Response
|
||||||
|
{
|
||||||
|
$jobId = (int) $request->input('id', 0);
|
||||||
|
if ($jobId <= 0) {
|
||||||
|
return Response::json(['error' => 'Invalid job ID'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = $this->printJobs->findById($jobId);
|
||||||
|
if ($job === null) {
|
||||||
|
return Response::json(['error' => 'Job not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$labelPath = (string) ($job['label_path'] ?? '');
|
||||||
|
$fullPath = $this->storagePath . '/' . $labelPath;
|
||||||
|
|
||||||
|
if (!file_exists($fullPath)) {
|
||||||
|
return Response::json(['error' => 'Label file not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($fullPath);
|
||||||
|
if ($content === false) {
|
||||||
|
return Response::json(['error' => 'Failed to read label file'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($labelPath, PATHINFO_EXTENSION));
|
||||||
|
$contentType = $extension === 'zpl' ? 'application/octet-stream' : 'application/pdf';
|
||||||
|
$filename = 'label_' . $jobId . '.' . ($extension ?: 'pdf');
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
$content,
|
||||||
|
200,
|
||||||
|
[
|
||||||
|
'Content-Type' => $contentType,
|
||||||
|
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markComplete(Request $request): Response
|
||||||
|
{
|
||||||
|
$jobId = (int) $request->input('id', 0);
|
||||||
|
if ($jobId <= 0) {
|
||||||
|
return Response::json(['error' => 'Invalid job ID'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = $this->printJobs->findById($jobId);
|
||||||
|
if ($job === null) {
|
||||||
|
return Response::json(['error' => 'Job not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->printJobs->markCompleted($jobId);
|
||||||
|
|
||||||
|
return Response::json(['id' => $jobId, 'status' => 'completed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkCreateJobs(Request $request): Response
|
||||||
|
{
|
||||||
|
$body = json_decode((string) file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($body)) {
|
||||||
|
$body = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = (string) ($body['_token'] ?? $request->input('_token', ''));
|
||||||
|
if (!Csrf::validate($token)) {
|
||||||
|
return Response::json(['error' => 'Invalid CSRF token'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageIds = $body['package_ids'] ?? $request->input('package_ids', []);
|
||||||
|
if (!is_array($packageIds) || $packageIds === []) {
|
||||||
|
$orderIds = $body['order_ids'] ?? $request->input('order_ids', []);
|
||||||
|
if (!is_array($orderIds) || $orderIds === []) {
|
||||||
|
return Response::json(['error' => 'package_ids or order_ids required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$intOrderIds = array_map('intval', $orderIds);
|
||||||
|
$packages = $this->printJobs->findPackagesWithLabelsByOrderIds($intOrderIds);
|
||||||
|
$packageIds = array_map(static fn(array $p): int => (int) $p['id'], $packages);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->auth->user();
|
||||||
|
$userId = (int) ($user['id'] ?? 0);
|
||||||
|
$created = [];
|
||||||
|
$skipped = [];
|
||||||
|
|
||||||
|
foreach ($packageIds as $pkgId) {
|
||||||
|
$pkgId = (int) $pkgId;
|
||||||
|
if ($pkgId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->printJobs->findPendingByPackageId($pkgId);
|
||||||
|
if ($existing !== null) {
|
||||||
|
$skipped[] = ['package_id' => $pkgId, 'reason' => 'already_pending'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$package = $this->packages->findById($pkgId);
|
||||||
|
if ($package === null) {
|
||||||
|
$skipped[] = ['package_id' => $pkgId, 'reason' => 'not_found'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$labelPath = $this->ensureLabel($pkgId, $package);
|
||||||
|
if ($labelPath === '') {
|
||||||
|
$skipped[] = ['package_id' => $pkgId, 'reason' => 'no_label'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobId = $this->printJobs->create([
|
||||||
|
'order_id' => (int) ($package['order_id'] ?? 0),
|
||||||
|
'package_id' => $pkgId,
|
||||||
|
'label_path' => $labelPath,
|
||||||
|
'created_by' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$created[] = ['id' => $jobId, 'package_id' => $pkgId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json(['created' => $created, 'skipped' => $skipped], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Modules/Printing/PrintApiKeyRepository.php
Normal file
79
src/Modules/Printing/PrintApiKeyRepository.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Printing;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class PrintApiKeyRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $name, string $keyHash, string $keyPrefix): int
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO print_api_keys (name, key_hash, key_prefix) VALUES (:name, :key_hash, :key_prefix)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'name' => $name,
|
||||||
|
'key_hash' => $keyHash,
|
||||||
|
'key_prefix' => $keyPrefix,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findByKeyHash(string $keyHash): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT * FROM print_api_keys WHERE key_hash = :key_hash LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['key_hash' => $keyHash]);
|
||||||
|
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return is_array($row) ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function listAll(): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->query(
|
||||||
|
'SELECT id, name, key_prefix, is_active, last_used_at, created_at FROM print_api_keys ORDER BY created_at DESC'
|
||||||
|
);
|
||||||
|
$rows = $statement !== false ? $statement->fetchAll(PDO::FETCH_ASSOC) : [];
|
||||||
|
|
||||||
|
return is_array($rows) ? $rows : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deactivate(int $id): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE print_api_keys SET is_active = 0 WHERE id = :id'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'DELETE FROM print_api_keys WHERE id = :id'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLastUsed(int $id): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE print_api_keys SET last_used_at = NOW() WHERE id = :id'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
165
src/Modules/Printing/PrintJobRepository.php
Normal file
165
src/Modules/Printing/PrintJobRepository.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Printing;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class PrintJobRepository
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PDO $pdo
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function create(array $data): int
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'INSERT INTO print_jobs (order_id, package_id, label_path, created_by)
|
||||||
|
VALUES (:order_id, :package_id, :label_path, :created_by)'
|
||||||
|
);
|
||||||
|
$statement->execute([
|
||||||
|
'order_id' => $data['order_id'],
|
||||||
|
'package_id' => $data['package_id'],
|
||||||
|
'label_path' => $data['label_path'],
|
||||||
|
'created_by' => $data['created_by'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function findPending(): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->query(
|
||||||
|
'SELECT pj.id, pj.order_id, pj.package_id, pj.label_path, pj.created_at,
|
||||||
|
o.internal_order_number AS order_number,
|
||||||
|
sp.tracking_number
|
||||||
|
FROM print_jobs pj
|
||||||
|
LEFT JOIN orders o ON o.id = pj.order_id
|
||||||
|
LEFT JOIN shipment_packages sp ON sp.id = pj.package_id
|
||||||
|
WHERE pj.status = \'pending\'
|
||||||
|
ORDER BY pj.created_at ASC'
|
||||||
|
);
|
||||||
|
$rows = $statement !== false ? $statement->fetchAll(PDO::FETCH_ASSOC) : [];
|
||||||
|
|
||||||
|
return is_array($rows) ? $rows : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT pj.*, o.internal_order_number AS order_number, sp.tracking_number
|
||||||
|
FROM print_jobs pj
|
||||||
|
LEFT JOIN orders o ON o.id = pj.order_id
|
||||||
|
LEFT JOIN shipment_packages sp ON sp.id = pj.package_id
|
||||||
|
WHERE pj.id = :id
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return is_array($row) ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markCompleted(int $id): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE print_jobs SET status = \'completed\', completed_at = NOW() WHERE id = :id'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markFailed(int $id): void
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'UPDATE print_jobs SET status = \'failed\' WHERE id = :id'
|
||||||
|
);
|
||||||
|
$statement->execute(['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public function pendingPackageIds(): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->query(
|
||||||
|
'SELECT DISTINCT package_id FROM print_jobs WHERE status = \'pending\''
|
||||||
|
);
|
||||||
|
$rows = $statement !== false ? $statement->fetchAll(PDO::FETCH_COLUMN) : [];
|
||||||
|
|
||||||
|
return is_array($rows) ? array_map('intval', $rows) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function findPendingByPackageId(int $packageId): ?array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
'SELECT id FROM print_jobs WHERE package_id = :package_id AND status = \'pending\' LIMIT 1'
|
||||||
|
);
|
||||||
|
$statement->execute(['package_id' => $packageId]);
|
||||||
|
$row = $statement->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return is_array($row) ? $row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function getRecentJobs(int $limit = 50, ?string $statusFilter = null): array
|
||||||
|
{
|
||||||
|
$sql = 'SELECT pj.id, pj.order_id, pj.package_id, pj.label_path, pj.status,
|
||||||
|
pj.created_at, pj.completed_at,
|
||||||
|
o.internal_order_number AS order_number,
|
||||||
|
sp.tracking_number
|
||||||
|
FROM print_jobs pj
|
||||||
|
LEFT JOIN orders o ON o.id = pj.order_id
|
||||||
|
LEFT JOIN shipment_packages sp ON sp.id = pj.package_id';
|
||||||
|
|
||||||
|
$params = [];
|
||||||
|
if ($statusFilter !== null && $statusFilter !== '') {
|
||||||
|
$sql .= ' WHERE pj.status = :status';
|
||||||
|
$params['status'] = $statusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY pj.created_at DESC LIMIT ' . max(1, $limit);
|
||||||
|
|
||||||
|
$statement = $this->pdo->prepare($sql);
|
||||||
|
$statement->execute($params);
|
||||||
|
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return is_array($rows) ? $rows : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $orderIds
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function findPackagesWithLabelsByOrderIds(array $orderIds): array
|
||||||
|
{
|
||||||
|
if ($orderIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($orderIds), '?'));
|
||||||
|
$statement = $this->pdo->prepare(
|
||||||
|
"SELECT id, order_id, label_path FROM shipment_packages
|
||||||
|
WHERE order_id IN ($placeholders) AND label_path IS NOT NULL AND label_path != ''
|
||||||
|
AND status != 'error'"
|
||||||
|
);
|
||||||
|
$statement->execute(array_values($orderIds));
|
||||||
|
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
return is_array($rows) ? $rows : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Modules\Settings;
|
namespace App\Modules\Settings;
|
||||||
|
|
||||||
use AppCorexceptionsApaczkaApiException;
|
use App\Core\Exceptions\ApaczkaApiException;
|
||||||
|
|
||||||
final class ApaczkaApiClient
|
final class ApaczkaApiClient
|
||||||
{
|
{
|
||||||
|
|||||||
96
src/Modules/Settings/PrintSettingsController.php
Normal file
96
src/Modules/Settings/PrintSettingsController.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Modules\Settings;
|
||||||
|
|
||||||
|
use App\Core\Http\Request;
|
||||||
|
use App\Core\Http\Response;
|
||||||
|
use App\Core\I18n\Translator;
|
||||||
|
use App\Core\Security\Csrf;
|
||||||
|
use App\Core\Support\Flash;
|
||||||
|
use App\Core\View\Template;
|
||||||
|
use App\Modules\Auth\AuthService;
|
||||||
|
use App\Modules\Printing\PrintApiKeyRepository;
|
||||||
|
use App\Modules\Printing\PrintJobRepository;
|
||||||
|
|
||||||
|
final class PrintSettingsController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Template $template,
|
||||||
|
private readonly Translator $translator,
|
||||||
|
private readonly AuthService $auth,
|
||||||
|
private readonly PrintApiKeyRepository $apiKeys,
|
||||||
|
private readonly PrintJobRepository $printJobs
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$keys = $this->apiKeys->listAll();
|
||||||
|
|
||||||
|
$statusFilter = trim((string) $request->input('print_status', ''));
|
||||||
|
$validStatuses = ['pending', 'completed', 'failed'];
|
||||||
|
$filterValue = in_array($statusFilter, $validStatuses, true) ? $statusFilter : null;
|
||||||
|
$recentJobs = $this->printJobs->getRecentJobs(50, $filterValue);
|
||||||
|
|
||||||
|
$html = $this->template->render('settings/printing', [
|
||||||
|
'title' => 'Drukowanie',
|
||||||
|
'activeMenu' => 'settings',
|
||||||
|
'activeSettings' => 'printing',
|
||||||
|
'user' => $this->auth->user(),
|
||||||
|
'csrfToken' => Csrf::token(),
|
||||||
|
'apiKeys' => $keys,
|
||||||
|
'printJobs' => $recentJobs,
|
||||||
|
'printStatusFilter' => $statusFilter,
|
||||||
|
'errorMessage' => (string) Flash::get('settings_error', ''),
|
||||||
|
'successMessage' => (string) Flash::get('settings_success', ''),
|
||||||
|
'newApiKey' => (string) Flash::get('settings_new_api_key', ''),
|
||||||
|
], 'layouts/app');
|
||||||
|
|
||||||
|
return Response::html($html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createKey(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||||
|
Flash::set('settings_error', 'Nieprawidłowy token CSRF');
|
||||||
|
return Response::redirect('/settings/printing');
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) $request->input('name', ''));
|
||||||
|
if ($name === '') {
|
||||||
|
Flash::set('settings_error', 'Nazwa klucza jest wymagana');
|
||||||
|
return Response::redirect('/settings/printing');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawKey = bin2hex(random_bytes(32));
|
||||||
|
$keyHash = hash('sha256', $rawKey);
|
||||||
|
$keyPrefix = substr($rawKey, 0, 8);
|
||||||
|
|
||||||
|
$this->apiKeys->create($name, $keyHash, $keyPrefix);
|
||||||
|
|
||||||
|
Flash::set('settings_success', 'Klucz API utworzony. Skopiuj go teraz — nie będzie ponownie wyświetlony.');
|
||||||
|
Flash::set('settings_new_api_key', $rawKey);
|
||||||
|
|
||||||
|
return Response::redirect('/settings/printing');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteKey(Request $request): Response
|
||||||
|
{
|
||||||
|
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||||
|
Flash::set('settings_error', 'Nieprawidłowy token CSRF');
|
||||||
|
return Response::redirect('/settings/printing');
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) $request->input('id', 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
Flash::set('settings_error', 'Nieprawidłowy ID klucza');
|
||||||
|
return Response::redirect('/settings/printing');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->apiKeys->delete($id);
|
||||||
|
Flash::set('settings_success', 'Klucz API został usunięty');
|
||||||
|
|
||||||
|
return Response::redirect('/settings/printing');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,8 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$carrierLabel = trim((string) ($serviceDefinition['name'] ?? ''));
|
||||||
|
|
||||||
$packageId = $this->packages->create([
|
$packageId = $this->packages->create([
|
||||||
'order_id' => $orderId,
|
'order_id' => $orderId,
|
||||||
'provider' => 'apaczka',
|
'provider' => 'apaczka',
|
||||||
@@ -123,7 +125,7 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
|
|||||||
'credentials_id' => null,
|
'credentials_id' => null,
|
||||||
'command_id' => null,
|
'command_id' => null,
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
'carrier_id' => null,
|
'carrier_id' => $carrierLabel !== '' ? $carrierLabel : null,
|
||||||
'package_type' => strtoupper(trim((string) ($formData['package_type'] ?? 'PACKAGE'))),
|
'package_type' => strtoupper(trim((string) ($formData['package_type'] ?? 'PACKAGE'))),
|
||||||
'weight_kg' => $weightKg,
|
'weight_kg' => $weightKg,
|
||||||
'length_cm' => $lengthCm,
|
'length_cm' => $lengthCm,
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ final class ShipmentController
|
|||||||
private readonly ShipmentProviderRegistry $providerRegistry,
|
private readonly ShipmentProviderRegistry $providerRegistry,
|
||||||
private readonly ShipmentPackageRepository $packageRepository,
|
private readonly ShipmentPackageRepository $packageRepository,
|
||||||
private readonly string $storagePath,
|
private readonly string $storagePath,
|
||||||
private readonly ?CarrierDeliveryMethodMappingRepository $deliveryMappings = null
|
private readonly ?CarrierDeliveryMethodMappingRepository $deliveryMappings = null,
|
||||||
|
private readonly ?\App\Modules\Printing\PrintJobRepository $printJobRepo = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,14 @@ final class ShipmentController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($existingPackages as &$pkg) {
|
||||||
|
$lp = trim((string) ($pkg['label_path'] ?? ''));
|
||||||
|
if ($lp !== '' && !file_exists($this->storagePath . '/' . $lp)) {
|
||||||
|
$pkg['label_path'] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($pkg);
|
||||||
|
|
||||||
$html = $this->template->render('shipments/prepare', [
|
$html = $this->template->render('shipments/prepare', [
|
||||||
'title' => $this->translator->get('shipments.prepare.title') . ' #' . $orderId,
|
'title' => $this->translator->get('shipments.prepare.title') . ' #' . $orderId,
|
||||||
'activeMenu' => 'orders',
|
'activeMenu' => 'orders',
|
||||||
@@ -138,6 +147,7 @@ final class ShipmentController
|
|||||||
'deliveryMapping' => $deliveryMapping,
|
'deliveryMapping' => $deliveryMapping,
|
||||||
'deliveryMappingDiagnostic' => $deliveryMappingDiagnostic,
|
'deliveryMappingDiagnostic' => $deliveryMappingDiagnostic,
|
||||||
'inpostServices' => $inpostServices,
|
'inpostServices' => $inpostServices,
|
||||||
|
'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [],
|
||||||
], 'layouts/app');
|
], 'layouts/app');
|
||||||
|
|
||||||
return Response::html($html);
|
return Response::html($html);
|
||||||
@@ -203,8 +213,8 @@ final class ShipmentController
|
|||||||
'user',
|
'user',
|
||||||
$actorName
|
$actorName
|
||||||
);
|
);
|
||||||
Flash::set('shipment.success', 'Komenda tworzenia przesylki wyslana. Sprawdz status.');
|
Flash::set('order.success', 'Przesylka utworzona. Sprawdz status w zakladce Przesylki.');
|
||||||
return Response::redirect('/orders/' . $orderId . '/shipment/prepare?check=' . $packageId);
|
return Response::redirect('/orders/' . $orderId);
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
$this->ordersRepository->recordActivity(
|
$this->ordersRepository->recordActivity(
|
||||||
$orderId,
|
$orderId,
|
||||||
|
|||||||
Reference in New Issue
Block a user