Add Allegro shipment service and related components

- Implement AllegroShipmentService for managing shipment creation and status checks.
- Create ShipmentController to handle shipment preparation and label downloading.
- Introduce ShipmentPackageRepository for database interactions related to shipment packages.
- Add methods for retrieving delivery services, creating shipments, checking creation status, and downloading labels.
- Implement address validation and token management for Allegro API integration.
This commit is contained in:
2026-03-06 01:06:59 +01:00
parent 9df7a63244
commit 1b5e403c31
46 changed files with 6705 additions and 133 deletions

View File

@@ -107,7 +107,6 @@ final class OrdersController
['key' => 'totals', 'label' => $this->translator->get('orders.fields.totals'), 'sortable' => true, 'sort_key' => 'total_with_tax', 'raw' => true],
['key' => 'shipping', 'label' => $this->translator->get('orders.fields.shipping'), 'raw' => true],
['key' => 'ordered_at', 'label' => $this->translator->get('orders.fields.ordered_at'), 'sortable' => true, 'sort_key' => 'ordered_at'],
['key' => 'source_updated_at', 'label' => $this->translator->get('orders.fields.source_updated_at'), 'sortable' => true, 'sort_key' => 'source_updated_at'],
],
'rows' => $tableRows,
'pagination' => [
@@ -144,11 +143,20 @@ final class OrdersController
$documents = is_array($details['documents'] ?? null) ? $details['documents'] : [];
$notes = is_array($details['notes'] ?? null) ? $details['notes'] : [];
$history = is_array($details['status_history'] ?? null) ? $details['status_history'] : [];
$activityLog = is_array($details['activity_log'] ?? null) ? $details['activity_log'] : [];
$statusCode = (string) (($order['effective_status_id'] ?? '') !== '' ? $order['effective_status_id'] : ($order['external_status_id'] ?? ''));
$statusCounts = $this->orders->statusCounts();
$statusConfig = $this->orders->statusPanelConfig();
$statusLabelMap = $this->statusLabelMap($statusConfig);
$resolvedHistory = $this->resolveHistoryLabels($history, $statusLabelMap);
$allStatuses = $this->buildAllStatusOptions($statusConfig);
$flashSuccess = (string) ($_SESSION['order_flash_success'] ?? '');
$flashError = (string) ($_SESSION['order_flash_error'] ?? '');
unset($_SESSION['order_flash_success'], $_SESSION['order_flash_error']);
$html = $this->template->render('orders/show', [
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
'activeMenu' => 'orders',
@@ -163,14 +171,51 @@ final class OrdersController
'shipments' => $shipments,
'documents' => $documents,
'notes' => $notes,
'history' => $history,
'history' => $resolvedHistory,
'activityLog' => $activityLog,
'statusLabel' => $this->statusLabel($statusCode, $statusLabelMap),
'statusPanel' => $this->buildStatusPanel($statusConfig, $statusCounts, $statusCode),
'allStatuses' => $allStatuses,
'currentStatusCode' => $statusCode,
'flashSuccess' => $flashSuccess,
'flashError' => $flashError,
], 'layouts/app');
return Response::html($html);
}
public function updateStatus(Request $request): Response
{
$orderId = max(0, (int) $request->input('id', 0));
if ($orderId <= 0) {
return Response::html('Not found', 404);
}
$csrfToken = (string) $request->input('_csrf_token', '');
if (!Csrf::validate($csrfToken)) {
$_SESSION['order_flash_error'] = $this->translator->get('auth.errors.csrf_expired');
return Response::redirect('/orders/' . $orderId);
}
$newStatus = trim((string) $request->input('new_status', ''));
if ($newStatus === '') {
$_SESSION['order_flash_error'] = $this->translator->get('orders.details.status_change.status_required');
return Response::redirect('/orders/' . $orderId);
}
$user = $this->auth->user();
$actorName = is_array($user) ? trim((string) ($user['name'] ?? $user['email'] ?? '')) : null;
$success = $this->orders->updateOrderStatus($orderId, $newStatus, 'user', $actorName !== '' ? $actorName : null);
if ($success) {
$_SESSION['order_flash_success'] = $this->translator->get('orders.details.status_change.success');
} else {
$_SESSION['order_flash_error'] = $this->translator->get('orders.details.status_change.failed');
}
return Response::redirect('/orders/' . $orderId);
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
@@ -218,12 +263,12 @@ final class OrdersController
. '<div class="orders-money__main">' . htmlspecialchars($totalWithTax . ' ' . $currency, ENT_QUOTES, 'UTF-8') . '</div>'
. '<div class="orders-money__meta">oplacono: ' . htmlspecialchars($totalPaid . ' ' . $currency, ENT_QUOTES, 'UTF-8') . '</div>'
. '</div>',
'shipping' => '<div class="orders-mini">'
. '<div>wys.: <strong>' . $shipments . '</strong></div>'
. '<div>dok.: <strong>' . $documents . '</strong></div>'
. '</div>',
'shipping' => $this->shippingHtml(
trim((string) ($row['external_carrier_id'] ?? '')),
$shipments,
$documents
),
'ordered_at' => (string) ($row['ordered_at'] ?? ''),
'source_updated_at' => (string) ($row['source_updated_at'] ?? ''),
];
}
@@ -455,9 +500,10 @@ final class OrdersController
$mediaUrl = trim((string) ($item['media_url'] ?? ''));
$thumb = $mediaUrl !== ''
? '<button type="button" class="orders-image-trigger js-order-img-open" data-image-url="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" aria-label="Podglad zdjecia produktu">'
? '<span class="orders-image-hover-wrap">'
. '<img class="orders-product__thumb" src="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" alt="">'
. '</button>'
. '<img class="orders-image-hover-popup" src="' . htmlspecialchars($mediaUrl, ENT_QUOTES, 'UTF-8') . '" alt="">'
. '</span>'
: '<span class="orders-product__thumb orders-product__thumb--empty"></span>';
$html .= '<div class="orders-product">'
@@ -478,6 +524,18 @@ final class OrdersController
return $html;
}
private function shippingHtml(string $deliveryMethod, int $shipments, int $documents): string
{
$html = '<div class="orders-mini">';
if ($deliveryMethod !== '' && !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $deliveryMethod)) {
$html .= '<div class="orders-mini__delivery">' . htmlspecialchars($deliveryMethod, ENT_QUOTES, 'UTF-8') . '</div>';
}
$html .= '<div>wys.: <strong>' . $shipments . '</strong> dok.: <strong>' . $documents . '</strong></div>';
$html .= '</div>';
return $html;
}
private function formatQuantity(float $value): string
{
$rounded = round($value, 3);
@@ -512,4 +570,47 @@ final class OrdersController
'3' => 'zwrocone',
];
}
/**
* @param array<int, array{name:string,color_hex:string,items:array<int, array{code:string,name:string}>}> $config
* @return array<int, array{code:string, name:string, group:string}>
*/
private function buildAllStatusOptions(array $config): array
{
$options = [];
foreach ($config as $group) {
$groupName = trim((string) ($group['name'] ?? ''));
$items = is_array($group['items'] ?? null) ? $group['items'] : [];
foreach ($items as $item) {
$code = strtolower(trim((string) ($item['code'] ?? '')));
if ($code === '') {
continue;
}
$options[] = [
'code' => $code,
'name' => (string) ($item['name'] ?? $code),
'group' => $groupName,
];
}
}
return $options;
}
/**
* @param array<int, array<string, mixed>> $history
* @param array<string, string> $statusLabelMap
* @return array<int, array<string, mixed>>
*/
private function resolveHistoryLabels(array $history, array $statusLabelMap): array
{
return array_map(function (array $entry) use ($statusLabelMap): array {
$fromCode = trim((string) ($entry['from_status_id'] ?? ''));
$toCode = trim((string) ($entry['to_status_id'] ?? ''));
$entry['from_label'] = $fromCode !== '' ? $this->statusLabel($fromCode, $statusLabelMap) : '-';
$entry['to_label'] = $toCode !== '' ? $this->statusLabel($toCode, $statusLabelMap) : '-';
return $entry;
}, $history);
}
}

