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:
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 : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user