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:
2026-03-22 21:16:54 +01:00
parent d1a1b79247
commit 02d06298ea
33 changed files with 2623 additions and 117 deletions

View File

@@ -63,6 +63,13 @@ final class Request
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>
*/

View File

@@ -44,6 +44,8 @@ final class ReceiptController
return Response::redirect('/orders/' . $orderId);
}
$existingReceipts = $this->receipts->findByOrderId($orderId);
$order = is_array($details['order'] ?? null) ? $details['order'] : [];
$items = is_array($details['items'] ?? null) ? $details['items'] : [];
$seller = $this->companySettings->getSettings();
@@ -67,6 +69,7 @@ final class ReceiptController
'configs' => array_values($configs),
'seller' => $seller,
'totalGross' => $totalGross,
'existingReceipts' => $existingReceipts,
], 'layouts/app');
return Response::html($html);

View File

@@ -30,7 +30,9 @@ final class OrdersController
private readonly ?ReceiptConfigRepository $receiptConfigRepo = null,
private readonly ?EmailSendingService $emailService = 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_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'),
'show_actions' => false,
],
@@ -172,6 +185,16 @@ final class OrdersController
? $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
? $this->receiptRepo->findByOrderId($orderId)
: [];
@@ -203,6 +226,7 @@ final class OrdersController
'payments' => $payments,
'shipments' => $shipments,
'packages' => $packages,
'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [],
'documents' => $documents,
'notes' => $notes,
'history' => $resolvedHistory,
@@ -282,6 +306,7 @@ final class OrdersController
$itemsPreview = is_array($row['items_preview'] ?? null) ? $row['items_preview'] : [];
return [
'id' => (int) ($row['id'] ?? 0),
'order_ref' => '<div class="orders-ref">'
. '<div class="orders-ref__main"><a href="/orders/' . (int) ($row['id'] ?? 0) . '">'
. htmlspecialchars($internalOrderNumber !== '' ? $internalOrderNumber : ('#' . (string) ($row['id'] ?? 0)), ENT_QUOTES, 'UTF-8')

View 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);
}
}

View 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);
}
}

View 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]);
}
}

View 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 : [];
}
}

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
namespace App\Modules\Settings;
use AppCorexceptionsApaczkaApiException;
use App\Core\Exceptions\ApaczkaApiException;
final class ApaczkaApiClient
{

View 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');
}
}

View File

@@ -116,6 +116,8 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
];
}
$carrierLabel = trim((string) ($serviceDefinition['name'] ?? ''));
$packageId = $this->packages->create([
'order_id' => $orderId,
'provider' => 'apaczka',
@@ -123,7 +125,7 @@ final class ApaczkaShipmentService implements ShipmentProviderInterface
'credentials_id' => null,
'command_id' => null,
'status' => 'pending',
'carrier_id' => null,
'carrier_id' => $carrierLabel !== '' ? $carrierLabel : null,
'package_type' => strtoupper(trim((string) ($formData['package_type'] ?? 'PACKAGE'))),
'weight_kg' => $weightKg,
'length_cm' => $lengthCm,

View File

@@ -27,7 +27,8 @@ final class ShipmentController
private readonly ShipmentProviderRegistry $providerRegistry,
private readonly ShipmentPackageRepository $packageRepository,
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', [
'title' => $this->translator->get('shipments.prepare.title') . ' #' . $orderId,
'activeMenu' => 'orders',
@@ -138,6 +147,7 @@ final class ShipmentController
'deliveryMapping' => $deliveryMapping,
'deliveryMappingDiagnostic' => $deliveryMappingDiagnostic,
'inpostServices' => $inpostServices,
'pendingPrintPackageIds' => $this->printJobRepo !== null ? $this->printJobRepo->pendingPackageIds() : [],
], 'layouts/app');
return Response::html($html);
@@ -203,8 +213,8 @@ final class ShipmentController
'user',
$actorName
);
Flash::set('shipment.success', 'Komenda tworzenia przesylki wyslana. Sprawdz status.');
return Response::redirect('/orders/' . $orderId . '/shipment/prepare?check=' . $packageId);
Flash::set('order.success', 'Przesylka utworzona. Sprawdz status w zakladce Przesylki.');
return Response::redirect('/orders/' . $orderId);
} catch (Throwable $exception) {
$this->ordersRepository->recordActivity(
$orderId,