View File

@@ -118,6 +118,7 @@ final class OrdersRepository
a.name AS buyer_name,
a.email AS buyer_email,
a.city AS buyer_city,
o.external_carrier_id,
(SELECT COUNT(*) FROM order_items oi WHERE oi.order_id = o.id) AS items_count,
(SELECT COALESCE(SUM(oi.quantity), 0) FROM order_items oi WHERE oi.order_id = o.id) AS items_qty,
(SELECT COUNT(*) FROM order_shipments sh WHERE sh.order_id = o.id) AS shipments_count,
@@ -170,6 +171,7 @@ final class OrdersRepository
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
'is_invoice' => (int) ($row['is_invoice'] ?? 0) === 1,
'is_canceled_by_buyer' => (int) ($row['is_canceled_by_buyer'] ?? 0) === 1,
'external_carrier_id' => (string) ($row['external_carrier_id'] ?? ''),
'buyer_name' => (string) ($row['buyer_name'] ?? ''),
'buyer_email' => (string) ($row['buyer_email'] ?? ''),
'buyer_city' => (string) ($row['buyer_city'] ?? ''),
@@ -469,6 +471,8 @@ final class OrdersRepository
$history = [];
}
$activityLog = $this->loadActivityLog($orderId);
return [
'order' => $order,
'addresses' => $addresses,
@@ -478,6 +482,7 @@ final class OrdersRepository
'documents' => $documents,
'notes' => $notes,
'status_history' => $history,
'activity_log' => $activityLog,
];
} catch (Throwable) {
return null;
@@ -636,6 +641,139 @@ final class OrdersRepository
return $this->supportsMappedMedia;
}
/**
* @return array<int, array<string, mixed>>
*/
private function loadActivityLog(int $orderId): array
{
try {
$stmt = $this->pdo->prepare(
'SELECT * FROM order_activity_log
WHERE order_id = :order_id
ORDER BY created_at DESC, id DESC'
);
$stmt->execute(['order_id' => $orderId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return is_array($rows) ? $rows : [];
} catch (Throwable) {
return [];
}
}
/**
* @param array<string, mixed>|null $details
*/
public function recordActivity(
int $orderId,
string $eventType,
string $summary,
?array $details = null,
string $actorType = 'system',
?string $actorName = null
): void {
$stmt = $this->pdo->prepare(
'INSERT INTO order_activity_log
(order_id, event_type, summary, details_json, actor_type, actor_name, created_at)
VALUES
(:order_id, :event_type, :summary, :details_json, :actor_type, :actor_name, NOW())'
);
$stmt->execute([
'order_id' => $orderId,
'event_type' => $eventType,
'summary' => $summary,
'details_json' => $details !== null ? json_encode($details, JSON_UNESCAPED_UNICODE) : null,
'actor_type' => $actorType,
'actor_name' => $actorName,
]);
}
public function recordStatusChange(
int $orderId,
?string $fromStatus,
string $toStatus,
string $changeSource = 'manual',
?string $comment = null,
string $actorType = 'system',
?string $actorName = null
): void {
$stmt = $this->pdo->prepare(
'INSERT INTO order_status_history
(order_id, from_status_id, to_status_id, changed_at, change_source, comment)
VALUES
(:order_id, :from_status_id, :to_status_id, NOW(), :change_source, :comment)'
);
$stmt->execute([
'order_id' => $orderId,
'from_status_id' => $fromStatus,
'to_status_id' => $toStatus,
'change_source' => $changeSource,
'comment' => $comment,
]);
$fromLabel = $fromStatus !== null ? $this->resolveStatusName($fromStatus) : '-';
$toLabel = $this->resolveStatusName($toStatus);
$summary = 'Zmiana statusu: ' . $fromLabel . ' → ' . $toLabel;
$this->recordActivity($orderId, 'status_change', $summary, [
'from_status' => $fromStatus,
'to_status' => $toStatus,
'change_source' => $changeSource,
'comment' => $comment,
], $actorType, $actorName);
}
public function updateOrderStatus(int $orderId, string $newStatusCode, string $actorType = 'user', ?string $actorName = null): bool
{
try {
$stmt = $this->pdo->prepare('SELECT external_status_id FROM orders WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $orderId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
return false;
}
$oldStatus = trim((string) ($row['external_status_id'] ?? ''));
$update = $this->pdo->prepare('UPDATE orders SET external_status_id = :status, updated_at = NOW() WHERE id = :id');
$update->execute(['status' => $newStatusCode, 'id' => $orderId]);
$this->recordStatusChange(
$orderId,
$oldStatus !== '' ? $oldStatus : null,
$newStatusCode,
'manual',
null,
$actorType,
$actorName
);
return true;
} catch (Throwable) {
return false;
}
}
private function resolveStatusName(string $code): string
{
$normalized = strtolower(trim($code));
if ($normalized === '') {
return $code;
}
try {
$stmt = $this->pdo->prepare('SELECT name FROM order_statuses WHERE LOWER(code) = :code LIMIT 1');
$stmt->execute(['code' => $normalized]);
$name = $stmt->fetchColumn();
if (is_string($name) && trim($name) !== '') {
return trim($name);
}
} catch (Throwable) {
}
return $code;
}
private function normalizeColorHex(string $value): string
{
$trimmed = trim($value);