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:
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
|
||||
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;
|
||||
|
||||
use AppCorexceptionsApaczkaApiException;
|
||||
use App\Core\Exceptions\ApaczkaApiException;
|
||||
|
||||
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([
|
||||
'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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user